Merge tag '13.11.0' into io
# Conflicts: # packages/backend/src/server/ServerService.ts # packages/backend/src/server/api/endpoints/notes/timeline.ts
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { defineAsyncComponent, reactive } from 'vue';
|
||||
import { defineAsyncComponent, reactive, ref } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import { showSuspendedDialog } from './scripts/show-suspended-dialog';
|
||||
import { i18n } from './i18n';
|
||||
@@ -7,6 +7,7 @@ import { del, get, set } from '@/scripts/idb-proxy';
|
||||
import { apiUrl } from '@/config';
|
||||
import { waiting, api, popup, popupMenu, success, alert } from '@/os';
|
||||
import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
|
||||
import { MenuButton } from './types/menu';
|
||||
|
||||
// TODO: 他のタブと永続化されたstateを同期
|
||||
|
||||
@@ -26,11 +27,11 @@ export function incNotesCount() {
|
||||
}
|
||||
|
||||
export async function signout() {
|
||||
if (!$i) return;
|
||||
|
||||
waiting();
|
||||
miLocalStorage.removeItem('account');
|
||||
|
||||
await removeAccount($i.id);
|
||||
|
||||
const accounts = await getAccounts();
|
||||
|
||||
//#region Remove service worker registration
|
||||
@@ -76,15 +77,19 @@ export async function addAccount(id: Account['id'], token: Account['token']) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAccount(id: Account['id']) {
|
||||
export async function removeAccount(idOrToken: Account['id']) {
|
||||
const accounts = await getAccounts();
|
||||
accounts.splice(accounts.findIndex(x => x.id === id), 1);
|
||||
const i = accounts.findIndex(x => x.id === idOrToken || x.token === idOrToken);
|
||||
if (i !== -1) accounts.splice(i, 1);
|
||||
|
||||
if (accounts.length > 0) await set('accounts', accounts);
|
||||
else await del('accounts');
|
||||
if (accounts.length > 0) {
|
||||
await set('accounts', accounts);
|
||||
} else {
|
||||
await del('accounts');
|
||||
}
|
||||
}
|
||||
|
||||
function fetchAccount(token: string): Promise<Account> {
|
||||
function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Account> {
|
||||
return new Promise((done, fail) => {
|
||||
// Fetch user
|
||||
window.fetch(`${apiUrl}/i`, {
|
||||
@@ -96,44 +101,94 @@ function fetchAccount(token: string): Promise<Account> {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
if (res.error) {
|
||||
if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
|
||||
showSuspendedDialog().then(() => {
|
||||
signout();
|
||||
});
|
||||
} else {
|
||||
alert({
|
||||
.then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => {
|
||||
if (res.status >= 500 && res.status < 600) {
|
||||
// サーバーエラー(5xx)の場合をrejectとする
|
||||
// (認証エラーなど4xxはresolve)
|
||||
return fail2(res);
|
||||
}
|
||||
res.json().then(done2, fail2);
|
||||
}))
|
||||
.then(async res => {
|
||||
if (res.error) {
|
||||
if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
|
||||
// SUSPENDED
|
||||
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
|
||||
await showSuspendedDialog();
|
||||
}
|
||||
} else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') {
|
||||
// USER_IS_DELETED
|
||||
// アカウントが削除されている
|
||||
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
|
||||
await alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.failedToFetchAccountInformation,
|
||||
text: JSON.stringify(res.error),
|
||||
title: i18n.ts.accountDeleted,
|
||||
text: i18n.ts.accountDeletedDescription,
|
||||
});
|
||||
}
|
||||
} else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') {
|
||||
// AUTHENTICATION_FAILED
|
||||
// トークンが無効化されていたりアカウントが削除されたりしている
|
||||
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
|
||||
await alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.tokenRevoked,
|
||||
text: i18n.ts.tokenRevokedDescription,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
res.token = token;
|
||||
done(res);
|
||||
await alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.failedToFetchAccountInformation,
|
||||
text: JSON.stringify(res.error),
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(fail);
|
||||
|
||||
// rejectかつ理由がtrueの場合、削除対象であることを示す
|
||||
fail(true);
|
||||
} else {
|
||||
(res as Account).token = token;
|
||||
done(res as Account);
|
||||
}
|
||||
})
|
||||
.catch(fail);
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAccount(accountData) {
|
||||
export function updateAccount(accountData: Partial<Account>) {
|
||||
if (!$i) return;
|
||||
for (const [key, value] of Object.entries(accountData)) {
|
||||
$i[key] = value;
|
||||
}
|
||||
miLocalStorage.setItem('account', JSON.stringify($i));
|
||||
}
|
||||
|
||||
export function refreshAccount() {
|
||||
return fetchAccount($i.token).then(updateAccount);
|
||||
export async function refreshAccount() {
|
||||
if (!$i) return;
|
||||
return fetchAccount($i.token, $i.id)
|
||||
.then(updateAccount, reason => {
|
||||
if (reason === true) return signout();
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
export async function login(token: Account['token'], redirect?: string) {
|
||||
waiting();
|
||||
const showing = ref(true);
|
||||
popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
|
||||
success: false,
|
||||
showing: showing,
|
||||
}, {}, 'closed');
|
||||
if (_DEV_) console.log('logging as token ', token);
|
||||
const me = await fetchAccount(token);
|
||||
const me = await fetchAccount(token, undefined, true)
|
||||
.catch(reason => {
|
||||
if (reason === true) {
|
||||
// 削除対象の場合
|
||||
removeAccount(token);
|
||||
}
|
||||
|
||||
showing.value = false;
|
||||
throw reason;
|
||||
});
|
||||
miLocalStorage.setItem('account', JSON.stringify(me));
|
||||
document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う
|
||||
await addAccount(me.id, token);
|
||||
@@ -155,6 +210,8 @@ export async function openAccountMenu(opts: {
|
||||
active?: misskey.entities.UserDetailed['id'];
|
||||
onChoose?: (account: misskey.entities.UserDetailed) => void;
|
||||
}, ev: MouseEvent) {
|
||||
if (!$i) return;
|
||||
|
||||
function showSigninDialog() {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
|
||||
done: res => {
|
||||
@@ -175,8 +232,9 @@ export async function openAccountMenu(opts: {
|
||||
|
||||
async function switchAccount(account: misskey.entities.UserDetailed) {
|
||||
const storedAccounts = await getAccounts();
|
||||
const token = storedAccounts.find(x => x.id === account.id).token;
|
||||
switchAccountWithToken(token);
|
||||
const found = storedAccounts.find(x => x.id === account.id);
|
||||
if (found == null) return;
|
||||
switchAccountWithToken(found.token);
|
||||
}
|
||||
|
||||
function switchAccountWithToken(token: string) {
|
||||
@@ -188,7 +246,7 @@ export async function openAccountMenu(opts: {
|
||||
|
||||
function createItem(account: misskey.entities.UserDetailed) {
|
||||
return {
|
||||
type: 'user',
|
||||
type: 'user' as const,
|
||||
user: account,
|
||||
active: opts.active != null ? opts.active === account.id : false,
|
||||
action: () => {
|
||||
@@ -201,22 +259,29 @@ export async function openAccountMenu(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
const accountItemPromises = storedAccounts.map(a => new Promise(res => {
|
||||
const accountItemPromises = storedAccounts.map(a => new Promise<ReturnType<typeof createItem> | MenuButton>(res => {
|
||||
accountsPromise.then(accounts => {
|
||||
const account = accounts.find(x => x.id === a.id);
|
||||
if (account == null) return res(null);
|
||||
if (account == null) return res({
|
||||
type: 'button' as const,
|
||||
text: a.id,
|
||||
action: () => {
|
||||
switchAccountWithToken(a.token);
|
||||
},
|
||||
});
|
||||
|
||||
res(createItem(account));
|
||||
});
|
||||
}));
|
||||
|
||||
if (opts.withExtraOperation) {
|
||||
popupMenu([...[{
|
||||
type: 'link',
|
||||
type: 'link' as const,
|
||||
text: i18n.ts.profile,
|
||||
to: `/@${ $i.username }`,
|
||||
avatar: $i,
|
||||
}, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
|
||||
type: 'parent',
|
||||
type: 'parent' as const,
|
||||
icon: 'ti ti-plus',
|
||||
text: i18n.ts.addAccount,
|
||||
children: [{
|
||||
@@ -227,7 +292,7 @@ export async function openAccountMenu(opts: {
|
||||
action: () => { createAccount(); },
|
||||
}],
|
||||
}, {
|
||||
type: 'link',
|
||||
type: 'link' as const,
|
||||
icon: 'ti ti-users',
|
||||
text: i18n.ts.manageAccounts,
|
||||
to: '/settings/accounts',
|
||||
|
6
packages/frontend/src/cache.ts
Normal file
6
packages/frontend/src/cache.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import * as misskey from 'misskey-js';
|
||||
import { Cache } from '@/scripts/cache';
|
||||
|
||||
export const clipsCache = new Cache<misskey.entities.Clip[]>(Infinity);
|
||||
export const rolesCache = new Cache(Infinity);
|
||||
export const userListsCache = new Cache<misskey.entities.UserList[]>(Infinity);
|
32
packages/frontend/src/components/MkAccountMoved.vue
Normal file
32
packages/frontend/src/components/MkAccountMoved.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<i class="ti ti-plane-departure" style="margin-right: 8px;"></i>
|
||||
{{ i18n.ts.accountMoved }}
|
||||
<MkMention :class="$style.link" :username="acct" :host="host ?? localHost"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MkMention from './MkMention.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { host as localHost } from '@/config';
|
||||
|
||||
defineProps<{
|
||||
acct: string;
|
||||
host: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
padding: 16px;
|
||||
font-size: 90%;
|
||||
background: var(--infoWarnBg);
|
||||
color: var(--error);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,28 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkAnalogClock from './MkAnalogClock.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkAnalogClock,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkAnalogClock v-bind="props" />',
|
||||
};
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAnalogClock>;
|
30
packages/frontend/src/components/MkButton.stories.impl.ts
Normal file
30
packages/frontend/src/components/MkButton.stories.impl.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
/* eslint-disable import/no-duplicates */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkButton from './MkButton.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkButton,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkButton v-bind="props">Text</MkButton>',
|
||||
};
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkButton>;
|
@@ -0,0 +1,2 @@
|
||||
import MkCaptcha from './MkCaptcha.vue';
|
||||
void MkCaptcha;
|
@@ -10,7 +10,8 @@ import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch } from 'vu
|
||||
import { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
type Captcha = {
|
||||
// APIs provided by Captcha services
|
||||
export type Captcha = {
|
||||
render(container: string | Node, options: {
|
||||
readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
|
||||
}): string;
|
||||
@@ -32,7 +33,7 @@ declare global {
|
||||
|
||||
const props = defineProps<{
|
||||
provider: CaptchaProvider;
|
||||
sitekey: string;
|
||||
sitekey: string | null; // null will show error on request
|
||||
modelValue?: string | null;
|
||||
}>();
|
||||
|
||||
|
39
packages/frontend/src/components/MkClipPreview.vue
Normal file
39
packages/frontend/src/components/MkClipPreview.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div :class="$style.root" class="_panel">
|
||||
<b>{{ clip.name }}</b>
|
||||
<div v-if="clip.description" :class="$style.description">{{ clip.description }}</div>
|
||||
<div v-if="clip.lastClippedAt">{{ i18n.ts.updatedAt }}: <MkTime :time="clip.lastClippedAt" mode="detail"/></div>
|
||||
<div :class="$style.user">
|
||||
<MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
defineProps<{
|
||||
clip: any;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.description {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.user {
|
||||
padding-top: 16px;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
|
||||
.userAvatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
</style>
|
@@ -14,10 +14,10 @@
|
||||
</div>
|
||||
</header>
|
||||
<Transition
|
||||
:enter-active-class="$store.state.animation ? $style.transition_toggle_enterActive : ''"
|
||||
:leave-active-class="$store.state.animation ? $style.transition_toggle_leaveActive : ''"
|
||||
:enter-from-class="$store.state.animation ? $style.transition_toggle_enterFrom : ''"
|
||||
:leave-to-class="$store.state.animation ? $style.transition_toggle_leaveTo : ''"
|
||||
:enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
|
||||
:leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
|
||||
:enter-from-class="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''"
|
||||
:leave-to-class="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''"
|
||||
@enter="enter"
|
||||
@after-enter="afterEnter"
|
||||
@leave="leave"
|
||||
@@ -26,7 +26,7 @@
|
||||
<div v-show="showBody" ref="content" :class="[$style.content, { [$style.omitted]: omitted }]">
|
||||
<slot></slot>
|
||||
<button v-if="omitted" :class="$style.fade" class="_button" @click="() => { ignoreOmit = true; omitted = false; }">
|
||||
<span :class="$style.fadeLabel">{{ $ts.showMore }}</span>
|
||||
<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -35,6 +35,8 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -79,6 +81,7 @@ export default defineComponent({
|
||||
showBody: this.expanded,
|
||||
omitted: null,
|
||||
ignoreOmit: false,
|
||||
defaultStore,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
|
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<Transition
|
||||
appear
|
||||
:enter-active-class="$store.state.animation ? $style.transition_fade_enterActive : ''"
|
||||
:leave-active-class="$store.state.animation ? $style.transition_fade_leaveActive : ''"
|
||||
:enter-from-class="$store.state.animation ? $style.transition_fade_enterFrom : ''"
|
||||
:leave-to-class="$store.state.animation ? $style.transition_fade_leaveTo : ''"
|
||||
:enter-active-class="defaultStore.state.animation ? $style.transition_fade_enterActive : ''"
|
||||
:leave-active-class="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''"
|
||||
:enter-from-class="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''"
|
||||
:leave-to-class="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
|
||||
>
|
||||
<div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
|
||||
<MkMenu :items="items" :align="'left'" @close="$emit('closed')"/>
|
||||
@@ -17,6 +17,7 @@ import { onMounted, onBeforeUnmount } from 'vue';
|
||||
import MkMenu from './MkMenu.vue';
|
||||
import { MenuItem } from './types/menu.vue';
|
||||
import contains from '@/scripts/contains';
|
||||
import { defaultStore } from '@/store';
|
||||
import * as os from '@/os';
|
||||
|
||||
const props = defineProps<{
|
||||
|
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, h, PropType, TransitionGroup, useCssModule } from 'vue';
|
||||
import MkAd from '@/components/global/MkAd.vue';
|
||||
import { isDebuggerEnabled, stackTraceInstances } from '@/debug';
|
||||
import { i18n } from '@/i18n';
|
||||
import * as os from '@/os';
|
||||
import { defaultStore } from '@/store';
|
||||
import { MisskeyEntity } from '@/types/date-separated-list';
|
||||
|
||||
@@ -46,7 +48,7 @@ export default defineComponent({
|
||||
|
||||
if (props.items.length === 0) return;
|
||||
|
||||
const renderChildren = () => props.items.map((item, i) => {
|
||||
const renderChildrenImpl = () => props.items.map((item, i) => {
|
||||
if (!slots || !slots.default) return;
|
||||
|
||||
const el = slots.default({
|
||||
@@ -95,6 +97,21 @@ export default defineComponent({
|
||||
}
|
||||
});
|
||||
|
||||
const renderChildren = () => {
|
||||
const children = renderChildrenImpl();
|
||||
if (isDebuggerEnabled(6864)) {
|
||||
const nodes = children.flatMap((node) => node ?? []);
|
||||
const keys = new Set(nodes.map((node) => node.key));
|
||||
if (keys.size !== nodes.length) {
|
||||
const id = crypto.randomUUID();
|
||||
const instances = stackTraceInstances();
|
||||
os.toast(instances.reduce((a, c) => `${a} at ${c.type.name}`, `[DEBUG_6864 (${id})]: ${nodes.length - keys.size} duplicated keys found`));
|
||||
console.warn({ id, debugId: 6864, stack: instances });
|
||||
}
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
function onBeforeLeave(el: HTMLElement) {
|
||||
el.style.top = `${el.offsetTop}px`;
|
||||
el.style.left = `${el.offsetLeft}px`;
|
||||
|
@@ -17,8 +17,8 @@
|
||||
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown">
|
||||
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
|
||||
<template #caption>
|
||||
<span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })" />
|
||||
<span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })" />
|
||||
<span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"/>
|
||||
<span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/>
|
||||
</template>
|
||||
</MkInput>
|
||||
<MkSelect v-if="select" v-model="selectedValue" autofocus>
|
||||
@@ -32,11 +32,11 @@
|
||||
</template>
|
||||
</MkSelect>
|
||||
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
|
||||
<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
|
||||
<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
|
||||
<MkButton v-if="showOkButton" inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
|
||||
<MkButton v-if="showCancelButton || input || select" inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
|
||||
</div>
|
||||
<div v-if="actions" :class="$style.buttons">
|
||||
<MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton>
|
||||
<MkButton v-for="action in actions" :key="action.text" inline rounded :primary="action.primary" :danger="action.danger" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkModal>
|
||||
@@ -84,6 +84,7 @@ const props = withDefaults(defineProps<{
|
||||
actions?: {
|
||||
text: string;
|
||||
primary?: boolean,
|
||||
danger?: boolean,
|
||||
callback: (...args: any[]) => void;
|
||||
}[];
|
||||
showOkButton?: boolean;
|
||||
|
@@ -14,7 +14,7 @@
|
||||
<div :class="$style.text">
|
||||
<I18n :src="i18n.ts.pleaseDonate" tag="span">
|
||||
<template #host>
|
||||
{{ $instance.name ?? host }}
|
||||
{{ instance.name ?? host }}
|
||||
</template>
|
||||
</I18n>
|
||||
<div style="margin-top: 0.2em;">
|
||||
@@ -37,6 +37,7 @@ import { host } from '@/config';
|
||||
import { i18n } from '@/i18n';
|
||||
import * as os from '@/os';
|
||||
import { miLocalStorage } from '@/local-storage';
|
||||
import { instance } from '@/instance';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
|
@@ -32,14 +32,14 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
|
||||
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
|
||||
import bytes from '@/filters/bytes';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
import { $i } from '@/account';
|
||||
import { getDriveFileMenu } from '@/scripts/get-drive-file-menu';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
@@ -60,48 +60,16 @@ const isDragging = ref(false);
|
||||
|
||||
const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`);
|
||||
|
||||
function getMenu() {
|
||||
return [{
|
||||
text: i18n.ts.rename,
|
||||
icon: 'ti ti-forms',
|
||||
action: rename,
|
||||
}, {
|
||||
text: props.file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
|
||||
icon: props.file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-off',
|
||||
action: toggleSensitive,
|
||||
}, {
|
||||
text: i18n.ts.describeFile,
|
||||
icon: 'ti ti-text-caption',
|
||||
action: describe,
|
||||
}, null, {
|
||||
text: i18n.ts.copyUrl,
|
||||
icon: 'ti ti-link',
|
||||
action: copyUrl,
|
||||
}, {
|
||||
type: 'a',
|
||||
href: props.file.url,
|
||||
target: '_blank',
|
||||
text: i18n.ts.download,
|
||||
icon: 'ti ti-download',
|
||||
download: props.file.name,
|
||||
}, null, {
|
||||
text: i18n.ts.delete,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: deleteFile,
|
||||
}];
|
||||
}
|
||||
|
||||
function onClick(ev: MouseEvent) {
|
||||
if (props.selectMode) {
|
||||
emit('chosen', props.file);
|
||||
} else {
|
||||
os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
os.popupMenu(getDriveFileMenu(props.file), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent) {
|
||||
os.contextMenu(getMenu(), ev);
|
||||
os.contextMenu(getDriveFileMenu(props.file), ev);
|
||||
}
|
||||
|
||||
function onDragstart(ev: DragEvent) {
|
||||
@@ -118,62 +86,6 @@ function onDragend() {
|
||||
isDragging.value = false;
|
||||
emit('dragend');
|
||||
}
|
||||
|
||||
function rename() {
|
||||
os.inputText({
|
||||
title: i18n.ts.renameFile,
|
||||
placeholder: i18n.ts.inputNewFileName,
|
||||
default: props.file.name,
|
||||
}).then(({ canceled, result: name }) => {
|
||||
if (canceled) return;
|
||||
os.api('drive/files/update', {
|
||||
fileId: props.file.id,
|
||||
name: name,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function describe() {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
|
||||
default: props.file.comment != null ? props.file.comment : '',
|
||||
file: props.file,
|
||||
}, {
|
||||
done: caption => {
|
||||
os.api('drive/files/update', {
|
||||
fileId: props.file.id,
|
||||
comment: caption.length === 0 ? null : caption,
|
||||
});
|
||||
},
|
||||
}, 'closed');
|
||||
}
|
||||
|
||||
function toggleSensitive() {
|
||||
os.api('drive/files/update', {
|
||||
fileId: props.file.id,
|
||||
isSensitive: !props.file.isSensitive,
|
||||
});
|
||||
}
|
||||
|
||||
function copyUrl() {
|
||||
copyToClipboard(props.file.url);
|
||||
os.success();
|
||||
}
|
||||
/*
|
||||
function addApp() {
|
||||
alert('not implemented yet');
|
||||
}
|
||||
*/
|
||||
async function deleteFile() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.t('driveFileDeleteConfirm', { name: props.file.name }),
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
os.api('drive/files/delete', {
|
||||
fileId: props.file.id,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@@ -8,7 +8,9 @@
|
||||
<button
|
||||
v-for="emoji in emojis"
|
||||
:key="emoji"
|
||||
:data-emoji="emoji"
|
||||
class="_button item"
|
||||
@pointerenter="computeButtonTitle"
|
||||
@click="emit('chosen', emoji, $event)"
|
||||
>
|
||||
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
|
||||
@@ -20,6 +22,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, Ref } from 'vue';
|
||||
import { getEmojiName } from '@/scripts/emojilist';
|
||||
|
||||
const props = defineProps<{
|
||||
emojis: string[] | Ref<string[]>;
|
||||
@@ -33,4 +36,12 @@ const emit = defineEmits<{
|
||||
const emojis = computed(() => Array.isArray(props.emojis) ? props.emojis : props.emojis.value);
|
||||
|
||||
const shown = ref(!!props.initialShown);
|
||||
|
||||
/** @see MkEmojiPicker.vue */
|
||||
function computeButtonTitle(ev: MouseEvent): void {
|
||||
const elm = ev.target as HTMLElement;
|
||||
const emoji = elm.dataset.emoji as string;
|
||||
elm.title = getEmojiName(emoji) ?? emoji;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
@@ -35,8 +35,10 @@
|
||||
<button
|
||||
v-for="emoji in pinned"
|
||||
:key="emoji"
|
||||
:data-emoji="emoji"
|
||||
class="_button item"
|
||||
tabindex="0"
|
||||
@pointerenter="computeButtonTitle"
|
||||
@click="chosen(emoji, $event)"
|
||||
>
|
||||
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
|
||||
@@ -52,6 +54,8 @@
|
||||
v-for="emoji in recentlyUsedEmojis"
|
||||
:key="emoji"
|
||||
class="_button item"
|
||||
:data-emoji="emoji"
|
||||
@pointerenter="computeButtonTitle"
|
||||
@click="chosen(emoji, $event)"
|
||||
>
|
||||
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
|
||||
@@ -90,7 +94,7 @@
|
||||
import { ref, shallowRef, computed, watch, onMounted } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import XSection from '@/components/MkEmojiPicker.section.vue';
|
||||
import { emojilist, emojiCharByCategory, UnicodeEmojiDef, unicodeEmojiCategories as categories } from '@/scripts/emojilist';
|
||||
import { emojilist, emojiCharByCategory, UnicodeEmojiDef, unicodeEmojiCategories as categories, getEmojiName } from '@/scripts/emojilist';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
import * as os from '@/os';
|
||||
import { isTouchUsing } from '@/scripts/touch';
|
||||
@@ -291,6 +295,13 @@ function getKey(emoji: string | Misskey.entities.CustomEmoji | UnicodeEmojiDef):
|
||||
return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`;
|
||||
}
|
||||
|
||||
/** @see MkEmojiPicker.section.vue */
|
||||
function computeButtonTitle(ev: MouseEvent): void {
|
||||
const elm = ev.target as HTMLElement;
|
||||
const emoji = elm.dataset.emoji as string;
|
||||
elm.title = getEmojiName(emoji) ?? emoji;
|
||||
}
|
||||
|
||||
function chosen(emoji: any, ev?: MouseEvent) {
|
||||
const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined;
|
||||
if (el) {
|
||||
|
@@ -9,7 +9,7 @@
|
||||
</button>
|
||||
</header>
|
||||
<Transition
|
||||
:name="$store.state.animation ? 'folder-toggle' : ''"
|
||||
:name="defaultStore.state.animation ? 'folder-toggle' : ''"
|
||||
@enter="enter"
|
||||
@after-enter="afterEnter"
|
||||
@leave="leave"
|
||||
@@ -26,6 +26,7 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { miLocalStorage } from '@/local-storage';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const miLocalStoragePrefix = 'ui:folder:' as const;
|
||||
|
||||
@@ -44,6 +45,7 @@ export default defineComponent({
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
defaultStore,
|
||||
bg: null,
|
||||
showBody: (this.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${this.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${this.persistKey}`) === 't') : this.expanded,
|
||||
};
|
||||
|
@@ -1,46 +1,52 @@
|
||||
<template>
|
||||
<div ref="rootEl" :class="[$style.root, { [$style.opened]: opened }]">
|
||||
<div :class="$style.header" class="_button" @click="toggle">
|
||||
<div :class="$style.headerIcon"><slot name="icon"></slot></div>
|
||||
<div :class="$style.headerText">
|
||||
<div :class="$style.headerTextMain">
|
||||
<slot name="label"></slot>
|
||||
</div>
|
||||
<div :class="$style.headerTextSub">
|
||||
<slot name="caption"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.headerRight">
|
||||
<span :class="$style.headerRightText"><slot name="suffix"></slot></span>
|
||||
<i v-if="opened" class="ti ti-chevron-up icon"></i>
|
||||
<i v-else class="ti ti-chevron-down icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null }">
|
||||
<Transition
|
||||
:enter-active-class="$store.state.animation ? $style.transition_toggle_enterActive : ''"
|
||||
:leave-active-class="$store.state.animation ? $style.transition_toggle_leaveActive : ''"
|
||||
:enter-from-class="$store.state.animation ? $style.transition_toggle_enterFrom : ''"
|
||||
:leave-to-class="$store.state.animation ? $style.transition_toggle_leaveTo : ''"
|
||||
@enter="enter"
|
||||
@after-enter="afterEnter"
|
||||
@leave="leave"
|
||||
@after-leave="afterLeave"
|
||||
>
|
||||
<KeepAlive>
|
||||
<div v-show="opened">
|
||||
<MkSpacer :margin-min="14" :margin-max="22">
|
||||
<slot></slot>
|
||||
</MkSpacer>
|
||||
<div ref="rootEl" :class="$style.root">
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<div :class="[$style.header, { [$style.opened]: opened }]" class="_button" @click="toggle">
|
||||
<div :class="$style.headerIcon"><slot name="icon"></slot></div>
|
||||
<div :class="$style.headerText">
|
||||
<div :class="$style.headerTextMain">
|
||||
<slot name="label"></slot>
|
||||
</div>
|
||||
<div :class="$style.headerTextSub">
|
||||
<slot name="caption"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</div>
|
||||
<div :class="$style.headerRight">
|
||||
<span :class="$style.headerRightText"><slot name="suffix"></slot></span>
|
||||
<i v-if="opened" class="ti ti-chevron-up icon"></i>
|
||||
<i v-else class="ti ti-chevron-down icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }">
|
||||
<Transition
|
||||
:enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
|
||||
:leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
|
||||
:enter-from-class="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''"
|
||||
:leave-to-class="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''"
|
||||
@enter="enter"
|
||||
@after-enter="afterEnter"
|
||||
@leave="leave"
|
||||
@after-leave="afterLeave"
|
||||
>
|
||||
<KeepAlive>
|
||||
<div v-show="opened">
|
||||
<MkSpacer :margin-min="14" :margin-max="22">
|
||||
<slot></slot>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</div>
|
||||
</MkStickyContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted } from 'vue';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
defaultOpen?: boolean;
|
||||
@@ -117,12 +123,6 @@ onMounted(() => {
|
||||
|
||||
.root {
|
||||
display: block;
|
||||
|
||||
&.opened {
|
||||
> .header {
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -132,6 +132,8 @@ onMounted(() => {
|
||||
box-sizing: border-box;
|
||||
padding: 9px 12px 9px 12px;
|
||||
background: var(--buttonBg);
|
||||
-webkit-backdrop-filter: var(--blur, blur(15px));
|
||||
backdrop-filter: var(--blur, blur(15px));
|
||||
border-radius: 6px;
|
||||
transition: border-radius 0.3s;
|
||||
|
||||
@@ -144,6 +146,10 @@ onMounted(() => {
|
||||
color: var(--accent);
|
||||
background: var(--buttonHoverBg);
|
||||
}
|
||||
|
||||
&.opened {
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.headerUpper {
|
||||
@@ -153,7 +159,7 @@ onMounted(() => {
|
||||
|
||||
.headerLower {
|
||||
color: var(--fgTransparentWeak);
|
||||
font-size: .85em;
|
||||
font-size: .85em;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
@@ -202,7 +208,6 @@ onMounted(() => {
|
||||
background: var(--panel);
|
||||
border-radius: 0 0 6px 6px;
|
||||
container-type: inline-size;
|
||||
overflow: auto;
|
||||
|
||||
&.bgSame {
|
||||
background: var(--bg);
|
||||
|
@@ -18,15 +18,15 @@
|
||||
<div class="_gaps_m">
|
||||
<template v-for="item in Object.keys(form).filter(item => !form[item].hidden)">
|
||||
<MkInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
</MkInput>
|
||||
<MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
</MkTextarea>
|
||||
<MkSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]">
|
||||
@@ -34,15 +34,15 @@
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
</MkSwitch>
|
||||
<MkSelect v-else-if="form[item].type === 'enum'" v-model="values[item]">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<option v-for="item in form[item].enum" :key="item.value" :value="item.value">{{ item.label }}</option>
|
||||
</MkSelect>
|
||||
<MkRadios v-else-if="form[item].type === 'radio'" v-model="values[item]">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<option v-for="item in form[item].options" :key="item.value" :value="item.value">{{ item.label }}</option>
|
||||
</MkRadios>
|
||||
<MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
</MkRange>
|
||||
<MkButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)">
|
||||
@@ -64,6 +64,7 @@ import MkRange from './MkRange.vue';
|
||||
import MkButton from './MkButton.vue';
|
||||
import MkRadios from './MkRadios.vue';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -93,6 +94,7 @@ export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
values: {},
|
||||
i18n,
|
||||
};
|
||||
},
|
||||
|
||||
|
@@ -0,0 +1,85 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { expect } from '@storybook/jest';
|
||||
import { userEvent, waitFor, within } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { galleryPost } from '../../.storybook/fakes';
|
||||
import MkGalleryPostPreview from './MkGalleryPostPreview.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkGalleryPostPreview,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkGalleryPostPreview v-bind="props" />',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
const canvas = within(canvasElement);
|
||||
const links = canvas.getAllByRole('link');
|
||||
await expect(links).toHaveLength(2);
|
||||
await expect(links[0]).toHaveAttribute('href', `/gallery/${galleryPost().id}`);
|
||||
await expect(links[1]).toHaveAttribute('href', `/@${galleryPost().user.username}@${galleryPost().user.host}`);
|
||||
},
|
||||
args: {
|
||||
post: galleryPost(),
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="width:260px"><story /></div>',
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkGalleryPostPreview>;
|
||||
export const Hover = {
|
||||
...Default,
|
||||
async play(context) {
|
||||
await Default.play(context);
|
||||
const canvas = within(context.canvasElement);
|
||||
const links = canvas.getAllByRole('link');
|
||||
await waitFor(() => userEvent.hover(links[0]));
|
||||
},
|
||||
} satisfies StoryObj<typeof MkGalleryPostPreview>;
|
||||
export const HoverThenUnhover = {
|
||||
...Default,
|
||||
async play(context) {
|
||||
await Hover.play(context);
|
||||
const canvas = within(context.canvasElement);
|
||||
const links = canvas.getAllByRole('link');
|
||||
await waitFor(() => userEvent.unhover(links[0]));
|
||||
},
|
||||
} satisfies StoryObj<typeof MkGalleryPostPreview>;
|
||||
export const Sensitive = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
post: galleryPost(true),
|
||||
},
|
||||
} satisfies StoryObj<typeof MkGalleryPostPreview>;
|
||||
export const SensitiveHover = {
|
||||
...Hover,
|
||||
args: {
|
||||
...Hover.args,
|
||||
post: galleryPost(true),
|
||||
},
|
||||
} satisfies StoryObj<typeof MkGalleryPostPreview>;
|
||||
export const SensitiveHoverThenUnhover = {
|
||||
...HoverThenUnhover,
|
||||
args: {
|
||||
...HoverThenUnhover.args,
|
||||
post: galleryPost(true),
|
||||
},
|
||||
} satisfies StoryObj<typeof MkGalleryPostPreview>;
|
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1">
|
||||
<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1" @pointerenter="enterHover" @pointerleave="leaveHover">
|
||||
<div class="thumbnail">
|
||||
<ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/>
|
||||
<ImgWithBlurhash class="img" :hash="post.files[0].blurhash"/>
|
||||
<Transition>
|
||||
<ImgWithBlurhash v-if="show" class="img layered" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/>
|
||||
</Transition>
|
||||
</div>
|
||||
<article>
|
||||
<header>
|
||||
@@ -15,12 +18,25 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import { computed, ref } from 'vue';
|
||||
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const props = defineProps<{
|
||||
post: any;
|
||||
post: misskey.entities.GalleryPost;
|
||||
}>();
|
||||
|
||||
const hover = ref(false);
|
||||
const show = computed(() => defaultStore.state.nsfw === 'ignore' || defaultStore.state.nsfw === 'respect' && !props.post.isSensitive || hover.value);
|
||||
|
||||
function enterHover(): void {
|
||||
hover.value = true;
|
||||
}
|
||||
|
||||
function leaveHover(): void {
|
||||
hover.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -56,6 +72,21 @@ const props = defineProps<{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
|
||||
&.layered {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
&.v-enter-active,
|
||||
&.v-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
&.v-enter-from,
|
||||
&.v-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<input v-model="query" :class="$style.input" type="search" :placeholder="q">
|
||||
<button :class="$style.button" @click="search"><i class="ti ti-search"></i> {{ $ts.searchByGoogle }}</button>
|
||||
<button :class="$style.button" @click="search"><i class="ti ti-search"></i> {{ i18n.ts.searchByGoogle }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
q: string;
|
||||
|
@@ -2,8 +2,8 @@
|
||||
<div class="mk-media-banner">
|
||||
<div v-if="media.isSensitive && hide" class="sensitive" @click="hide = false">
|
||||
<span class="icon"><i class="ti ti-alert-triangle"></i></span>
|
||||
<b>{{ $ts.sensitive }}</b>
|
||||
<span>{{ $ts.clickToShow }}</span>
|
||||
<b>{{ i18n.ts.sensitive }}</b>
|
||||
<span>{{ i18n.ts.clickToShow }}</span>
|
||||
</div>
|
||||
<div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" class="audio">
|
||||
<VuePlyr :options="{ volume: 0.5 }">
|
||||
@@ -33,6 +33,7 @@ import * as misskey from 'misskey-js';
|
||||
import VuePlyr from 'vue-plyr';
|
||||
import { ColdDeviceStorage } from '@/store';
|
||||
import 'vue-plyr/dist/vue-plyr.css';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
media: misskey.entities.DriveFile;
|
||||
|
@@ -3,21 +3,24 @@
|
||||
<ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/>
|
||||
<div :class="$style.hiddenText">
|
||||
<div :class="$style.hiddenTextWrapper">
|
||||
<b style="display: block;"><i class="ti ti-alert-triangle"></i> {{ $ts.sensitive }}</b>
|
||||
<span style="display: block;">{{ $ts.clickToShow }}</span>
|
||||
<b style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}</b>
|
||||
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else :class="$style.visible" :style="defaultStore.state.darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'">
|
||||
<div v-else :class="$style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'">
|
||||
<a
|
||||
:class="$style.imageContainer"
|
||||
:href="image.url"
|
||||
:title="image.name"
|
||||
>
|
||||
<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :cover="false"/>
|
||||
<div v-if="image.type === 'image/gif'" :class="$style.gif">GIF</div>
|
||||
</a>
|
||||
<button v-tooltip="$ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button>
|
||||
<div :class="$style.indicators">
|
||||
<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
|
||||
<div v-if="image.comment" :class="$style.indicator">ALT</div>
|
||||
</div>
|
||||
<button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -27,6 +30,7 @@ import * as misskey from 'misskey-js';
|
||||
import { getStaticImageUrl } from '@/scripts/media-proxy';
|
||||
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
image: misskey.entities.DriveFile;
|
||||
@@ -34,11 +38,12 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
let hide = $ref(true);
|
||||
let darkMode = $ref(defaultStore.state.darkMode);
|
||||
|
||||
const url = (props.raw || defaultStore.state.loadRawImages)
|
||||
? props.image.url
|
||||
: defaultStore.state.disableShowingAnimatedImages
|
||||
? getStaticImageUrl(props.image.thumbnailUrl)
|
||||
? getStaticImageUrl(props.image.url)
|
||||
: props.image.thumbnailUrl;
|
||||
|
||||
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
|
||||
@@ -108,18 +113,25 @@ watch(() => props.image, () => {
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.gif {
|
||||
background-color: var(--fg);
|
||||
.indicators {
|
||||
display: inline-flex;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
opacity: .5;
|
||||
font-size: 14px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
/* Hardcode to black because either --bg or --fg makes it hard to read in dark/light mode */
|
||||
background-color: black;
|
||||
border-radius: 6px;
|
||||
color: var(--accentLighten);
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
left: 12px;
|
||||
opacity: .5;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
top: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
@@ -118,7 +118,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
lightbox.init();
|
||||
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
if (lightbox.pswp && lightbox.pswp.isOpen === true) {
|
||||
lightbox.pswp.close();
|
||||
@@ -239,5 +239,6 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
|
||||
max-height: 8em;
|
||||
overflow-y: auto;
|
||||
text-shadow: var(--bg) 0 0 10px, var(--bg) 0 0 3px, var(--bg) 0 0 3px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div v-if="hide" class="icozogqfvdetwohsdglrbswgrejoxbdj" @click="hide = false">
|
||||
<div>
|
||||
<b><i class="ti ti-alert-triangle"></i> {{ $ts.sensitive }}</b>
|
||||
<span>{{ $ts.clickToShow }}</span>
|
||||
<b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}</b>
|
||||
<span>{{ i18n.ts.clickToShow }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="kkjnbbplepmiyuadieoenjgutgcmtsvu">
|
||||
@@ -28,6 +28,7 @@ import * as misskey from 'misskey-js';
|
||||
import VuePlyr from 'vue-plyr';
|
||||
import { defaultStore } from '@/store';
|
||||
import 'vue-plyr/dist/vue-plyr.css';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
video: misskey.entities.DriveFile;
|
||||
|
@@ -3,7 +3,7 @@
|
||||
<img :class="$style.icon" :src="`/avatar/@${username}@${host}`" alt="">
|
||||
<span>
|
||||
<span :class="$style.username">@{{ username }}</span>
|
||||
<span v-if="(host != localHost) || $store.state.showFullAcct" :class="$style.host">@{{ toUnicode(host) }}</span>
|
||||
<span v-if="(host != localHost) || defaultStore.state.showFullAcct" :class="$style.host">@{{ toUnicode(host) }}</span>
|
||||
</span>
|
||||
</MkA>
|
||||
</template>
|
||||
@@ -14,6 +14,7 @@ import { } from 'vue';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { host as localHost } from '@/config';
|
||||
import { $i } from '@/account';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const props = defineProps<{
|
||||
username: string;
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div role="menu">
|
||||
<div
|
||||
ref="itemsEl" v-hotkey="keymap"
|
||||
class="_popup _shadow"
|
||||
@@ -8,37 +8,37 @@
|
||||
@contextmenu.self="e => e.preventDefault()"
|
||||
>
|
||||
<template v-for="(item, i) in items2">
|
||||
<div v-if="item === null" :class="$style.divider"></div>
|
||||
<span v-else-if="item.type === 'label'" :class="[$style.label, $style.item]">
|
||||
<div v-if="item === null" role="separator" :class="$style.divider"></div>
|
||||
<span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]">
|
||||
<span>{{ item.text }}</span>
|
||||
</span>
|
||||
<span v-else-if="item.type === 'pending'" :tabindex="i" :class="[$style.pending, $style.item]">
|
||||
<span v-else-if="item.type === 'pending'" role="menuitem" :tabindex="i" :class="[$style.pending, $style.item]">
|
||||
<span><MkEllipsis/></span>
|
||||
</span>
|
||||
<MkA v-else-if="item.type === 'link'" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<MkA v-else-if="item.type === 'link'" role="menuitem" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||
<span>{{ item.text }}</span>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
</MkA>
|
||||
<a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<a v-else-if="item.type === 'a'" role="menuitem" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||
<span>{{ item.text }}</span>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
</a>
|
||||
<button v-else-if="item.type === 'user'" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<button v-else-if="item.type === 'user'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
</button>
|
||||
<span v-else-if="item.type === 'switch'" :tabindex="i" :class="$style.item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<span v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" :class="$style.item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<MkSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</MkSwitch>
|
||||
</span>
|
||||
<button v-else-if="item.type === 'parent'" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)">
|
||||
<button v-else-if="item.type === 'parent'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)">
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||
<span>{{ item.text }}</span>
|
||||
<span :class="$style.caret"><i class="ti ti-chevron-right ti-fw"></i></span>
|
||||
</button>
|
||||
<button v-else :tabindex="i" class="_button" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||
<span>{{ item.text }}</span>
|
||||
|
@@ -8,7 +8,7 @@
|
||||
:duration="transitionDuration" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened"
|
||||
>
|
||||
<div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
|
||||
<div class="_modalBg data-cy-bg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent, 'data-cy-transparent': isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
|
||||
<div data-cy-bg :data-cy-transparent="isEnableBgTransparent" class="_modalBg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
|
||||
<div ref="content" :class="[$style.content, { [$style.fixed]: fixed }]" :style="{ zIndex }" @click.self="onBgClick">
|
||||
<slot :max-height="maxHeight" :type="type"></slot>
|
||||
</div>
|
||||
|
@@ -2,7 +2,7 @@
|
||||
<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
|
||||
<div ref="rootEl" class="hrmcaedk" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }">
|
||||
<div class="header" @contextmenu="onContextmenu">
|
||||
<button v-if="history.length > 0" v-tooltip="$ts.goBack" class="_button" @click="back()"><i class="ti ti-arrow-left"></i></button>
|
||||
<button v-if="history.length > 0" v-tooltip="i18n.ts.goBack" class="_button" @click="back()"><i class="ti ti-arrow-left"></i></button>
|
||||
<span v-else style="display: inline-block; width: 20px"></span>
|
||||
<span v-if="pageMetadata?.value" class="title">
|
||||
<i v-if="pageMetadata?.value.icon" class="icon" :class="pageMetadata?.value.icon"></i>
|
||||
|
@@ -31,7 +31,7 @@
|
||||
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
|
||||
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
|
||||
</span>
|
||||
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span>
|
||||
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
|
||||
<span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,7 +57,7 @@
|
||||
<div v-if="translating || translation" :class="$style.translation">
|
||||
<MkLoading v-if="translating" mini/>
|
||||
<div v-else :class="$style.translated">
|
||||
<b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}: </b>
|
||||
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
|
||||
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,11 +103,15 @@
|
||||
<i class="ti ti-ban"></i>
|
||||
</button>
|
||||
<button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.footerButton" class="_button" @mousedown="react()">
|
||||
<i class="ti ti-plus"></i>
|
||||
<i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
|
||||
<i v-else class="ti ti-plus"></i>
|
||||
</button>
|
||||
<button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click="undoReact(appearNote)">
|
||||
<i class="ti ti-minus"></i>
|
||||
</button>
|
||||
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()">
|
||||
<i class="ti ti-paperclip"></i>
|
||||
</button>
|
||||
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="menu()">
|
||||
<i class="ti ti-dots"></i>
|
||||
</button>
|
||||
@@ -150,7 +154,7 @@ import { reactionPicker } from '@/scripts/reaction-picker';
|
||||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
|
||||
import { $i } from '@/account';
|
||||
import { i18n } from '@/i18n';
|
||||
import { getNoteMenu } from '@/scripts/get-note-menu';
|
||||
import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu';
|
||||
import { useNoteCapture } from '@/scripts/use-note-capture';
|
||||
import { deepClone } from '@/scripts/clone';
|
||||
import { useTooltip } from '@/scripts/use-tooltip';
|
||||
@@ -165,6 +169,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const inChannel = inject('inChannel', null);
|
||||
const currentClip = inject<Ref<misskey.entities.Clip> | null>('currentClip', null);
|
||||
|
||||
let note = $ref(deepClone(props.note));
|
||||
|
||||
@@ -191,6 +196,7 @@ const menuButton = shallowRef<HTMLElement>();
|
||||
const renoteButton = shallowRef<HTMLElement>();
|
||||
const renoteTime = shallowRef<HTMLElement>();
|
||||
const reactButton = shallowRef<HTMLElement>();
|
||||
const clipButton = shallowRef<HTMLElement>();
|
||||
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
|
||||
const isMyRenote = $i && ($i.id === note.userId);
|
||||
const showContent = ref(false);
|
||||
@@ -329,18 +335,32 @@ function reply(viaKeyboard = false): void {
|
||||
|
||||
function react(viaKeyboard = false): void {
|
||||
pleaseLogin();
|
||||
blur();
|
||||
reactionPicker.show(reactButton.value, reaction => {
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
os.api('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
reaction: '❤️',
|
||||
});
|
||||
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
|
||||
claimAchievement('reactWithoutRead');
|
||||
const el = reactButton.value as HTMLElement | null | undefined;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
}
|
||||
}, () => {
|
||||
focus();
|
||||
});
|
||||
} else {
|
||||
blur();
|
||||
reactionPicker.show(reactButton.value, reaction => {
|
||||
os.api('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
|
||||
claimAchievement('reactWithoutRead');
|
||||
}
|
||||
}, () => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function undoReact(note): void {
|
||||
@@ -351,8 +371,6 @@ function undoReact(note): void {
|
||||
});
|
||||
}
|
||||
|
||||
const currentClipPage = inject<Ref<misskey.entities.Clip> | null>('currentClipPage', null);
|
||||
|
||||
function onContextmenu(ev: MouseEvent): void {
|
||||
const isLink = (el: HTMLElement) => {
|
||||
if (el.tagName === 'A') return true;
|
||||
@@ -367,16 +385,20 @@ function onContextmenu(ev: MouseEvent): void {
|
||||
ev.preventDefault();
|
||||
react();
|
||||
} else {
|
||||
os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), ev).then(focus);
|
||||
os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }), ev).then(focus);
|
||||
}
|
||||
}
|
||||
|
||||
function menu(viaKeyboard = false): void {
|
||||
os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, {
|
||||
os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }), menuButton.value, {
|
||||
viaKeyboard,
|
||||
}).then(focus);
|
||||
}
|
||||
|
||||
async function clip() {
|
||||
os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
function showRenoteMenu(viaKeyboard = false): void {
|
||||
if (!isMyRenote) return;
|
||||
os.popupMenu([{
|
||||
|
@@ -30,7 +30,7 @@
|
||||
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
|
||||
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
|
||||
</span>
|
||||
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span>
|
||||
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<article class="article" @contextmenu.stop="onContextmenu">
|
||||
@@ -48,7 +48,7 @@
|
||||
<i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i>
|
||||
<i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
|
||||
</span>
|
||||
<span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span>
|
||||
<span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="username"><MkAcct :user="appearNote.user"/></div>
|
||||
@@ -70,7 +70,7 @@
|
||||
<div v-if="translating || translation" class="translation">
|
||||
<MkLoading v-if="translating" mini/>
|
||||
<div v-else class="translated">
|
||||
<b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}: </b>
|
||||
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
|
||||
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,11 +108,15 @@
|
||||
<i class="ti ti-ban"></i>
|
||||
</button>
|
||||
<button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @mousedown="react()">
|
||||
<i class="ti ti-plus"></i>
|
||||
<i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
|
||||
<i v-else class="ti ti-plus"></i>
|
||||
</button>
|
||||
<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
|
||||
<i class="ti ti-minus"></i>
|
||||
</button>
|
||||
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="button _button" @mousedown="clip()">
|
||||
<i class="ti ti-paperclip"></i>
|
||||
</button>
|
||||
<button ref="menuButton" class="button _button" @mousedown="menu()">
|
||||
<i class="ti ti-dots"></i>
|
||||
</button>
|
||||
@@ -155,7 +159,7 @@ import { reactionPicker } from '@/scripts/reaction-picker';
|
||||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
|
||||
import { $i } from '@/account';
|
||||
import { i18n } from '@/i18n';
|
||||
import { getNoteMenu } from '@/scripts/get-note-menu';
|
||||
import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu';
|
||||
import { useNoteCapture } from '@/scripts/use-note-capture';
|
||||
import { deepClone } from '@/scripts/clone';
|
||||
import { useTooltip } from '@/scripts/use-tooltip';
|
||||
@@ -195,6 +199,7 @@ const menuButton = shallowRef<HTMLElement>();
|
||||
const renoteButton = shallowRef<HTMLElement>();
|
||||
const renoteTime = shallowRef<HTMLElement>();
|
||||
const reactButton = shallowRef<HTMLElement>();
|
||||
const clipButton = shallowRef<HTMLElement>();
|
||||
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
|
||||
const isMyRenote = $i && ($i.id === note.userId);
|
||||
const showContent = ref(false);
|
||||
@@ -323,18 +328,32 @@ function reply(viaKeyboard = false): void {
|
||||
|
||||
function react(viaKeyboard = false): void {
|
||||
pleaseLogin();
|
||||
blur();
|
||||
reactionPicker.show(reactButton.value, reaction => {
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
os.api('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
reaction: '❤️',
|
||||
});
|
||||
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
|
||||
claimAchievement('reactWithoutRead');
|
||||
const el = reactButton.value as HTMLElement | null | undefined;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
}
|
||||
}, () => {
|
||||
focus();
|
||||
});
|
||||
} else {
|
||||
blur();
|
||||
reactionPicker.show(reactButton.value, reaction => {
|
||||
os.api('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
|
||||
claimAchievement('reactWithoutRead');
|
||||
}
|
||||
}, () => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function undoReact(note): void {
|
||||
@@ -369,6 +388,10 @@ function menu(viaKeyboard = false): void {
|
||||
}).then(focus);
|
||||
}
|
||||
|
||||
async function clip() {
|
||||
os.popupMenu(await getNoteClipMenu({ note: note, isDeleted }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
function showRenoteMenu(viaKeyboard = false): void {
|
||||
if (!isMyRenote) return;
|
||||
os.popupMenu([{
|
||||
|
@@ -17,7 +17,7 @@
|
||||
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
|
||||
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
|
||||
</span>
|
||||
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span>
|
||||
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
|
||||
<span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
|
||||
</div>
|
||||
</header>
|
||||
|
@@ -3,7 +3,7 @@
|
||||
<MkAvatar :class="$style.avatar" :user="$i" link preview/>
|
||||
<div :class="$style.main">
|
||||
<div :class="$style.header">
|
||||
<MkUserName :user="$i"/>
|
||||
<MkUserName :user="$i" :nowrap="true"/>
|
||||
</div>
|
||||
<div>
|
||||
<div :class="$style.content">
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import { $i } from '@/account';
|
||||
|
||||
const props = defineProps<{
|
||||
text: string;
|
||||
@@ -49,6 +50,9 @@ const props = defineProps<{
|
||||
.header {
|
||||
margin-bottom: 2px;
|
||||
font-weight: bold;
|
||||
width: 100%;
|
||||
overflow: clip;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@container (min-width: 350px) {
|
||||
|
@@ -22,6 +22,7 @@ import * as misskey from 'misskey-js';
|
||||
import MkNoteHeader from '@/components/MkNoteHeader.vue';
|
||||
import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
|
||||
import MkCwButton from '@/components/MkCwButton.vue';
|
||||
import { $i } from '@/account';
|
||||
|
||||
const props = defineProps<{
|
||||
note: misskey.entities.Note;
|
||||
@@ -47,6 +48,9 @@ const showContent = $ref(false);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
position: sticky !important;
|
||||
top: calc(16px + var(--stickyTop, 0px));
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
|
@@ -33,6 +33,7 @@ import MkCwButton from '@/components/MkCwButton.vue';
|
||||
import { notePage } from '@/filters/note';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
import { $i } from '@/account';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: misskey.entities.Note;
|
||||
|
@@ -19,7 +19,7 @@
|
||||
:ad="true"
|
||||
:class="$style.notes"
|
||||
>
|
||||
<XNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/>
|
||||
<MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/>
|
||||
</MkDateSeparatedList>
|
||||
</div>
|
||||
</template>
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { shallowRef } from 'vue';
|
||||
import XNote from '@/components/MkNote.vue';
|
||||
import MkNote from '@/components/MkNote.vue';
|
||||
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
@@ -69,8 +69,9 @@
|
||||
<span v-else-if="notification.type === 'followRequestAccepted'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span>
|
||||
<template v-else-if="notification.type === 'receiveFollowRequest'">
|
||||
<span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}</span>
|
||||
<div v-if="full && !followRequestDone">
|
||||
<button class="_textButton" @click="acceptFollowRequest()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ i18n.ts.reject }}</button>
|
||||
<div v-if="full && !followRequestDone" :class="$style.followRequestCommands">
|
||||
<MkButton :class="$style.followRequestCommandButton" rounded primary @click="acceptFollowRequest()"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton>
|
||||
<MkButton :class="$style.followRequestCommandButton" rounded danger @click="rejectFollowRequest()"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else-if="notification.type === 'app'" :class="$style.text">
|
||||
@@ -82,17 +83,17 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { ref, shallowRef } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
||||
import MkFollowButton from '@/components/MkFollowButton.vue';
|
||||
import XReactionTooltip from '@/components/MkReactionTooltip.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { getNoteSummary } from '@/scripts/get-note-summary';
|
||||
import { notePage } from '@/filters/note';
|
||||
import { userPage } from '@/filters/user';
|
||||
import { i18n } from '@/i18n';
|
||||
import * as os from '@/os';
|
||||
import { stream } from '@/stream';
|
||||
import { useTooltip } from '@/scripts/use-tooltip';
|
||||
import { $i } from '@/account';
|
||||
|
||||
@@ -108,35 +109,6 @@ const props = withDefaults(defineProps<{
|
||||
const elRef = shallowRef<HTMLElement>(null);
|
||||
const reactionRef = ref(null);
|
||||
|
||||
let readObserver: IntersectionObserver | undefined;
|
||||
let connection;
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.notification.isRead) {
|
||||
readObserver = new IntersectionObserver((entries, observer) => {
|
||||
if (!entries.some(entry => entry.isIntersecting)) return;
|
||||
stream.send('readNotification', {
|
||||
id: props.notification.id,
|
||||
});
|
||||
observer.disconnect();
|
||||
});
|
||||
|
||||
readObserver.observe(elRef.value);
|
||||
|
||||
connection = stream.useChannel('main');
|
||||
connection.on('readAllNotifications', () => readObserver.disconnect());
|
||||
|
||||
watch(props.notification.isRead, () => {
|
||||
readObserver.disconnect();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (readObserver) readObserver.disconnect();
|
||||
if (connection) connection.dispose();
|
||||
});
|
||||
|
||||
const followRequestDone = ref(false);
|
||||
|
||||
const acceptFollowRequest = () => {
|
||||
@@ -294,6 +266,16 @@ useTooltip(reactionRef, (showing) => {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.followRequestCommands {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-width: 300px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.followRequestCommandButton {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@container (max-width: 600px) {
|
||||
.root {
|
||||
padding: 16px;
|
||||
|
@@ -9,7 +9,7 @@
|
||||
|
||||
<template #default="{ items: notifications }">
|
||||
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :no-gap="true">
|
||||
<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
|
||||
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
|
||||
<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
|
||||
</MkDateSeparatedList>
|
||||
</template>
|
||||
@@ -21,7 +21,7 @@ import { onUnmounted, onMounted, computed, shallowRef } from 'vue';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
import XNotification from '@/components/MkNotification.vue';
|
||||
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
||||
import XNote from '@/components/MkNote.vue';
|
||||
import MkNote from '@/components/MkNote.vue';
|
||||
import { stream } from '@/stream';
|
||||
import { $i } from '@/account';
|
||||
import { i18n } from '@/i18n';
|
||||
@@ -29,7 +29,6 @@ import { notificationTypes } from '@/const';
|
||||
|
||||
const props = defineProps<{
|
||||
includeTypes?: typeof notificationTypes[number][];
|
||||
unreadOnly?: boolean;
|
||||
}>();
|
||||
|
||||
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
||||
@@ -40,23 +39,17 @@ const pagination: Paging = {
|
||||
params: computed(() => ({
|
||||
includeTypes: props.includeTypes ?? undefined,
|
||||
excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes,
|
||||
unreadOnly: props.unreadOnly,
|
||||
})),
|
||||
};
|
||||
|
||||
const onNotification = (notification) => {
|
||||
const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type);
|
||||
if (isMuted || document.visibilityState === 'visible') {
|
||||
stream.send('readNotification', {
|
||||
id: notification.id,
|
||||
});
|
||||
stream.send('readNotification');
|
||||
}
|
||||
|
||||
if (!isMuted) {
|
||||
pagingComponent.value.prepend({
|
||||
...notification,
|
||||
isRead: document.visibilityState === 'visible',
|
||||
});
|
||||
pagingComponent.value.prepend(notification);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,30 +58,6 @@ let connection;
|
||||
onMounted(() => {
|
||||
connection = stream.useChannel('main');
|
||||
connection.on('notification', onNotification);
|
||||
connection.on('readAllNotifications', () => {
|
||||
if (pagingComponent.value) {
|
||||
for (const item of pagingComponent.value.queue) {
|
||||
item.isRead = true;
|
||||
}
|
||||
for (const item of pagingComponent.value.items) {
|
||||
item.isRead = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
connection.on('readNotifications', notificationIds => {
|
||||
if (pagingComponent.value) {
|
||||
for (let i = 0; i < pagingComponent.value.queue.length; i++) {
|
||||
if (notificationIds.includes(pagingComponent.value.queue[i].id)) {
|
||||
pagingComponent.value.queue[i].isRead = true;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < (pagingComponent.value.items || []).length; i++) {
|
||||
if (notificationIds.includes(pagingComponent.value.items[i].id)) {
|
||||
pagingComponent.value.items[i].isRead = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
@@ -2,16 +2,17 @@
|
||||
<div ref="content" :class="[$style.content, { [$style.omitted]: omitted }]">
|
||||
<slot></slot>
|
||||
<button v-if="omitted" :class="$style.fade" class="_button" @click="() => { ignoreOmit = true; omitted = false; }">
|
||||
<span :class="$style.fadeLabel">{{ $ts.showMore }}</span>
|
||||
<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
maxHeight: number;
|
||||
maxHeight?: number;
|
||||
}>(), {
|
||||
maxHeight: 200,
|
||||
});
|
||||
|
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<Transition
|
||||
:enter-active-class="$store.state.animation ? $style.transition_fade_enterActive : ''"
|
||||
:leave-active-class="$store.state.animation ? $style.transition_fade_leaveActive : ''"
|
||||
:enter-from-class="$store.state.animation ? $style.transition_fade_enterFrom : ''"
|
||||
:leave-to-class="$store.state.animation ? $style.transition_fade_leaveTo : ''"
|
||||
:enter-active-class="defaultStore.state.animation ? $style.transition_fade_enterActive : ''"
|
||||
:leave-active-class="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''"
|
||||
:enter-from-class="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''"
|
||||
:leave-to-class="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
|
||||
mode="out-in"
|
||||
>
|
||||
<MkLoading v-if="fetching"/>
|
||||
@@ -21,14 +21,14 @@
|
||||
|
||||
<div v-else ref="rootEl">
|
||||
<div v-show="pagination.reversed && more" key="_more_" class="_margin">
|
||||
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMoreAhead">
|
||||
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead">
|
||||
{{ i18n.ts.loadMore }}
|
||||
</MkButton>
|
||||
<MkLoading v-else class="loading"/>
|
||||
</div>
|
||||
<slot :items="items" :fetching="fetching || moreFetching"></slot>
|
||||
<div v-show="!pagination.reversed && more" key="_more_" class="_margin">
|
||||
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
|
||||
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
|
||||
{{ i18n.ts.loadMore }}
|
||||
</MkButton>
|
||||
<MkLoading v-else class="loading"/>
|
||||
@@ -163,21 +163,22 @@ async function init(): Promise<void> {
|
||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await os.api(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit: props.pagination.noPaging ? (props.pagination.limit || 10) : (props.pagination.limit || 10) + 1,
|
||||
limit: props.pagination.limit ?? 10,
|
||||
}).then(res => {
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
const item = res[i];
|
||||
if (i === 3) item._shouldInsertAd_ = true;
|
||||
}
|
||||
if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) {
|
||||
res.pop();
|
||||
|
||||
if (res.length === 0 || props.pagination.noPaging) {
|
||||
items.value = res;
|
||||
more.value = false;
|
||||
} else {
|
||||
if (props.pagination.reversed) moreFetching.value = true;
|
||||
items.value = res;
|
||||
more.value = true;
|
||||
} else {
|
||||
items.value = res;
|
||||
more.value = false;
|
||||
}
|
||||
|
||||
offset.value = res.length;
|
||||
error.value = false;
|
||||
fetching.value = false;
|
||||
@@ -198,7 +199,7 @@ const fetchMore = async (): Promise<void> => {
|
||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await os.api(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit: SECOND_FETCH_LIMIT + 1,
|
||||
limit: SECOND_FETCH_LIMIT,
|
||||
...(props.pagination.offsetMode ? {
|
||||
offset: offset.value,
|
||||
} : {
|
||||
@@ -227,20 +228,7 @@ const fetchMore = async (): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
if (res.length > SECOND_FETCH_LIMIT) {
|
||||
res.pop();
|
||||
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
});
|
||||
} else {
|
||||
items.value = items.value.concat(res);
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
}
|
||||
} else {
|
||||
if (res.length === 0) {
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = false;
|
||||
@@ -251,6 +239,17 @@ const fetchMore = async (): Promise<void> => {
|
||||
more.value = false;
|
||||
moreFetching.value = false;
|
||||
}
|
||||
} else {
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
});
|
||||
} else {
|
||||
items.value = items.value.concat(res);
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
}
|
||||
}
|
||||
offset.value += res.length;
|
||||
}, err => {
|
||||
@@ -264,20 +263,19 @@ const fetchMoreAhead = async (): Promise<void> => {
|
||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await os.api(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit: SECOND_FETCH_LIMIT + 1,
|
||||
limit: SECOND_FETCH_LIMIT,
|
||||
...(props.pagination.offsetMode ? {
|
||||
offset: offset.value,
|
||||
} : {
|
||||
sinceId: items.value[items.value.length - 1].id,
|
||||
}),
|
||||
}).then(res => {
|
||||
if (res.length > SECOND_FETCH_LIMIT) {
|
||||
res.pop();
|
||||
items.value = items.value.concat(res);
|
||||
more.value = true;
|
||||
} else {
|
||||
if (res.length === 0) {
|
||||
items.value = items.value.concat(res);
|
||||
more.value = false;
|
||||
} else {
|
||||
items.value = items.value.concat(res);
|
||||
more.value = true;
|
||||
}
|
||||
offset.value += res.length;
|
||||
moreFetching.value = false;
|
||||
|
@@ -6,12 +6,12 @@
|
||||
<span>
|
||||
<template v-if="choice.isVoted"><i class="ti ti-check"></i></template>
|
||||
<Mfm :text="choice.text" :plain="true"/>
|
||||
<span v-if="showResult" class="votes">({{ $t('_poll.votesCount', { n: choice.votes }) }})</span>
|
||||
<span v-if="showResult" class="votes">({{ i18n.t('_poll.votesCount', { n: choice.votes }) }})</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!readOnly">
|
||||
<span>{{ $t('_poll.totalVotes', { n: total }) }}</span>
|
||||
<span>{{ i18n.t('_poll.totalVotes', { n: total }) }}</span>
|
||||
<span> · </span>
|
||||
<a v-if="!closed && !isVoted" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a>
|
||||
<span v-if="isVoted">{{ i18n.ts._poll.voted }}</span>
|
||||
|
@@ -5,7 +5,7 @@
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="(choice, i) in choices" :key="i">
|
||||
<MkInput class="input" small :model-value="choice" :placeholder="$t('_poll.choiceN', { n: i + 1 })" @update:model-value="onInput(i, $event)">
|
||||
<MkInput class="input" small :model-value="choice" :placeholder="i18n.t('_poll.choiceN', { n: i + 1 })" @update:model-value="onInput(i, $event)">
|
||||
</MkInput>
|
||||
<button class="_button" @click="remove(i)">
|
||||
<i class="ti ti-x"></i>
|
||||
|
@@ -7,20 +7,35 @@
|
||||
@drop.stop="onDrop"
|
||||
>
|
||||
<header :class="$style.header">
|
||||
<button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button>
|
||||
<button v-click-anime v-tooltip="i18n.ts.switchAccount" :class="$style.account" class="_button" @click="openAccountMenu">
|
||||
<MkAvatar :user="postAccount ?? $i" :class="$style.avatar"/>
|
||||
</button>
|
||||
<div :class="$style.headerRight">
|
||||
<span :class="[$style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</span>
|
||||
<span v-if="localOnly" :class="$style.localOnly"><i class="ti ti-world-off"></i></span>
|
||||
<button ref="visibilityButton" v-tooltip="i18n.ts.visibility" class="_button" :class="$style.visibility" :disabled="channel != null" @click="setVisibility">
|
||||
<span v-if="visibility === 'public'"><i class="ti ti-world"></i></span>
|
||||
<span v-if="visibility === 'home'"><i class="ti ti-home"></i></span>
|
||||
<span v-if="visibility === 'followers'"><i class="ti ti-lock"></i></span>
|
||||
<span v-if="visibility === 'specified'"><i class="ti ti-mail"></i></span>
|
||||
<div :class="$style.headerLeft">
|
||||
<button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button>
|
||||
<button v-click-anime v-tooltip="i18n.ts.switchAccount" :class="$style.account" class="_button" @click="openAccountMenu">
|
||||
<MkAvatar :user="postAccount ?? $i" :class="$style.avatar"/>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="$style.headerRight">
|
||||
<template v-if="!(channel != null && fixed)">
|
||||
<button v-if="channel == null" ref="visibilityButton" v-click-anime v-tooltip="i18n.ts.visibility" :class="['_button', $style.headerRightItem, $style.visibility]" @click="setVisibility">
|
||||
<span v-if="visibility === 'public'"><i class="ti ti-world"></i></span>
|
||||
<span v-if="visibility === 'home'"><i class="ti ti-home"></i></span>
|
||||
<span v-if="visibility === 'followers'"><i class="ti ti-lock"></i></span>
|
||||
<span v-if="visibility === 'specified'"><i class="ti ti-mail"></i></span>
|
||||
<span :class="$style.headerRightButtonText">{{ i18n.ts._visibility[visibility] }}</span>
|
||||
</button>
|
||||
<button v-else :class="['_button', $style.headerRightItem, $style.visibility]" disabled>
|
||||
<span><i class="ti ti-device-tv"></i></span>
|
||||
<span :class="$style.headerRightButtonText">{{ channel.name }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<button v-click-anime v-tooltip="i18n.ts._visibility.disableFederation" :class="['_button', $style.headerRightItem, $style.localOnly, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified'" @click="toggleLocalOnly">
|
||||
<span v-if="!localOnly"><i class="ti ti-rocket"></i></span>
|
||||
<span v-else><i class="ti ti-rocket-off"></i></span>
|
||||
</button>
|
||||
<button v-click-anime v-tooltip="i18n.ts.reactionAcceptance" :class="['_button', $style.headerRightItem, $style.reactionAcceptance, { [$style.danger]: reactionAcceptance }]" @click="toggleReactionAcceptance">
|
||||
<span v-if="reactionAcceptance === 'likeOnly'"><i class="ti ti-heart"></i></span>
|
||||
<span v-else-if="reactionAcceptance === 'likeOnlyForRemote'"><i class="ti ti-heart-plus"></i></span>
|
||||
<span v-else><i class="ti ti-icons"></i></span>
|
||||
</button>
|
||||
<button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.previewButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button>
|
||||
<button v-click-anime class="_button" :class="[$style.submit, { [$style.submitPosting]: posting }]" :disabled="!canPost" data-cy-open-post-form-submit @click="post">
|
||||
<div :class="$style.submitInner">
|
||||
<template v-if="posted"></template>
|
||||
@@ -31,41 +46,49 @@
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div :class="[$style.form]">
|
||||
<MkNoteSimple v-if="reply" :class="$style.targetNote" :note="reply"/>
|
||||
<MkNoteSimple v-if="renote" :class="$style.targetNote" :note="renote"/>
|
||||
<div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null"><i class="ti ti-x"></i></button></div>
|
||||
<div v-if="visibility === 'specified'" :class="$style.toSpecified">
|
||||
<span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span>
|
||||
<div :class="$style.visibleUsers">
|
||||
<span v-for="u in visibleUsers" :key="u.id" :class="$style.visibleUser">
|
||||
<MkAcct :user="u"/>
|
||||
<button class="_button" style="padding: 4px 8px;" @click="removeVisibleUser(u)"><i class="ti ti-x"></i></button>
|
||||
</span>
|
||||
<button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button>
|
||||
</div>
|
||||
<MkNoteSimple v-if="reply" :class="$style.targetNote" :note="reply"/>
|
||||
<MkNoteSimple v-if="renote" :class="$style.targetNote" :note="renote"/>
|
||||
<div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null"><i class="ti ti-x"></i></button></div>
|
||||
<div v-if="visibility === 'specified'" :class="$style.toSpecified">
|
||||
<span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span>
|
||||
<div :class="$style.visibleUsers">
|
||||
<span v-for="u in visibleUsers" :key="u.id" :class="$style.visibleUser">
|
||||
<MkAcct :user="u"/>
|
||||
<button class="_button" style="padding: 4px 8px;" @click="removeVisibleUser(u)"><i class="ti ti-x"></i></button>
|
||||
</span>
|
||||
<button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button>
|
||||
</div>
|
||||
<MkInfo v-if="localOnly && channel == null" warn :class="$style.disableFederationWarn">{{ i18n.ts.disableFederationWarn }}</MkInfo>
|
||||
<MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
|
||||
<input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown">
|
||||
<textarea ref="textareaEl" v-model="text" :class="[$style.text, { [$style.withCw]: useCw }]" :disabled="posting || posted" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
|
||||
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
||||
<XPostFormAttaches v-model="files" :class="$style.attaches" @detach="detachFile" @change-sensitive="updateFileSensitive" @change-name="updateFileName"/>
|
||||
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
|
||||
<XNotePreview v-if="showPreview" :class="$style.preview" :text="text"/>
|
||||
<footer :class="$style.footer">
|
||||
</div>
|
||||
<MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
|
||||
<input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown">
|
||||
<div :class="[$style.textOuter, { [$style.withCw]: useCw }]">
|
||||
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
|
||||
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
|
||||
</div>
|
||||
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
||||
<XPostFormAttaches v-model="files" :class="$style.attaches" @detach="detachFile" @change-sensitive="updateFileSensitive" @change-name="updateFileName"/>
|
||||
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
|
||||
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text"/>
|
||||
<div v-if="showingOptions" style="padding: 8px 16px;">
|
||||
</div>
|
||||
<footer :class="$style.footer">
|
||||
<div :class="$style.footerLeft">
|
||||
<button v-tooltip="i18n.ts.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFrom"><i class="ti ti-photo-plus"></i></button>
|
||||
<button v-tooltip="i18n.ts.poll" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: poll }]" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button>
|
||||
<button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button>
|
||||
<button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button>
|
||||
<button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button>
|
||||
<button v-tooltip="i18n.ts.emoji" class="_button" :class="$style.footerButton" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
|
||||
<button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugin" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button>
|
||||
</footer>
|
||||
<datalist id="hashtags">
|
||||
<option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"/>
|
||||
</datalist>
|
||||
</div>
|
||||
<button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
|
||||
</div>
|
||||
<div :class="$style.footerRight">
|
||||
<button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.footerButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button>
|
||||
<!--<button v-tooltip="i18n.ts.more" class="_button" :class="$style.footerButton" @click="showingOptions = !showingOptions"><i class="ti ti-dots"></i></button>-->
|
||||
</div>
|
||||
</footer>
|
||||
<datalist id="hashtags">
|
||||
<option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"/>
|
||||
</datalist>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -77,7 +100,7 @@ import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
import { toASCII } from 'punycode/';
|
||||
import * as Acct from 'misskey-js/built/acct';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import XNotePreview from '@/components/MkNotePreview.vue';
|
||||
import MkNotePreview from '@/components/MkNotePreview.vue';
|
||||
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
|
||||
import MkPollEditor from '@/components/MkPollEditor.vue';
|
||||
import { host, url } from '@/config';
|
||||
@@ -103,7 +126,7 @@ const modal = inject('modal');
|
||||
const props = withDefaults(defineProps<{
|
||||
reply?: misskey.entities.Note;
|
||||
renote?: misskey.entities.Note;
|
||||
channel?: any; // TODO
|
||||
channel?: misskey.entities.Channel; // TODO
|
||||
mention?: misskey.entities.User;
|
||||
specified?: misskey.entities.User;
|
||||
initialText?: string;
|
||||
@@ -151,12 +174,14 @@ let visibleUsers = $ref([]);
|
||||
if (props.initialVisibleUsers) {
|
||||
props.initialVisibleUsers.forEach(pushVisibleUser);
|
||||
}
|
||||
let reactionAcceptance = $ref(defaultStore.state.reactionAcceptance);
|
||||
let autocomplete = $ref(null);
|
||||
let draghover = $ref(false);
|
||||
let quoteId = $ref(null);
|
||||
let hasNotSpecifiedMentions = $ref(false);
|
||||
let recentHashtags = $ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]'));
|
||||
let imeText = $ref('');
|
||||
let showingOptions = $ref(false);
|
||||
|
||||
const draftKey = $computed((): string => {
|
||||
let key = props.channel ? `channel:${props.channel.id}` : '';
|
||||
@@ -166,7 +191,7 @@ const draftKey = $computed((): string => {
|
||||
} else if (props.reply) {
|
||||
key += `reply:${props.reply.id}`;
|
||||
} else {
|
||||
key += 'note';
|
||||
key += `note:${$i.id}`;
|
||||
}
|
||||
|
||||
return key;
|
||||
@@ -389,13 +414,14 @@ function upload(file: File, name?: string) {
|
||||
|
||||
function setVisibility() {
|
||||
if (props.channel) {
|
||||
// TODO: information dialog
|
||||
visibility = 'public';
|
||||
localOnly = true; // TODO: チャンネルが連合するようになった折には消す
|
||||
return;
|
||||
}
|
||||
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), {
|
||||
currentVisibility: visibility,
|
||||
currentLocalOnly: localOnly,
|
||||
localOnly: localOnly,
|
||||
src: visibilityButton,
|
||||
}, {
|
||||
changeVisibility: v => {
|
||||
@@ -404,15 +430,65 @@ function setVisibility() {
|
||||
defaultStore.set('visibility', visibility);
|
||||
}
|
||||
},
|
||||
changeLocalOnly: v => {
|
||||
localOnly = v;
|
||||
if (defaultStore.state.rememberNoteVisibility) {
|
||||
defaultStore.set('localOnly', localOnly);
|
||||
}
|
||||
},
|
||||
}, 'closed');
|
||||
}
|
||||
|
||||
async function toggleLocalOnly() {
|
||||
if (props.channel) {
|
||||
visibility = 'public';
|
||||
localOnly = true; // TODO: チャンネルが連合するようになった折には消す
|
||||
return;
|
||||
}
|
||||
|
||||
const neverShowInfo = miLocalStorage.getItem('neverShowLocalOnlyInfo');
|
||||
|
||||
if (!localOnly && neverShowInfo !== 'true') {
|
||||
const confirm = await os.actions({
|
||||
type: 'question',
|
||||
title: i18n.ts.disableFederationConfirm,
|
||||
text: i18n.ts.disableFederationConfirmWarn,
|
||||
actions: [
|
||||
{
|
||||
value: 'yes' as const,
|
||||
text: i18n.ts.disableFederationOk,
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
value: 'neverShow' as const,
|
||||
text: `${i18n.ts.disableFederationOk} (${i18n.ts.neverShow})`,
|
||||
danger: true,
|
||||
},
|
||||
{
|
||||
value: 'no' as const,
|
||||
text: i18n.ts.cancel,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (confirm.canceled) return;
|
||||
if (confirm.result === 'no') return;
|
||||
|
||||
if (confirm.result === 'neverShow') {
|
||||
miLocalStorage.setItem('neverShowLocalOnlyInfo', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
localOnly = !localOnly;
|
||||
}
|
||||
|
||||
async function toggleReactionAcceptance() {
|
||||
const select = await os.select({
|
||||
title: i18n.ts.reactionAcceptance,
|
||||
items: [
|
||||
{ value: null, text: i18n.ts.all },
|
||||
{ value: 'likeOnly' as const, text: i18n.ts.likeOnly },
|
||||
{ value: 'likeOnlyForRemote' as const, text: i18n.ts.likeOnlyForRemote },
|
||||
],
|
||||
default: reactionAcceptance,
|
||||
});
|
||||
if (select.canceled) return;
|
||||
reactionAcceptance = select.result;
|
||||
}
|
||||
|
||||
function pushVisibleUser(user) {
|
||||
if (!visibleUsers.some(u => u.username === user.username && u.host === user.host)) {
|
||||
visibleUsers.push(user);
|
||||
@@ -422,6 +498,10 @@ function pushVisibleUser(user) {
|
||||
function addVisibleUser() {
|
||||
os.selectUser().then(user => {
|
||||
pushVisibleUser(user);
|
||||
|
||||
if (!text.toLowerCase().includes(`@${user.username.toLowerCase()}`)) {
|
||||
text = `@${Acct.toString(user)} ${text}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -575,7 +655,8 @@ async function post(ev?: MouseEvent) {
|
||||
text.includes('$[x4') ||
|
||||
text.includes('$[scale') ||
|
||||
text.includes('$[position');
|
||||
if (annoying) {
|
||||
|
||||
if (annoying && visibility === 'public') {
|
||||
const { canceled, result } = await os.actions({
|
||||
type: 'warning',
|
||||
text: i18n.ts.thisPostMayBeAnnoying,
|
||||
@@ -610,6 +691,7 @@ async function post(ev?: MouseEvent) {
|
||||
localOnly: localOnly,
|
||||
visibility: visibility,
|
||||
visibleUserIds: visibility === 'specified' ? visibleUsers.map(u => u.id) : undefined,
|
||||
reactionAcceptance,
|
||||
};
|
||||
|
||||
if (withHashtags && hashtags && hashtags.trim() !== '') {
|
||||
@@ -658,7 +740,14 @@ async function post(ev?: MouseEvent) {
|
||||
if ((text.includes('love') || text.includes('❤')) && text.includes('misskey')) {
|
||||
claimAchievement('iLoveMisskey');
|
||||
}
|
||||
if (text.includes('Efrlqw8ytg4'.toLowerCase()) || text.includes('XVCwzwxdHuA'.toLowerCase())) {
|
||||
if (
|
||||
text.includes('https://youtu.be/Efrlqw8ytg4'.toLowerCase()) ||
|
||||
text.includes('https://www.youtube.com/watch?v=Efrlqw8ytg4'.toLowerCase()) ||
|
||||
text.includes('https://m.youtube.com/watch?v=Efrlqw8ytg4'.toLowerCase()) ||
|
||||
text.includes('https://youtu.be/XVCwzwxdHuA'.toLowerCase()) ||
|
||||
text.includes('https://www.youtube.com/watch?v=XVCwzwxdHuA'.toLowerCase()) ||
|
||||
text.includes('https://m.youtube.com/watch?v=XVCwzwxdHuA'.toLowerCase())
|
||||
) {
|
||||
claimAchievement('brainDiver');
|
||||
}
|
||||
|
||||
@@ -793,6 +882,7 @@ defineExpose({
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
container-type: inline-size;
|
||||
|
||||
&.modal {
|
||||
width: 100%;
|
||||
@@ -800,21 +890,29 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
//#region header
|
||||
.header {
|
||||
z-index: 1000;
|
||||
height: 66px;
|
||||
min-height: 50px;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(36px, 50px));
|
||||
grid-template-rows: minmax(40px, 100%);
|
||||
}
|
||||
|
||||
.cancel {
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
width: 64px;
|
||||
line-height: 66px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.account {
|
||||
height: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
display: inline-flex;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
@@ -822,55 +920,23 @@ defineExpose({
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: auto;
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.textCount {
|
||||
opacity: 0.7;
|
||||
line-height: 66px;
|
||||
}
|
||||
|
||||
.visibility {
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
margin: 0 0 0 8px;
|
||||
|
||||
& + .localOnly {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.localOnly {
|
||||
margin: 0 0 0 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.previewButton {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
margin: 0 8px 0 0;
|
||||
font-size: 16px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background: var(--X5);
|
||||
}
|
||||
|
||||
&.previewButtonActive {
|
||||
color: var(--accent);
|
||||
}
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
font-size: 0.9em;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
gap: 4px;
|
||||
overflow: clip;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.submit {
|
||||
margin: 16px 16px 16px 0;
|
||||
margin: 12px 12px 12px 6px;
|
||||
vertical-align: bottom;
|
||||
|
||||
&:disabled {
|
||||
@@ -898,17 +964,48 @@ defineExpose({
|
||||
padding: 0 12px;
|
||||
line-height: 34px;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
border-radius: 6px;
|
||||
min-width: 90px;
|
||||
box-sizing: border-box;
|
||||
color: var(--fgOnAccent);
|
||||
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
|
||||
}
|
||||
|
||||
.form {
|
||||
.headerRightItem {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background: var(--X5);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: none;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff2a2a;
|
||||
}
|
||||
}
|
||||
|
||||
.headerRightButtonText {
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.visibility {
|
||||
overflow: clip;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:enabled {
|
||||
> .headerRightButtonText {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
.preview {
|
||||
padding: 16px 20px 0 20px;
|
||||
}
|
||||
@@ -942,10 +1039,6 @@ defineExpose({
|
||||
background: var(--X4);
|
||||
}
|
||||
|
||||
.disableFederationWarn {
|
||||
margin: 0 20px 16px 20px;
|
||||
}
|
||||
|
||||
.hasNotSpecifiedMentions {
|
||||
margin: 0 20px 16px 20px;
|
||||
}
|
||||
@@ -987,18 +1080,61 @@ defineExpose({
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
|
||||
.text {
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 90px;
|
||||
.textOuter {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
&.withCw {
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
width: 100%;
|
||||
min-height: 90px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.textCount {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 2px;
|
||||
padding: 4px 6px;
|
||||
font-size: .9em;
|
||||
color: var(--warn);
|
||||
border-radius: 6px;
|
||||
min-width: 1.6em;
|
||||
text-align: center;
|
||||
|
||||
&.textOver {
|
||||
color: #ff2a2a;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
padding: 0 16px 16px 16px;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.footerLeft {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
|
||||
grid-auto-rows: 46px;
|
||||
}
|
||||
|
||||
.footerRight {
|
||||
flex: 0.3;
|
||||
margin-left: auto;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
|
||||
grid-auto-rows: 46px;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.footerButton {
|
||||
@@ -1006,8 +1142,8 @@ defineExpose({
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
@@ -1019,30 +1155,34 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
.previewButtonActive {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@container (max-width: 500px) {
|
||||
.header {
|
||||
height: 50px;
|
||||
.headerRight {
|
||||
font-size: .9em;
|
||||
}
|
||||
|
||||
> .cancel {
|
||||
width: 50px;
|
||||
line-height: 50px;
|
||||
}
|
||||
.headerRightButtonText {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .headerRight {
|
||||
> .textCount {
|
||||
line-height: 50px;
|
||||
}
|
||||
.visibility {
|
||||
overflow: initial;
|
||||
}
|
||||
|
||||
> .submit {
|
||||
margin: 8px;
|
||||
}
|
||||
}
|
||||
.submit {
|
||||
margin: 8px 8px 8px 4px;
|
||||
}
|
||||
|
||||
.toSpecified {
|
||||
padding: 6px 16px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
padding: 16px 14px 0 14px;
|
||||
}
|
||||
.cw,
|
||||
.hashtags,
|
||||
.text {
|
||||
@@ -1058,11 +1198,13 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 310px) {
|
||||
.footerButton {
|
||||
@container (max-width: 330px) {
|
||||
.headerRight {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
font-size: 14px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -24,19 +24,19 @@ const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.d
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any[];
|
||||
detachMediaFn: () => void;
|
||||
detachMediaFn?: (id: string) => void;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update:modelValue', value: any[]): void;
|
||||
(ev: 'detach'): void;
|
||||
(ev: 'detach', id: string): void;
|
||||
(ev: 'changeSensitive'): void;
|
||||
(ev: 'changeName'): void;
|
||||
}>();
|
||||
|
||||
let menuShowing = false;
|
||||
|
||||
function detachMedia(id) {
|
||||
function detachMedia(id: string) {
|
||||
if (props.detachMediaFn) {
|
||||
props.detachMediaFn(id);
|
||||
} else {
|
||||
|
@@ -3,7 +3,7 @@
|
||||
ref="buttonEl"
|
||||
v-ripple="canToggle"
|
||||
class="_button"
|
||||
:class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle }]"
|
||||
:class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.large]: defaultStore.state.largeNoteReactions }]"
|
||||
@click="toggleReaction()"
|
||||
>
|
||||
<MkReactionIcon :class="$style.icon" :reaction="reaction" :emoji-url="note.reactionEmojis[reaction.substr(1, reaction.length - 2)]"/>
|
||||
@@ -118,6 +118,17 @@ useTooltip(buttonEl, async (showing) => {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&.large {
|
||||
height: 42px;
|
||||
font-size: 1.5em;
|
||||
border-radius: 6px;
|
||||
|
||||
> .count {
|
||||
font-size: 0.7em;
|
||||
line-height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
&.reacted {
|
||||
background: var(--accent);
|
||||
|
||||
|
@@ -1,27 +1,28 @@
|
||||
<template>
|
||||
<TransitionGroup
|
||||
:enter-active-class="$store.state.animation ? $style.transition_x_enterActive : ''"
|
||||
:leave-active-class="$store.state.animation ? $style.transition_x_leaveActive : ''"
|
||||
:enter-from-class="$store.state.animation ? $style.transition_x_enterFrom : ''"
|
||||
:leave-to-class="$store.state.animation ? $style.transition_x_leaveTo : ''"
|
||||
:move-class="$store.state.animation ? $style.transition_x_move : ''"
|
||||
:enter-active-class="defaultStore.state.animation ? $style.transition_x_enterActive : ''"
|
||||
:leave-active-class="defaultStore.state.animation ? $style.transition_x_leaveActive : ''"
|
||||
:enter-from-class="defaultStore.state.animation ? $style.transition_x_enterFrom : ''"
|
||||
:leave-to-class="defaultStore.state.animation ? $style.transition_x_leaveTo : ''"
|
||||
:move-class="defaultStore.state.animation ? $style.transition_x_move : ''"
|
||||
tag="div" :class="$style.root"
|
||||
>
|
||||
<XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note"/>
|
||||
<slot v-if="hasMoreReactions" name="more" />
|
||||
<slot v-if="hasMoreReactions" name="more"/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as misskey from 'misskey-js';
|
||||
import XReaction from '@/components/MkReactionsViewer.reaction.vue';
|
||||
import { watch } from 'vue';
|
||||
import XReaction from '@/components/MkReactionsViewer.reaction.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: misskey.entities.Note;
|
||||
maxNumber?: number;
|
||||
note: misskey.entities.Note;
|
||||
maxNumber?: number;
|
||||
}>(), {
|
||||
maxNumber: Infinity,
|
||||
maxNumber: Infinity,
|
||||
});
|
||||
|
||||
const initialReactions = new Set(Object.keys(props.note.reactions));
|
||||
|
@@ -36,9 +36,11 @@ async function renderChart() {
|
||||
const wide = rootEl.offsetWidth > 600;
|
||||
const narrow = rootEl.offsetWidth < 400;
|
||||
|
||||
const maxDays = wide ? 15 : narrow ? 5 : 10;
|
||||
const maxDays = wide ? 10 : narrow ? 5 : 7;
|
||||
|
||||
const raw = await os.api('retention', { });
|
||||
let raw = await os.api('retention', { });
|
||||
|
||||
raw = raw.slice(0, maxDays);
|
||||
|
||||
const data = [];
|
||||
for (const record of raw) {
|
||||
@@ -60,10 +62,9 @@ async function renderChart() {
|
||||
const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300';
|
||||
|
||||
// 視覚上の分かりやすさのため上から最も大きい3つの値の平均を最大値とする
|
||||
//const max = raw.readWrite.slice().sort((a, b) => b - a).slice(0, 3).reduce((a, b) => a + b, 0) / 3;
|
||||
const max = 4;
|
||||
const max = raw.map(x => x.users).slice().sort((a, b) => b - a).slice(0, 3).reduce((a, b) => a + b, 0) / 3;
|
||||
|
||||
const marginEachCell = 6;
|
||||
const marginEachCell = 12;
|
||||
|
||||
chartInstance = new Chart(chartEl, {
|
||||
type: 'matrix',
|
||||
|
@@ -36,6 +36,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkRadio from '@/components/MkRadio.vue';
|
||||
import * as os from '@/os';
|
||||
import * as config from '@/config';
|
||||
import { $i } from '@/account';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -51,6 +52,7 @@ export default defineComponent({
|
||||
text: '',
|
||||
flag: true,
|
||||
radio: 'misskey',
|
||||
$i,
|
||||
mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`,
|
||||
};
|
||||
},
|
||||
|
@@ -20,7 +20,7 @@ import MkSignin from '@/components/MkSignin.vue';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
withDefaults(defineProps<{
|
||||
autoSet?: boolean;
|
||||
message?: string,
|
||||
}>(), {
|
||||
@@ -29,7 +29,7 @@ const props = withDefaults(defineProps<{
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done'): void;
|
||||
(ev: 'done', v: any): void;
|
||||
(ev: 'closed'): void;
|
||||
(ev: 'cancelled'): void;
|
||||
}>();
|
||||
@@ -38,11 +38,11 @@ const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
|
||||
function onClose() {
|
||||
emit('cancelled');
|
||||
dialog.close();
|
||||
if (dialog) dialog.close();
|
||||
}
|
||||
|
||||
function onLogin(res) {
|
||||
emit('done', res);
|
||||
dialog.close();
|
||||
if (dialog) dialog.close();
|
||||
}
|
||||
</script>
|
||||
|
@@ -9,6 +9,7 @@
|
||||
<template #prefix>@</template>
|
||||
<template #suffix>@{{ host }}</template>
|
||||
<template #caption>
|
||||
<div><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.cannotBeChangedLater }}</div>
|
||||
<span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
|
||||
<span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
|
||||
<span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
|
||||
@@ -71,7 +72,7 @@ import { toUnicode } from 'punycode/';
|
||||
import MkButton from './MkButton.vue';
|
||||
import MkInput from './MkInput.vue';
|
||||
import MkSwitch from './MkSwitch.vue';
|
||||
import MkCaptcha from '@/components/MkCaptcha.vue';
|
||||
import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue';
|
||||
import * as config from '@/config';
|
||||
import * as os from '@/os';
|
||||
import { login } from '@/account';
|
||||
@@ -91,9 +92,9 @@ const emit = defineEmits<{
|
||||
|
||||
const host = toUnicode(config.host);
|
||||
|
||||
let hcaptcha = $ref();
|
||||
let recaptcha = $ref();
|
||||
let turnstile = $ref();
|
||||
let hcaptcha = $ref<Captcha | undefined>();
|
||||
let recaptcha = $ref<Captcha | undefined>();
|
||||
let turnstile = $ref<Captcha | undefined>();
|
||||
|
||||
let username: string = $ref('');
|
||||
let password: string = $ref('');
|
||||
@@ -109,6 +110,8 @@ let ToSAgreement: boolean = $ref(false);
|
||||
let hCaptchaResponse = $ref(null);
|
||||
let reCaptchaResponse = $ref(null);
|
||||
let turnstileResponse = $ref(null);
|
||||
let usernameAbortController: null | AbortController = $ref(null);
|
||||
let emailAbortController: null | AbortController = $ref(null);
|
||||
|
||||
const shouldDisableSubmitting = $computed((): boolean => {
|
||||
return submitting ||
|
||||
@@ -116,7 +119,9 @@ const shouldDisableSubmitting = $computed((): boolean => {
|
||||
instance.enableHcaptcha && !hCaptchaResponse ||
|
||||
instance.enableRecaptcha && !reCaptchaResponse ||
|
||||
instance.enableTurnstile && !turnstileResponse ||
|
||||
passwordRetypeState === 'not-match';
|
||||
instance.emailRequiredForSignup && emailState !== 'ok' ||
|
||||
usernameState !== 'ok' ||
|
||||
passwordRetypeState !== 'match';
|
||||
});
|
||||
|
||||
function onChangeUsername(): void {
|
||||
@@ -138,14 +143,20 @@ function onChangeUsername(): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (usernameAbortController != null) {
|
||||
usernameAbortController.abort();
|
||||
}
|
||||
usernameState = 'wait';
|
||||
usernameAbortController = new AbortController();
|
||||
|
||||
os.api('username/available', {
|
||||
username,
|
||||
}).then(result => {
|
||||
}, undefined, usernameAbortController.signal).then(result => {
|
||||
usernameState = result.available ? 'ok' : 'unavailable';
|
||||
}).catch(() => {
|
||||
usernameState = 'error';
|
||||
}).catch((err) => {
|
||||
if (err.name !== 'AbortError') {
|
||||
usernameState = 'error';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,11 +166,15 @@ function onChangeEmail(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
if (emailAbortController != null) {
|
||||
emailAbortController.abort();
|
||||
}
|
||||
emailState = 'wait';
|
||||
emailAbortController = new AbortController();
|
||||
|
||||
os.api('email-address/available', {
|
||||
emailAddress: email,
|
||||
}).then(result => {
|
||||
}, undefined, emailAbortController.signal).then(result => {
|
||||
emailState = result.available ? 'ok' :
|
||||
result.reason === 'used' ? 'unavailable:used' :
|
||||
result.reason === 'format' ? 'unavailable:format' :
|
||||
@@ -167,8 +182,10 @@ function onChangeEmail(): void {
|
||||
result.reason === 'mx' ? 'unavailable:mx' :
|
||||
result.reason === 'smtp' ? 'unavailable:smtp' :
|
||||
'unavailable';
|
||||
}).catch(() => {
|
||||
emailState = 'error';
|
||||
}).catch((err) => {
|
||||
if (err.name !== 'AbortError') {
|
||||
emailState = 'error';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,19 +208,20 @@ function onChangePasswordRetype(): void {
|
||||
passwordRetypeState = password === retypedPassword ? 'match' : 'not-match';
|
||||
}
|
||||
|
||||
function onSubmit(): void {
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
|
||||
os.api('signup', {
|
||||
username,
|
||||
password,
|
||||
emailAddress: email,
|
||||
invitationCode,
|
||||
'hcaptcha-response': hCaptchaResponse,
|
||||
'g-recaptcha-response': reCaptchaResponse,
|
||||
'turnstile-response': turnstileResponse,
|
||||
}).then(() => {
|
||||
try {
|
||||
await os.api('signup', {
|
||||
username,
|
||||
password,
|
||||
emailAddress: email,
|
||||
invitationCode,
|
||||
'hcaptcha-response': hCaptchaResponse,
|
||||
'g-recaptcha-response': reCaptchaResponse,
|
||||
'turnstile-response': turnstileResponse,
|
||||
});
|
||||
if (instance.emailRequiredForSignup) {
|
||||
os.alert({
|
||||
type: 'success',
|
||||
@@ -212,28 +230,27 @@ function onSubmit(): void {
|
||||
});
|
||||
emit('signupEmailPending');
|
||||
} else {
|
||||
os.api('signin', {
|
||||
const res = await os.api('signin', {
|
||||
username,
|
||||
password,
|
||||
}).then(res => {
|
||||
emit('signup', res);
|
||||
|
||||
if (props.autoSet) {
|
||||
login(res.i);
|
||||
}
|
||||
});
|
||||
emit('signup', res);
|
||||
|
||||
if (props.autoSet) {
|
||||
return login(res.i);
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
} catch {
|
||||
submitting = false;
|
||||
hcaptcha.reset?.();
|
||||
recaptcha.reset?.();
|
||||
turnstile.reset?.();
|
||||
hcaptcha?.reset?.();
|
||||
recaptcha?.reset?.();
|
||||
turnstile?.reset?.();
|
||||
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.somethingHappened,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@@ -8,7 +8,7 @@
|
||||
<MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
|
||||
</div>
|
||||
<details v-if="note.files.length > 0">
|
||||
<summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary>
|
||||
<summary>({{ i18n.t('withNFiles', { n: note.files.length }) }})</summary>
|
||||
<MkMediaList :media-list="note.files"/>
|
||||
</details>
|
||||
<details v-if="note.poll">
|
||||
@@ -27,6 +27,7 @@ import * as misskey from 'misskey-js';
|
||||
import MkMediaList from '@/components/MkMediaList.vue';
|
||||
import MkPoll from '@/components/MkPoll.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { $i } from '@/account';
|
||||
|
||||
const props = defineProps<{
|
||||
note: misskey.entities.Note;
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<MkNotes ref="tlComponent" :no-gap="!$store.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
|
||||
<MkNotes ref="tlComponent" :no-gap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -8,6 +8,7 @@ import MkNotes from '@/components/MkNotes.vue';
|
||||
import { stream } from '@/stream';
|
||||
import * as sound from '@/scripts/sound';
|
||||
import { $i } from '@/account';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const props = defineProps<{
|
||||
src: string;
|
||||
|
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<Transition
|
||||
:enter-active-class="$store.state.animation ? $style.transition_toast_enterActive : ''"
|
||||
:leave-active-class="$store.state.animation ? $style.transition_toast_leaveActive : ''"
|
||||
:enter-from-class="$store.state.animation ? $style.transition_toast_enterFrom : ''"
|
||||
:leave-to-class="$store.state.animation ? $style.transition_toast_leaveTo : ''"
|
||||
:enter-active-class="defaultStore.state.animation ? $style.transition_toast_enterActive : ''"
|
||||
:leave-active-class="defaultStore.state.animation ? $style.transition_toast_leaveActive : ''"
|
||||
:enter-from-class="defaultStore.state.animation ? $style.transition_toast_enterFrom : ''"
|
||||
:leave-to-class="defaultStore.state.animation ? $style.transition_toast_leaveTo : ''"
|
||||
appear @after-leave="emit('closed')"
|
||||
>
|
||||
<div v-if="showing" class="_acrylic" :class="$style.root" :style="{ zIndex }">
|
||||
@@ -19,6 +19,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
import * as os from '@/os';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
defineProps<{
|
||||
message: string;
|
||||
|
@@ -10,7 +10,7 @@
|
||||
@closed="$emit('closed')"
|
||||
@ok="ok()"
|
||||
>
|
||||
<template #header>{{ title || $ts.generateAccessToken }}</template>
|
||||
<template #header>{{ title || i18n.ts.generateAccessToken }}</template>
|
||||
|
||||
<MkSpacer :margin-min="20" :margin-max="28">
|
||||
<div class="_gaps_m">
|
||||
@@ -19,15 +19,15 @@
|
||||
</div>
|
||||
<div>
|
||||
<MkInput v-model="name">
|
||||
<template #label>{{ $ts.name }}</template>
|
||||
<template #label>{{ i18n.ts.name }}</template>
|
||||
</MkInput>
|
||||
</div>
|
||||
<div><b>{{ $ts.permission }}</b></div>
|
||||
<div><b>{{ i18n.ts.permission }}</b></div>
|
||||
<div class="_buttons">
|
||||
<MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton>
|
||||
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
|
||||
</div>
|
||||
<MkSwitch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model="permissions[kind]">{{ $t(`_permissions.${kind}`) }}</MkSwitch>
|
||||
<MkSwitch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model="permissions[kind]">{{ i18n.t(`_permissions.${kind}`) }}</MkSwitch>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkModalWindow>
|
||||
|
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<Transition
|
||||
:enter-active-class="$store.state.animation ? $style.transition_tooltip_enterActive : ''"
|
||||
:leave-active-class="$store.state.animation ? $style.transition_tooltip_leaveActive : ''"
|
||||
:enter-from-class="$store.state.animation ? $style.transition_tooltip_enterFrom : ''"
|
||||
:leave-to-class="$store.state.animation ? $style.transition_tooltip_leaveTo : ''"
|
||||
:enter-active-class="defaultStore.state.animation ? $style.transition_tooltip_enterActive : ''"
|
||||
:leave-active-class="defaultStore.state.animation ? $style.transition_tooltip_leaveActive : ''"
|
||||
:enter-from-class="defaultStore.state.animation ? $style.transition_tooltip_enterFrom : ''"
|
||||
:leave-to-class="defaultStore.state.animation ? $style.transition_tooltip_leaveTo : ''"
|
||||
appear @after-leave="emit('closed')"
|
||||
>
|
||||
<div v-show="showing" ref="el" :class="$style.root" class="_acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }">
|
||||
@@ -19,6 +19,7 @@
|
||||
import { nextTick, onMounted, onUnmounted, shallowRef } from 'vue';
|
||||
import * as os from '@/os';
|
||||
import { calcPopupPosition } from '@/scripts/popup-position';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
showing: boolean;
|
||||
|
@@ -1,7 +1,18 @@
|
||||
<template>
|
||||
<template v-if="playerEnabled">
|
||||
<div :class="$style.player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
|
||||
<iframe v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
|
||||
<template v-if="player.url && playerEnabled">
|
||||
<div
|
||||
:class="$style.player"
|
||||
:style="player.width ? `padding: ${(player.height || 0) / player.width * 100}% 0 0` : `padding: ${(player.height || 0)}px 0 0`"
|
||||
>
|
||||
<iframe
|
||||
v-if="player.url.startsWith('http://') || player.url.startsWith('https://')"
|
||||
sandbox="allow-popups allow-scripts allow-storage-access-by-user-activation allow-same-origin"
|
||||
scrolling="no"
|
||||
:allow="player.allow.join(';')"
|
||||
:class="$style.playerIframe"
|
||||
:src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')"
|
||||
:style="{ border: 0 }"
|
||||
></iframe>
|
||||
<span v-else>invalid url</span>
|
||||
</div>
|
||||
<div :class="$style.action">
|
||||
@@ -12,7 +23,7 @@
|
||||
</template>
|
||||
<template v-else-if="tweetId && tweetExpanded">
|
||||
<div ref="twitter" :class="$style.twitter">
|
||||
<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${$store.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe>
|
||||
<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe>
|
||||
</div>
|
||||
<div :class="$style.action">
|
||||
<MkButton :small="true" inline @click="tweetExpanded = false">
|
||||
@@ -28,7 +39,7 @@
|
||||
<header :class="$style.header">
|
||||
<h1 v-if="unknownUrl" :class="$style.title">{{ url }}</h1>
|
||||
<h1 v-else-if="fetching" :class="$style.title"><MkEllipsis/></h1>
|
||||
<h1 v-else :class="$style.title" :title="title">{{ title }}</h1>
|
||||
<h1 v-else :class="$style.title" :title="title ?? undefined">{{ title }}</h1>
|
||||
</header>
|
||||
<p v-if="unknownUrl" :class="$style.text">{{ i18n.ts.cannotLoad }}</p>
|
||||
<p v-else-if="fetching" :class="$style.text"><MkEllipsis/></p>
|
||||
@@ -37,7 +48,7 @@
|
||||
<img v-if="icon" :class="$style.siteIcon" :src="icon"/>
|
||||
<p v-if="unknownUrl" :class="$style.siteName">?</p>
|
||||
<p v-else-if="fetching" :class="$style.siteName"><MkEllipsis/></p>
|
||||
<p v-else :class="$style.siteName" :title="sitename">{{ sitename }}</p>
|
||||
<p v-else :class="$style.siteName" :title="sitename ?? undefined">{{ sitename }}</p>
|
||||
</footer>
|
||||
</article>
|
||||
</component>
|
||||
@@ -59,12 +70,16 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, onUnmounted } from 'vue';
|
||||
import type { summaly } from 'summaly';
|
||||
import { url as local } from '@/config';
|
||||
import { i18n } from '@/i18n';
|
||||
import * as os from '@/os';
|
||||
import { deviceKind } from '@/scripts/device-kind';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { versatileLang } from '@/scripts/intl-const';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
url: string;
|
||||
@@ -91,7 +106,7 @@ let player = $ref({
|
||||
url: null,
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
} as SummalyResult['player']);
|
||||
let playerEnabled = $ref(false);
|
||||
let tweetId = $ref<string | null>(null);
|
||||
let tweetExpanded = $ref(props.detail);
|
||||
@@ -114,11 +129,7 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/
|
||||
requestUrl.hash = '';
|
||||
|
||||
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`).then(res => {
|
||||
res.json().then(info => {
|
||||
if (info.url == null) {
|
||||
unknownUrl = true;
|
||||
return;
|
||||
}
|
||||
res.json().then((info: SummalyResult) => {
|
||||
title = info.title;
|
||||
description = info.description;
|
||||
thumbnail = info.thumbnail;
|
||||
@@ -139,7 +150,7 @@ function adjustTweetHeight(message: any) {
|
||||
}
|
||||
|
||||
const openPlayer = (): void => {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkYoutubePlayer.vue')), {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkYouTubePlayer.vue')), {
|
||||
url: requestUrl.href,
|
||||
});
|
||||
};
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="fgmtyycl" :style="{ zIndex, top: top + 'px', left: left + 'px' }">
|
||||
<Transition :name="$store.state.animation ? '_transition_zoom' : ''" @after-leave="emit('closed')">
|
||||
<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" @after-leave="emit('closed')">
|
||||
<MkUrlPreview v-if="showing" class="_popup _shadow" :url="url"/>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -10,6 +10,7 @@
|
||||
import { onMounted } from 'vue';
|
||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import * as os from '@/os';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const props = defineProps<{
|
||||
showing: boolean;
|
||||
|
@@ -6,7 +6,7 @@
|
||||
<MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
|
||||
<p class="username"><MkAcct :user="user"/></p>
|
||||
</div>
|
||||
<span v-if="$i && $i.id !== user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span>
|
||||
<span v-if="$i && $i.id !== user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span>
|
||||
<div class="description">
|
||||
<div v-if="user.description" class="mfm">
|
||||
<Mfm :text="user.description" :author="user" :i="$i"/>
|
||||
@@ -33,6 +33,7 @@ import * as misskey from 'misskey-js';
|
||||
import MkFollowButton from '@/components/MkFollowButton.vue';
|
||||
import { userPage } from '@/filters/user';
|
||||
import { i18n } from '@/i18n';
|
||||
import { $i } from '@/account';
|
||||
|
||||
defineProps<{
|
||||
user: misskey.entities.UserDetailed;
|
||||
|
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<Transition
|
||||
:enter-active-class="$store.state.animation ? $style.transition_popup_enterActive : ''"
|
||||
:leave-active-class="$store.state.animation ? $style.transition_popup_leaveActive : ''"
|
||||
:enter-from-class="$store.state.animation ? $style.transition_popup_enterFrom : ''"
|
||||
:leave-to-class="$store.state.animation ? $style.transition_popup_leaveTo : ''"
|
||||
:enter-active-class="defaultStore.state.animation ? $style.transition_popup_enterActive : ''"
|
||||
:leave-active-class="defaultStore.state.animation ? $style.transition_popup_leaveActive : ''"
|
||||
:enter-from-class="defaultStore.state.animation ? $style.transition_popup_enterFrom : ''"
|
||||
:leave-to-class="defaultStore.state.animation ? $style.transition_popup_leaveTo : ''"
|
||||
appear @after-leave="emit('closed')"
|
||||
>
|
||||
<div v-if="showing" :class="$style.root" class="_popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }">
|
||||
<div v-if="user != null">
|
||||
<div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''">
|
||||
<span v-if="$i && $i.id != user.id && user.isFollowed" :class="$style.followed">{{ $ts.followsYou }}</span>
|
||||
<span v-if="$i && $i.id != user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 128 128" :class="$style.avatarBack">
|
||||
<g transform="matrix(1.6,0,0,1.6,-38.4,-51.2)">
|
||||
@@ -27,15 +27,15 @@
|
||||
</div>
|
||||
<div :class="$style.status">
|
||||
<div :class="$style.statusItem">
|
||||
<div :class="$style.statusItemLabel">{{ $ts.notes }}</div>
|
||||
<div :class="$style.statusItemLabel">{{ i18n.ts.notes }}</div>
|
||||
<div>{{ number(user.notesCount) }}</div>
|
||||
</div>
|
||||
<div :class="$style.statusItem">
|
||||
<div :class="$style.statusItemLabel">{{ $ts.following }}</div>
|
||||
<div :class="$style.statusItemLabel">{{ i18n.ts.following }}</div>
|
||||
<div>{{ number(user.followingCount) }}</div>
|
||||
</div>
|
||||
<div :class="$style.statusItem">
|
||||
<div :class="$style.statusItemLabel">{{ $ts.followers }}</div>
|
||||
<div :class="$style.statusItemLabel">{{ i18n.ts.followers }}</div>
|
||||
<div>{{ number(user.followersCount) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,6 +59,8 @@ import * as os from '@/os';
|
||||
import { getUserMenu } from '@/scripts/get-user-menu';
|
||||
import number from '@/filters/number';
|
||||
import { i18n } from '@/i18n';
|
||||
import { defaultStore } from '@/store';
|
||||
import { $i } from '@/account';
|
||||
|
||||
const props = defineProps<{
|
||||
showing: boolean;
|
||||
|
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<MkModal ref="modal" :z-priority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')">
|
||||
<div class="_popup" :class="$style.root">
|
||||
<MkModal ref="modal" v-slot="{ type }" :z-priority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')">
|
||||
<div class="_popup" :class="{ [$style.root]: true, [$style.asDrawer]: type === 'drawer' }">
|
||||
<div :class="[$style.label, $style.item]">
|
||||
{{ i18n.ts.visibility }}
|
||||
</div>
|
||||
<button key="public" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')">
|
||||
<div :class="$style.icon"><i class="ti ti-world"></i></div>
|
||||
<div :class="$style.body">
|
||||
@@ -29,21 +32,12 @@
|
||||
<span :class="$style.itemDescription">{{ i18n.ts._visibility.specifiedDescription }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<div :class="$style.divider"></div>
|
||||
<button key="localOnly" class="_button" :class="[$style.item, $style.localOnly, { [$style.active]: localOnly }]" data-index="5" @click="localOnly = !localOnly">
|
||||
<div :class="$style.icon"><i class="ti ti-world-off"></i></div>
|
||||
<div :class="$style.body">
|
||||
<span :class="$style.itemTitle">{{ i18n.ts._visibility.disableFederation }}</span>
|
||||
<span :class="$style.itemDescription">{{ i18n.ts._visibility.disableFederationDescription }}</span>
|
||||
</div>
|
||||
<div :class="$style.toggle"><i :class="localOnly ? 'ti ti-toggle-right' : 'ti ti-toggle-left'"></i></div>
|
||||
</button>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, watch } from 'vue';
|
||||
import { nextTick } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import MkModal from '@/components/MkModal.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
@@ -52,42 +46,58 @@ const modal = $shallowRef<InstanceType<typeof MkModal>>();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
currentVisibility: typeof misskey.noteVisibilities[number];
|
||||
currentLocalOnly: boolean;
|
||||
localOnly: boolean;
|
||||
src?: HTMLElement;
|
||||
}>(), {
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'changeVisibility', v: typeof misskey.noteVisibilities[number]): void;
|
||||
(ev: 'changeLocalOnly', v: boolean): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
let v = $ref(props.currentVisibility);
|
||||
let localOnly = $ref(props.currentLocalOnly);
|
||||
|
||||
watch($$(localOnly), () => {
|
||||
emit('changeLocalOnly', localOnly);
|
||||
});
|
||||
|
||||
function choose(visibility: typeof misskey.noteVisibilities[number]): void {
|
||||
v = visibility;
|
||||
emit('changeVisibility', visibility);
|
||||
nextTick(() => {
|
||||
modal.close();
|
||||
if (modal) modal.close();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
padding: 8px 0;
|
||||
|
||||
&.asDrawer {
|
||||
padding: 12px 0 max(env(safe-area-inset-bottom, 0px), 12px) 0;
|
||||
width: 100%;
|
||||
border-radius: 24px;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
|
||||
.label {
|
||||
pointer-events: none;
|
||||
font-size: 12px;
|
||||
padding-bottom: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.item {
|
||||
font-size: 14px;
|
||||
padding: 10px 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 8px 0;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
.label {
|
||||
pointer-events: none;
|
||||
font-size: 10px;
|
||||
padding-bottom: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.item {
|
||||
@@ -107,13 +117,7 @@ function choose(visibility: typeof misskey.noteVisibilities[number]): void {
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--fgOnAccent);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
&.localOnly.active {
|
||||
color: var(--accent);
|
||||
background: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,16 +148,4 @@ function choose(visibility: typeof misskey.noteVisibilities[number]): void {
|
||||
.itemDescription {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
width: 16px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
</style>
|
||||
|
@@ -19,9 +19,9 @@
|
||||
@update:model-value="v => emit('updateWidgets', v)"
|
||||
>
|
||||
<template #item="{element}">
|
||||
<div :class="[$style.widget, $style['customize-container']]" class="data-cy-customize-container">
|
||||
<div :class="[$style.widget, $style['customize-container']]" data-cy-customize-container>
|
||||
<button :class="$style['customize-container-config']" class="_button" @click.prevent.stop="configWidget(element.id)"><i class="ti ti-settings"></i></button>
|
||||
<button :class="$style['customize-container-remove']" class="_button data-cy-customize-container-remove" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button>
|
||||
<button :class="$style['customize-container-remove']" data-cy-customize-container-remove class="_button" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button>
|
||||
<div class="handle">
|
||||
<component :is="`widget-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :class="$style['customize-container-handle-widget']" :widget="element" @update-props="updateWidget(element.id, $event)"/>
|
||||
</div>
|
||||
|
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<Transition
|
||||
:enter-active-class="$store.state.animation ? $style.transition_window_enterActive : ''"
|
||||
:leave-active-class="$store.state.animation ? $style.transition_window_leaveActive : ''"
|
||||
:enter-from-class="$store.state.animation ? $style.transition_window_enterFrom : ''"
|
||||
:leave-to-class="$store.state.animation ? $style.transition_window_leaveTo : ''"
|
||||
:enter-active-class="defaultStore.state.animation ? $style.transition_window_enterActive : ''"
|
||||
:leave-active-class="defaultStore.state.animation ? $style.transition_window_leaveActive : ''"
|
||||
:enter-from-class="defaultStore.state.animation ? $style.transition_window_enterFrom : ''"
|
||||
:leave-to-class="defaultStore.state.animation ? $style.transition_window_leaveTo : ''"
|
||||
appear
|
||||
@after-leave="$emit('closed')"
|
||||
>
|
||||
@@ -11,15 +11,21 @@
|
||||
<div :class="$style.body" class="_shadow" @mousedown="onBodyMousedown" @keydown="onKeydown">
|
||||
<div :class="[$style.header, { [$style.mini]: mini }]" @contextmenu.prevent.stop="onContextmenu">
|
||||
<span :class="$style.headerLeft">
|
||||
<button v-for="button in buttonsLeft" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
|
||||
<template v-if="!minimized">
|
||||
<button v-for="button in buttonsLeft" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
|
||||
</template>
|
||||
</span>
|
||||
<span :class="$style.headerTitle" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown">
|
||||
<slot name="header"></slot>
|
||||
</span>
|
||||
<span :class="$style.headerRight">
|
||||
<button v-for="button in buttonsRight" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
|
||||
<template v-if="!minimized">
|
||||
<button v-for="button in buttonsRight" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
|
||||
</template>
|
||||
<button v-if="canResize && minimized" v-tooltip="i18n.ts.windowRestore" class="_button" :class="$style.headerButton" @click="unMinimize()"><i class="ti ti-maximize"></i></button>
|
||||
<button v-else-if="canResize && !maximized" v-tooltip="i18n.ts.windowMinimize" class="_button" :class="$style.headerButton" @click="minimize()"><i class="ti ti-minimize"></i></button>
|
||||
<button v-if="canResize && maximized" v-tooltip="i18n.ts.windowRestore" class="_button" :class="$style.headerButton" @click="unMaximize()"><i class="ti ti-picture-in-picture"></i></button>
|
||||
<button v-else-if="canResize && !maximized" v-tooltip="i18n.ts.windowMaximize" class="_button" :class="$style.headerButton" @click="maximize()"><i class="ti ti-rectangle"></i></button>
|
||||
<button v-else-if="canResize && !maximized && !minimized" v-tooltip="i18n.ts.windowMaximize" class="_button" :class="$style.headerButton" @click="maximize()"><i class="ti ti-rectangle"></i></button>
|
||||
<button v-if="closeButton" v-tooltip="i18n.ts.close" class="_button" :class="$style.headerButton" @click="close()"><i class="ti ti-x"></i></button>
|
||||
</span>
|
||||
</div>
|
||||
@@ -27,7 +33,7 @@
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="canResize">
|
||||
<template v-if="canResize && !minimized">
|
||||
<div :class="$style.handleTop" @mousedown.prevent="onTopHandleMousedown"></div>
|
||||
<div :class="$style.handleRight" @mousedown.prevent="onRightHandleMousedown"></div>
|
||||
<div :class="$style.handleBottom" @mousedown.prevent="onBottomHandleMousedown"></div>
|
||||
@@ -47,6 +53,7 @@ import contains from '@/scripts/contains';
|
||||
import * as os from '@/os';
|
||||
import { MenuItem } from '@/types/menu';
|
||||
import { i18n } from '@/i18n';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const minHeight = 50;
|
||||
const minWidth = 250;
|
||||
@@ -99,10 +106,11 @@ let rootEl = $shallowRef<HTMLElement | null>();
|
||||
let showing = $ref(true);
|
||||
let beforeClickedAt = 0;
|
||||
let maximized = $ref(false);
|
||||
let unMaximizedTop = '';
|
||||
let unMaximizedLeft = '';
|
||||
let unMaximizedWidth = '';
|
||||
let unMaximizedHeight = '';
|
||||
let minimized = $ref(false);
|
||||
let unResizedTop = '';
|
||||
let unResizedLeft = '';
|
||||
let unResizedWidth = '';
|
||||
let unResizedHeight = '';
|
||||
|
||||
function close() {
|
||||
showing = false;
|
||||
@@ -131,10 +139,10 @@ function top() {
|
||||
|
||||
function maximize() {
|
||||
maximized = true;
|
||||
unMaximizedTop = rootEl.style.top;
|
||||
unMaximizedLeft = rootEl.style.left;
|
||||
unMaximizedWidth = rootEl.style.width;
|
||||
unMaximizedHeight = rootEl.style.height;
|
||||
unResizedTop = rootEl.style.top;
|
||||
unResizedLeft = rootEl.style.left;
|
||||
unResizedWidth = rootEl.style.width;
|
||||
unResizedHeight = rootEl.style.height;
|
||||
rootEl.style.top = '0';
|
||||
rootEl.style.left = '0';
|
||||
rootEl.style.width = '100%';
|
||||
@@ -143,10 +151,35 @@ function maximize() {
|
||||
|
||||
function unMaximize() {
|
||||
maximized = false;
|
||||
rootEl.style.top = unMaximizedTop;
|
||||
rootEl.style.left = unMaximizedLeft;
|
||||
rootEl.style.width = unMaximizedWidth;
|
||||
rootEl.style.height = unMaximizedHeight;
|
||||
rootEl.style.top = unResizedTop;
|
||||
rootEl.style.left = unResizedLeft;
|
||||
rootEl.style.width = unResizedWidth;
|
||||
rootEl.style.height = unResizedHeight;
|
||||
}
|
||||
|
||||
function minimize() {
|
||||
minimized = true;
|
||||
unResizedWidth = rootEl.style.width;
|
||||
unResizedHeight = rootEl.style.height;
|
||||
rootEl.style.width = minWidth + 'px';
|
||||
rootEl.style.height = props.mini ? '32px' : '39px';
|
||||
}
|
||||
|
||||
function unMinimize() {
|
||||
const main = rootEl;
|
||||
if (main == null) return;
|
||||
|
||||
minimized = false;
|
||||
rootEl.style.width = unResizedWidth;
|
||||
rootEl.style.height = unResizedHeight;
|
||||
const browserWidth = window.innerWidth;
|
||||
const browserHeight = window.innerHeight;
|
||||
const windowWidth = main.offsetWidth;
|
||||
const windowHeight = main.offsetHeight;
|
||||
|
||||
const position = main.getBoundingClientRect();
|
||||
if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px';
|
||||
if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px';
|
||||
}
|
||||
|
||||
function onBodyMousedown() {
|
||||
@@ -154,7 +187,11 @@ function onBodyMousedown() {
|
||||
}
|
||||
|
||||
function onDblClick() {
|
||||
maximize();
|
||||
if (minimized) {
|
||||
unMinimize();
|
||||
} else {
|
||||
maximize();
|
||||
}
|
||||
}
|
||||
|
||||
function onHeaderMousedown(evt: MouseEvent) {
|
||||
@@ -186,7 +223,7 @@ function onHeaderMousedown(evt: MouseEvent) {
|
||||
|
||||
const clickX = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientX : evt.clientX;
|
||||
const clickY = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientY : evt.clientY;
|
||||
const moveBaseX = beforeMaximized ? parseInt(unMaximizedWidth, 10) / 2 : clickX - position.left; // TODO: parseIntやめる
|
||||
const moveBaseX = beforeMaximized ? parseInt(unResizedWidth, 10) / 2 : clickX - position.left; // TODO: parseIntやめる
|
||||
const moveBaseY = beforeMaximized ? 20 : clickY - position.top;
|
||||
const browserWidth = window.innerWidth;
|
||||
const browserHeight = window.innerHeight;
|
||||
|
@@ -6,7 +6,7 @@
|
||||
</template>
|
||||
|
||||
<div class="poamfof">
|
||||
<Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
|
||||
<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
|
||||
<div v-if="player.url && (player.url.startsWith('http://') || player.url.startsWith('https://'))" class="player">
|
||||
<iframe v-if="!fetching" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
|
||||
</div>
|
||||
@@ -21,6 +21,7 @@
|
||||
<script lang="ts" setup>
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import { versatileLang } from '@/scripts/intl-const';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const props = defineProps<{
|
||||
url: string;
|
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="vrtktovh" :class="{ first }">
|
||||
<div class="label"><slot name="label"></slot></div>
|
||||
<div class="main">
|
||||
<div :class="[$style.root, { [$style.rootFirst]: first }]">
|
||||
<div :class="[$style.label, { [$style.labelFirst]: first }]"><slot name="label"></slot></div>
|
||||
<div :class="$style.main">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,31 +13,31 @@ defineProps<{
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vrtktovh {
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
border-top: solid 0.5px var(--divider);
|
||||
//border-bottom: solid 0.5px var(--divider);
|
||||
}
|
||||
|
||||
> .label {
|
||||
font-weight: bold;
|
||||
padding: 1.5em 0 0 0;
|
||||
margin: 0 0 16px 0;
|
||||
.rootFirst {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.label {
|
||||
font-weight: bold;
|
||||
padding: 1.5em 0 0 0;
|
||||
margin: 0 0 16px 0;
|
||||
|
||||
> .main {
|
||||
margin: 1.5em 0 0 0;
|
||||
}
|
||||
|
||||
&.first {
|
||||
border-top: none;
|
||||
|
||||
> .label {
|
||||
padding-top: 0;
|
||||
}
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.labelFirst {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
margin: 1.5em 0 0 0;
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
|
||||
<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
|
||||
<div v-if="pending">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
@@ -8,8 +8,8 @@
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="wszdbhzo">
|
||||
<div><i class="ti ti-alert-triangle"></i> {{ $ts.somethingHappened }}</div>
|
||||
<MkButton inline class="retry" @click="retry"><i class="ti ti-reload"></i> {{ $ts.retry }}</MkButton>
|
||||
<div><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</div>
|
||||
<MkButton inline class="retry" @click="retry"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -18,6 +18,8 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, ref, watch } from 'vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -72,6 +74,8 @@ export default defineComponent({
|
||||
rejected,
|
||||
result,
|
||||
retry,
|
||||
defaultStore,
|
||||
i18n,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
47
packages/frontend/src/components/global/MkA.stories.impl.ts
Normal file
47
packages/frontend/src/components/global/MkA.stories.impl.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { expect } from '@storybook/jest';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkA from './MkA.vue';
|
||||
import { tick } from '@/scripts/test-utils';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkA,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkA v-bind="props">Misskey</MkA>',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
const canvas = within(canvasElement);
|
||||
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||
await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
|
||||
await userEvent.click(a, { button: 2 });
|
||||
await tick();
|
||||
const menu = canvas.getByRole('menu');
|
||||
await expect(menu).toBeInTheDocument();
|
||||
await userEvent.click(a, { button: 0 });
|
||||
a.blur();
|
||||
await tick();
|
||||
await expect(menu).not.toBeInTheDocument();
|
||||
},
|
||||
args: {
|
||||
to: '#test',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkA>;
|
@@ -0,0 +1,43 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { userDetailed } from '../../../.storybook/fakes';
|
||||
import MkAcct from './MkAcct.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkAcct,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkAcct v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
user: {
|
||||
...userDetailed(),
|
||||
host: null,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAcct>;
|
||||
export const Detail = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
user: userDetailed(),
|
||||
detail: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAcct>;
|
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<span>
|
||||
<span>@{{ user.username }}</span>
|
||||
<span v-if="user.host || detail || $store.state.showFullAcct" style="opacity: 0.5;">@{{ user.host || host }}</span>
|
||||
<span v-if="user.host || detail || defaultStore.state.showFullAcct" style="opacity: 0.5;">@{{ user.host || host }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import * as misskey from 'misskey-js';
|
||||
import { toUnicode } from 'punycode/';
|
||||
import { host as hostRaw } from '@/config';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
defineProps<{
|
||||
user: misskey.entities.UserDetailed;
|
||||
@@ -17,4 +18,3 @@ defineProps<{
|
||||
|
||||
const host = toUnicode(hostRaw);
|
||||
</script>
|
||||
|
||||
|
120
packages/frontend/src/components/global/MkAd.stories.impl.ts
Normal file
120
packages/frontend/src/components/global/MkAd.stories.impl.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { expect } from '@storybook/jest';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { i18n } from '@/i18n';
|
||||
import MkAd from './MkAd.vue';
|
||||
const common = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkAd,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkAd v-bind="props" />',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement, args }) {
|
||||
const canvas = within(canvasElement);
|
||||
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||
await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
|
||||
const img = within(a).getByRole('img');
|
||||
await expect(img).toBeInTheDocument();
|
||||
let buttons = canvas.getAllByRole<HTMLButtonElement>('button');
|
||||
await expect(buttons).toHaveLength(1);
|
||||
const i = buttons[0];
|
||||
await expect(i).toBeInTheDocument();
|
||||
await userEvent.click(i);
|
||||
await expect(a).not.toBeInTheDocument();
|
||||
await expect(i).not.toBeInTheDocument();
|
||||
buttons = canvas.getAllByRole<HTMLButtonElement>('button');
|
||||
await expect(buttons).toHaveLength(args.__hasReduce ? 2 : 1);
|
||||
const reduce = args.__hasReduce ? buttons[0] : null;
|
||||
const back = buttons[args.__hasReduce ? 1 : 0];
|
||||
if (reduce) {
|
||||
await expect(reduce).toBeInTheDocument();
|
||||
await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd);
|
||||
}
|
||||
await expect(back).toBeInTheDocument();
|
||||
await expect(back).toHaveTextContent(i18n.ts._ad.back);
|
||||
await userEvent.click(back);
|
||||
if (reduce) {
|
||||
await expect(reduce).not.toBeInTheDocument();
|
||||
}
|
||||
await expect(back).not.toBeInTheDocument();
|
||||
const aAgain = canvas.getByRole<HTMLAnchorElement>('link');
|
||||
await expect(aAgain).toBeInTheDocument();
|
||||
const imgAgain = within(aAgain).getByRole('img');
|
||||
await expect(imgAgain).toBeInTheDocument();
|
||||
},
|
||||
args: {
|
||||
prefer: [],
|
||||
specify: {
|
||||
id: 'someadid',
|
||||
radio: 1,
|
||||
url: '#test',
|
||||
},
|
||||
__hasReduce: true,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAd>;
|
||||
export const Square = {
|
||||
...common,
|
||||
args: {
|
||||
...common.args,
|
||||
specify: {
|
||||
...common.args.specify,
|
||||
place: 'square',
|
||||
imageUrl:
|
||||
'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAd>;
|
||||
export const Horizontal = {
|
||||
...common,
|
||||
args: {
|
||||
...common.args,
|
||||
specify: {
|
||||
...common.args.specify,
|
||||
place: 'horizontal',
|
||||
imageUrl:
|
||||
'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAd>;
|
||||
export const HorizontalBig = {
|
||||
...common,
|
||||
args: {
|
||||
...common.args,
|
||||
specify: {
|
||||
...common.args.specify,
|
||||
place: 'horizontal-big',
|
||||
imageUrl:
|
||||
'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAd>;
|
||||
export const ZeroRatio = {
|
||||
...Square,
|
||||
args: {
|
||||
...Square.args,
|
||||
specify: {
|
||||
...Square.args.specify,
|
||||
ratio: 0,
|
||||
},
|
||||
__hasReduce: false,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAd>;
|
@@ -9,9 +9,9 @@
|
||||
<div v-else :class="$style.menu">
|
||||
<div :class="$style.menuContainer">
|
||||
<div>Ads by {{ host }}</div>
|
||||
<!--<MkButton class="button" primary>{{ $ts._ad.like }}</MkButton>-->
|
||||
<MkButton v-if="chosen.ratio !== 0" :class="$style.menuButton" @click="reduceFrequency">{{ $ts._ad.reduceFrequencyOfThisAd }}</MkButton>
|
||||
<button class="_textButton" @click="toggleMenu">{{ $ts._ad.back }}</button>
|
||||
<!--<MkButton class="button" primary>{{ i18n.ts._ad.like }}</MkButton>-->
|
||||
<MkButton v-if="chosen.ratio !== 0" :class="$style.menuButton" @click="reduceFrequency">{{ i18n.ts._ad.reduceFrequencyOfThisAd }}</MkButton>
|
||||
<button class="_textButton" @click="toggleMenu">{{ i18n.ts._ad.back }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { instance } from '@/instance';
|
||||
import { host } from '@/config';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
@@ -82,7 +83,7 @@ const choseAd = (): Ad | null => {
|
||||
};
|
||||
|
||||
const chosen = ref(choseAd());
|
||||
const shouldHide = $ref($i && $i.policies.canHideAds);
|
||||
const shouldHide = $ref($i && $i.policies.canHideAds && (props.specify == null));
|
||||
|
||||
function reduceFrequency(): void {
|
||||
if (chosen.value == null) return;
|
||||
|
@@ -0,0 +1,66 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { userDetailed } from '../../../.storybook/fakes';
|
||||
import MkAvatar from './MkAvatar.vue';
|
||||
const common = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkAvatar,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkAvatar v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
user: userDetailed(),
|
||||
},
|
||||
decorators: [
|
||||
(Story, context) => ({
|
||||
// eslint-disable-next-line quotes
|
||||
template: `<div :style="{ display: 'grid', width: '${context.args.size}px', height: '${context.args.size}px' }"><story/></div>`,
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAvatar>;
|
||||
export const ProfilePage = {
|
||||
...common,
|
||||
args: {
|
||||
...common.args,
|
||||
size: 120,
|
||||
indicator: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAvatar>;
|
||||
export const ProfilePageCat = {
|
||||
...ProfilePage,
|
||||
args: {
|
||||
...ProfilePage.args,
|
||||
user: {
|
||||
...userDetailed(),
|
||||
isCat: true,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
...ProfilePage.parameters,
|
||||
chromatic: {
|
||||
/* Your story couldn’t be captured because it exceeds our 25,000,000px limit. Its dimensions are 5,504,893x5,504,892px. Possible ways to resolve:
|
||||
* * Separate pages into components
|
||||
* * Minimize the number of very large elements in a story
|
||||
*/
|
||||
disableSnapshot: true,
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAvatar>;
|
@@ -1,31 +1,40 @@
|
||||
<template>
|
||||
<span v-if="!link" v-user-preview="preview ? user.id : undefined" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: $store.state.squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
|
||||
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
|
||||
<img :class="$style.inner" :src="url" decoding="async"/>
|
||||
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
|
||||
<template v-if="user.isCat">
|
||||
<div :class="$style.earLeft"/>
|
||||
<div :class="$style.earRight"/>
|
||||
</template>
|
||||
</span>
|
||||
<MkA v-else v-user-preview="preview ? user.id : undefined" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: $store.state.squareAvatars }]" :style="{ color }" :title="acct(user)" :to="userPage(user)" :target="target">
|
||||
<img :class="$style.inner" :src="url" decoding="async"/>
|
||||
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
|
||||
<template v-if="user.isCat">
|
||||
<div :class="$style.earLeft"/>
|
||||
<div :class="$style.earRight"/>
|
||||
</template>
|
||||
</MkA>
|
||||
<div v-if="user.isCat" :class="[$style.ears, { [$style.mask]: useBlurEffect }]">
|
||||
<div :class="$style.earLeft">
|
||||
<div v-if="false" :class="$style.layer">
|
||||
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
|
||||
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
|
||||
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.earRight">
|
||||
<div v-if="false" :class="$style.layer">
|
||||
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
|
||||
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
|
||||
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watch } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import MkA from './MkA.vue';
|
||||
import { getStaticImageUrl } from '@/scripts/media-proxy';
|
||||
import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
|
||||
import { acct, userPage } from '@/filters/user';
|
||||
import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const animation = $ref(defaultStore.state.animation);
|
||||
const squareAvatars = $ref(defaultStore.state.squareAvatars);
|
||||
const useBlurEffect = $ref(defaultStore.state.useBlurEffect);
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
user: misskey.entities.User;
|
||||
target?: string | null;
|
||||
@@ -43,15 +52,20 @@ const emit = defineEmits<{
|
||||
(ev: 'click', v: MouseEvent): void;
|
||||
}>();
|
||||
|
||||
const bound = $computed(() => props.link
|
||||
? { to: userPage(props.user), target: props.target }
|
||||
: {});
|
||||
|
||||
const url = $computed(() => defaultStore.state.disableShowingAnimatedImages
|
||||
? getStaticImageUrl(props.user.avatarUrl)
|
||||
: props.user.avatarUrl);
|
||||
|
||||
function onClick(ev: MouseEvent) {
|
||||
function onClick(ev: MouseEvent): void {
|
||||
if (props.link) return;
|
||||
emit('click', ev);
|
||||
}
|
||||
|
||||
let color = $ref();
|
||||
let color = $ref<string | undefined>();
|
||||
|
||||
watch(() => props.user.avatarBlurhash, () => {
|
||||
color = extractAvgColorFromBlurhash(props.user.avatarBlurhash);
|
||||
@@ -77,6 +91,18 @@ watch(() => props.user.avatarBlurhash, () => {
|
||||
to { transform: rotate(-37.6deg) skew(-30deg); }
|
||||
}
|
||||
|
||||
@keyframes eartightleft {
|
||||
from { transform: rotate(37.6deg) skew(30deg); }
|
||||
50% { transform: rotate(37.4deg) skew(30deg); }
|
||||
to { transform: rotate(37.6deg) skew(30deg); }
|
||||
}
|
||||
|
||||
@keyframes eartightright {
|
||||
from { transform: rotate(-37.6deg) skew(-30deg); }
|
||||
50% { transform: rotate(-37.4deg) skew(-30deg); }
|
||||
to { transform: rotate(-37.6deg) skew(-30deg); }
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
@@ -118,42 +144,149 @@ watch(() => props.user.avatarBlurhash, () => {
|
||||
}
|
||||
|
||||
.cat {
|
||||
> .earLeft,
|
||||
> .earRight {
|
||||
> .ears {
|
||||
contain: strict;
|
||||
display: inline-block;
|
||||
height: 50%;
|
||||
width: 50%;
|
||||
background: currentColor;
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 50%;
|
||||
pointer-events: none;
|
||||
|
||||
&::before {
|
||||
contain: strict;
|
||||
content: '';
|
||||
display: block;
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
margin: 20%;
|
||||
background: #df548f;
|
||||
&.mask {
|
||||
-webkit-mask:
|
||||
url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><filter id="a"><feGaussianBlur in="SourceGraphic" stdDeviation="1"/></filter><circle cx="16" cy="16" r="15" filter="url(%23a)"/></svg>') center / 50% 50%,
|
||||
linear-gradient(#fff, #fff);
|
||||
-webkit-mask-composite: destination-out, source-over;
|
||||
mask:
|
||||
url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><filter id="a"><feGaussianBlur in="SourceGraphic" stdDeviation="1"/></filter><circle cx="16" cy="16" r="15" filter="url(%23a)"/></svg>') exclude center / 50% 50%,
|
||||
linear-gradient(#fff, #fff); // polyfill of `image(#fff)`
|
||||
|
||||
> .earLeft {
|
||||
animation: eartightleft 6s infinite;
|
||||
}
|
||||
|
||||
> .earRight {
|
||||
animation: eartightright 6s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .earLeft {
|
||||
border-radius: 0 75% 75%;
|
||||
transform: rotate(37.5deg) skew(30deg);
|
||||
}
|
||||
> .earLeft,
|
||||
> .earRight {
|
||||
contain: strict;
|
||||
display: inline-block;
|
||||
height: 50%;
|
||||
width: 50%;
|
||||
background: currentColor;
|
||||
|
||||
> .earRight {
|
||||
border-radius: 75% 0 75% 75%;
|
||||
transform: rotate(-37.5deg) skew(-30deg);
|
||||
}
|
||||
&::after {
|
||||
contain: strict;
|
||||
content: '';
|
||||
display: block;
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
margin: 20%;
|
||||
background: #df548f;
|
||||
}
|
||||
|
||||
> .layer {
|
||||
contain: strict;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 280%;
|
||||
height: 280%;
|
||||
|
||||
> .plot {
|
||||
contain: strict;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
clip-path: path('M0 0H1V1H0z');
|
||||
transform: scale(32767);
|
||||
transform-origin: 0 0;
|
||||
opacity: 0.5;
|
||||
|
||||
&:first-child {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
opacity: calc(1 / 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> .earLeft {
|
||||
animation: earwiggleleft 1s infinite;
|
||||
transform: rotate(37.5deg) skew(30deg);
|
||||
|
||||
&, &::after {
|
||||
border-radius: 0 75% 75%;
|
||||
}
|
||||
|
||||
> .layer {
|
||||
left: 0;
|
||||
transform:
|
||||
skew(-30deg)
|
||||
rotate(-37.5deg)
|
||||
translate(-2.82842712475%, /* -2 * sqrt(2) */
|
||||
-38.5857864376%); /* 40 - 2 * sqrt(2) */
|
||||
|
||||
> .plot {
|
||||
background-position: 20% 10%; /* ~= 37.5deg */
|
||||
|
||||
&:first-child {
|
||||
background-position-x: 21%;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
background-position-y: 11%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .earRight {
|
||||
animation: earwiggleright 1s infinite;
|
||||
transform: rotate(-37.5deg) skew(-30deg);
|
||||
|
||||
&, &::after {
|
||||
border-radius: 75% 0 75% 75%;
|
||||
}
|
||||
|
||||
> .layer {
|
||||
right: 0;
|
||||
transform:
|
||||
skew(30deg)
|
||||
rotate(37.5deg)
|
||||
translate(2.82842712475%, /* 2 * sqrt(2) */
|
||||
-38.5857864376%); /* 40 - 2 * sqrt(2) */
|
||||
|
||||
> .plot {
|
||||
position: absolute;
|
||||
background-position: 80% 10%; /* ~= 37.5deg */
|
||||
|
||||
&:first-child {
|
||||
background-position-x: 79%;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
background-position-y: 11%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.animation:hover {
|
||||
> .ears {
|
||||
> .earLeft {
|
||||
animation: earwiggleleft 1s infinite;
|
||||
}
|
||||
|
||||
> .earRight {
|
||||
animation: earwiggleright 1s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,45 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkCustomEmoji from './MkCustomEmoji.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkCustomEmoji,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkCustomEmoji v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
name: 'mi',
|
||||
url: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkCustomEmoji>;
|
||||
export const Normal = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
normal: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkCustomEmoji>;
|
||||
export const Missing = {
|
||||
...Default,
|
||||
args: {
|
||||
name: Default.args.name,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkCustomEmoji>;
|
@@ -0,0 +1,32 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import MkEllipsis from './MkEllipsis.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkEllipsis,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkEllipsis v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
static: isChromatic(),
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkEllipsis>;
|
@@ -1,9 +1,19 @@
|
||||
<template>
|
||||
<span :class="$style.root">
|
||||
<span :class="[$style.root, { [$style.static]: static }]">
|
||||
<span :class="$style.dot">.</span><span :class="$style.dot">.</span><span :class="$style.dot">.</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
static?: boolean;
|
||||
}>(), {
|
||||
static: false,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
@keyframes ellipsis {
|
||||
0%, 80%, 100% {
|
||||
@@ -15,7 +25,9 @@
|
||||
}
|
||||
|
||||
.root {
|
||||
|
||||
&.static > .dot {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
|
@@ -0,0 +1,31 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkEmoji from './MkEmoji.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkEmoji,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkEmoji v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
emoji: '❤',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkEmoji>;
|
@@ -0,0 +1,34 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { expect } from '@storybook/jest';
|
||||
import { waitFor } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkError from './MkError.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkError,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkError v-bind="props" />',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement.firstElementChild).not.toBeNull();
|
||||
await waitFor(async () => expect(canvasElement.firstElementChild?.classList).not.toContain('_transition_zoom-enter-active'));
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkError>;
|
@@ -0,0 +1,5 @@
|
||||
export const argTypes = {
|
||||
retry: {
|
||||
action: 'retry',
|
||||
},
|
||||
};
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Transition :name="$store.state.animation ? '_transition_zoom' : ''" appear>
|
||||
<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" appear>
|
||||
<div :class="$style.root">
|
||||
<img :class="$style.img" src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
|
||||
<p :class="$style.text"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p>
|
||||
@@ -11,6 +11,7 @@
|
||||
<script lang="ts" setup>
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'retry'): void;
|
||||
|
@@ -0,0 +1,60 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import MkLoading from './MkLoading.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkLoading,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkLoading v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
static: isChromatic(),
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkLoading>;
|
||||
export const Inline = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
inline: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkLoading>;
|
||||
export const Colored = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
colored: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkLoading>;
|
||||
export const Mini = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
mini: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkLoading>;
|
||||
export const Em = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
em: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkLoading>;
|
@@ -6,7 +6,7 @@
|
||||
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
|
||||
</g>
|
||||
</svg>
|
||||
<svg :class="[$style.spinner, $style.fg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg :class="[$style.spinner, $style.fg, { [$style.static]: static }]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1.125,0,0,1.125,12,12)">
|
||||
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
|
||||
</g>
|
||||
@@ -19,11 +19,13 @@
|
||||
import { } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
static?: boolean;
|
||||
inline?: boolean;
|
||||
colored?: boolean;
|
||||
mini?: boolean;
|
||||
em?: boolean;
|
||||
}>(), {
|
||||
static: false,
|
||||
inline: false,
|
||||
colored: true,
|
||||
mini: false,
|
||||
@@ -97,5 +99,9 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
.fg {
|
||||
animation: spinner 0.5s linear infinite;
|
||||
|
||||
&.static {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -0,0 +1,74 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkMisskeyFlavoredMarkdown from './MkMisskeyFlavoredMarkdown.vue';
|
||||
import { within } from '@storybook/testing-library';
|
||||
import { expect } from '@storybook/jest';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkMisskeyFlavoredMarkdown,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkMisskeyFlavoredMarkdown v-bind="props" />',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement, args }) {
|
||||
const canvas = within(canvasElement);
|
||||
if (args.plain) {
|
||||
const aiHelloMiskist = canvas.getByText('@ai *Hello*, #Miskist!');
|
||||
await expect(aiHelloMiskist).toBeInTheDocument();
|
||||
} else {
|
||||
const ai = canvas.getByText('@ai');
|
||||
await expect(ai).toBeInTheDocument();
|
||||
await expect(ai.closest('a')).toHaveAttribute('href', '/@ai');
|
||||
const hello = canvas.getByText('Hello');
|
||||
await expect(hello).toBeInTheDocument();
|
||||
await expect(hello.style.fontStyle).toBe('oblique');
|
||||
const miskist = canvas.getByText('#Miskist');
|
||||
await expect(miskist).toBeInTheDocument();
|
||||
await expect(miskist).toHaveAttribute('href', args.isNote ?? true ? '/tags/Miskist' : '/user-tags/Miskist');
|
||||
}
|
||||
const heart = canvas.getByAltText('❤');
|
||||
await expect(heart).toBeInTheDocument();
|
||||
await expect(heart).toHaveAttribute('src', '/twemoji/2764.svg');
|
||||
},
|
||||
args: {
|
||||
text: '@ai *Hello*, #Miskist! ❤',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
||||
export const Plain = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
plain: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
||||
export const Nowrap = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
nowrap: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
||||
export const IsNotNote = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
isNote: false,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
@@ -0,0 +1,99 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { waitFor } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkPageHeader from './MkPageHeader.vue';
|
||||
export const Empty = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkPageHeader,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkPageHeader v-bind="props" />',
|
||||
};
|
||||
},
|
||||
async play() {
|
||||
const wait = new Promise((resolve) => setTimeout(resolve, 800));
|
||||
await waitFor(async () => await wait);
|
||||
},
|
||||
args: {
|
||||
static: true,
|
||||
tabs: [],
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkPageHeader>;
|
||||
export const OneTab = {
|
||||
...Empty,
|
||||
args: {
|
||||
...Empty.args,
|
||||
tab: 'sometabkey',
|
||||
tabs: [
|
||||
{
|
||||
key: 'sometabkey',
|
||||
title: 'Some Tab Title',
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies StoryObj<typeof MkPageHeader>;
|
||||
export const Icon = {
|
||||
...OneTab,
|
||||
args: {
|
||||
...OneTab.args,
|
||||
tabs: [
|
||||
{
|
||||
...OneTab.args.tabs[0],
|
||||
icon: 'ti ti-home',
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies StoryObj<typeof MkPageHeader>;
|
||||
export const IconOnly = {
|
||||
...Icon,
|
||||
args: {
|
||||
...Icon.args,
|
||||
tabs: [
|
||||
{
|
||||
...Icon.args.tabs[0],
|
||||
title: undefined,
|
||||
iconOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies StoryObj<typeof MkPageHeader>;
|
||||
export const SomeTabs = {
|
||||
...Empty,
|
||||
args: {
|
||||
...Empty.args,
|
||||
tab: 'princess',
|
||||
tabs: [
|
||||
{
|
||||
key: 'princess',
|
||||
title: 'Princess',
|
||||
icon: 'ti ti-crown',
|
||||
},
|
||||
{
|
||||
key: 'fairy',
|
||||
title: 'Fairy',
|
||||
icon: 'ti ti-snowflake',
|
||||
},
|
||||
{
|
||||
key: 'angel',
|
||||
title: 'Angel',
|
||||
icon: 'ti ti-feather',
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies StoryObj<typeof MkPageHeader>;
|
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import MkPageHeader_tabs from './MkPageHeader.tabs.vue';
|
||||
void MkPageHeader_tabs;
|
@@ -33,14 +33,18 @@
|
||||
<script lang="ts">
|
||||
export type Tab = {
|
||||
key: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
iconOnly?: boolean;
|
||||
onClick?: (ev: MouseEvent) => void;
|
||||
} & {
|
||||
iconOnly: true;
|
||||
iccn: string;
|
||||
};
|
||||
} & (
|
||||
| {
|
||||
iconOnly?: false;
|
||||
title: string;
|
||||
icon?: string;
|
||||
}
|
||||
| {
|
||||
iconOnly: true;
|
||||
icon: string;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
@@ -8,7 +8,9 @@
|
||||
|
||||
<template v-if="metadata">
|
||||
<div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
|
||||
<MkAvatar v-if="metadata.avatar" :class="$style.titleAvatar" :user="metadata.avatar" indicator/>
|
||||
<div v-if="metadata.avatar" :class="$style.titleAvatarContainer">
|
||||
<MkAvatar :class="$style.titleAvatar" :user="metadata.avatar" indicator/>
|
||||
</div>
|
||||
<i v-else-if="metadata.icon" :class="[$style.titleIcon, metadata.icon]"></i>
|
||||
|
||||
<div :class="$style.title">
|
||||
@@ -241,7 +243,7 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: min(30vw, 400px);
|
||||
overflow: auto;
|
||||
overflow: clip;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
@@ -249,13 +251,19 @@ onUnmounted(() => {
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.titleAvatar {
|
||||
.titleAvatarContainer {
|
||||
$size: 32px;
|
||||
display: inline-block;
|
||||
contain: strict;
|
||||
overflow: clip;
|
||||
width: $size;
|
||||
height: $size;
|
||||
vertical-align: bottom;
|
||||
margin: 0 8px;
|
||||
padding: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.titleAvatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import MkStickyContainer from './MkStickyContainer.vue';
|
||||
void MkStickyContainer;
|
312
packages/frontend/src/components/global/MkTime.stories.impl.ts
Normal file
312
packages/frontend/src/components/global/MkTime.stories.impl.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { expect } from '@storybook/jest';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkTime from './MkTime.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { dateTimeFormat } from '@/scripts/intl-const';
|
||||
const now = new Date('2023-04-01T00:00:00.000Z');
|
||||
const future = new Date(8640000000000000);
|
||||
const oneHourAgo = new Date(now.getTime() - 3600000);
|
||||
const oneDayAgo = new Date(now.getTime() - 86400000);
|
||||
const oneWeekAgo = new Date(now.getTime() - 604800000);
|
||||
const oneMonthAgo = new Date(now.getTime() - 2592000000);
|
||||
const oneYearAgo = new Date(now.getTime() - 31536000000);
|
||||
export const Empty = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkTime,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkTime v-bind="props" />',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.ts._ago.invalid);
|
||||
},
|
||||
args: {
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const RelativeFuture = {
|
||||
...Empty,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.ts._ago.future);
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: future,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const AbsoluteFuture = {
|
||||
...Empty,
|
||||
async play({ canvasElement, args }) {
|
||||
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: future,
|
||||
mode: 'absolute',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const DetailFuture = {
|
||||
...Empty,
|
||||
async play(context) {
|
||||
await AbsoluteFuture.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||
await RelativeFuture.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(')');
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: future,
|
||||
mode: 'detail',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const RelativeNow = {
|
||||
...Empty,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.ts._ago.justNow);
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: now,
|
||||
origin: now,
|
||||
mode: 'relative',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const AbsoluteNow = {
|
||||
...Empty,
|
||||
async play({ canvasElement, args }) {
|
||||
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: now,
|
||||
origin: now,
|
||||
mode: 'absolute',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const DetailNow = {
|
||||
...Empty,
|
||||
async play(context) {
|
||||
await AbsoluteNow.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||
await RelativeNow.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(')');
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: now,
|
||||
origin: now,
|
||||
mode: 'detail',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const RelativeOneHourAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.hoursAgo', { n: 1 }));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneHourAgo,
|
||||
origin: now,
|
||||
mode: 'relative',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const AbsoluteOneHourAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement, args }) {
|
||||
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneHourAgo,
|
||||
origin: now,
|
||||
mode: 'absolute',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const DetailOneHourAgo = {
|
||||
...Empty,
|
||||
async play(context) {
|
||||
await AbsoluteOneHourAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||
await RelativeOneHourAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(')');
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneHourAgo,
|
||||
origin: now,
|
||||
mode: 'detail',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const RelativeOneDayAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.daysAgo', { n: 1 }));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneDayAgo,
|
||||
origin: now,
|
||||
mode: 'relative',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const AbsoluteOneDayAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement, args }) {
|
||||
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneDayAgo,
|
||||
origin: now,
|
||||
mode: 'absolute',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const DetailOneDayAgo = {
|
||||
...Empty,
|
||||
async play(context) {
|
||||
await AbsoluteOneDayAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||
await RelativeOneDayAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(')');
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneDayAgo,
|
||||
origin: now,
|
||||
mode: 'detail',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const RelativeOneWeekAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.weeksAgo', { n: 1 }));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneWeekAgo,
|
||||
origin: now,
|
||||
mode: 'relative',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const AbsoluteOneWeekAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement, args }) {
|
||||
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneWeekAgo,
|
||||
origin: now,
|
||||
mode: 'absolute',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const DetailOneWeekAgo = {
|
||||
...Empty,
|
||||
async play(context) {
|
||||
await AbsoluteOneWeekAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||
await RelativeOneWeekAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(')');
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneWeekAgo,
|
||||
origin: now,
|
||||
mode: 'detail',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const RelativeOneMonthAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.monthsAgo', { n: 1 }));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneMonthAgo,
|
||||
origin: now,
|
||||
mode: 'relative',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const AbsoluteOneMonthAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement, args }) {
|
||||
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneMonthAgo,
|
||||
origin: now,
|
||||
mode: 'absolute',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const DetailOneMonthAgo = {
|
||||
...Empty,
|
||||
async play(context) {
|
||||
await AbsoluteOneMonthAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||
await RelativeOneMonthAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(')');
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneMonthAgo,
|
||||
origin: now,
|
||||
mode: 'detail',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const RelativeOneYearAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.yearsAgo', { n: 1 }));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneYearAgo,
|
||||
origin: now,
|
||||
mode: 'relative',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const AbsoluteOneYearAgo = {
|
||||
...Empty,
|
||||
async play({ canvasElement, args }) {
|
||||
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneYearAgo,
|
||||
origin: now,
|
||||
mode: 'absolute',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
||||
export const DetailOneYearAgo = {
|
||||
...Empty,
|
||||
async play(context) {
|
||||
await AbsoluteOneYearAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||
await RelativeOneYearAgo.play(context);
|
||||
await expect(context.canvasElement).toHaveTextContent(')');
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
time: oneYearAgo,
|
||||
origin: now,
|
||||
mode: 'detail',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTime>;
|
@@ -14,8 +14,10 @@ import { dateTimeFormat } from '@/scripts/intl-const';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
time: Date | string | number | null;
|
||||
origin?: Date | null;
|
||||
mode?: 'relative' | 'absolute' | 'detail';
|
||||
}>(), {
|
||||
origin: null,
|
||||
mode: 'relative',
|
||||
});
|
||||
|
||||
@@ -25,7 +27,7 @@ const _time = props.time == null ? NaN :
|
||||
const invalid = Number.isNaN(_time);
|
||||
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
|
||||
|
||||
let now = $ref((new Date()).getTime());
|
||||
let now = $ref((props.origin ?? new Date()).getTime());
|
||||
const relative = $computed<string>(() => {
|
||||
if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない
|
||||
if (invalid) return i18n.ts._ago.invalid;
|
||||
@@ -46,7 +48,7 @@ const relative = $computed<string>(() => {
|
||||
let tickId: number;
|
||||
|
||||
function tick() {
|
||||
now = (new Date()).getTime();
|
||||
now = props.origin ?? (new Date()).getTime();
|
||||
const ago = (now - _time) / 1000/*ms*/;
|
||||
const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
|
||||
|
||||
|
@@ -0,0 +1,77 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { expect } from '@storybook/jest';
|
||||
import { userEvent, waitFor, within } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { commonHandlers } from '../../../.storybook/mocks';
|
||||
import MkUrl from './MkUrl.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkUrl,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkUrl v-bind="props">Text</MkUrl>',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
const canvas = within(canvasElement);
|
||||
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||
await expect(a).toHaveAttribute('href', 'https://misskey-hub.net/');
|
||||
await waitFor(() => userEvent.hover(a));
|
||||
/*
|
||||
await tick(); // FIXME: wait for network request
|
||||
const anchors = canvas.getAllByRole<HTMLAnchorElement>('link');
|
||||
const popup = anchors.find(anchor => anchor !== a)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||
await expect(popup).toBeInTheDocument();
|
||||
await expect(popup).toHaveAttribute('href', 'https://misskey-hub.net/');
|
||||
await expect(popup).toHaveTextContent('Misskey Hub');
|
||||
await expect(popup).toHaveTextContent('Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。');
|
||||
await expect(popup).toHaveTextContent('misskey-hub.net');
|
||||
const icon = within(popup).getByRole('img');
|
||||
await expect(icon).toBeInTheDocument();
|
||||
await expect(icon).toHaveAttribute('src', 'https://misskey-hub.net/favicon.ico');
|
||||
*/
|
||||
await waitFor(() => userEvent.unhover(a));
|
||||
},
|
||||
args: {
|
||||
url: 'https://misskey-hub.net/',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.get('/url', (req, res, ctx) => {
|
||||
return res(ctx.json({
|
||||
title: 'Misskey Hub',
|
||||
icon: 'https://misskey-hub.net/favicon.ico',
|
||||
description: 'Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。',
|
||||
thumbnail: null,
|
||||
player: {
|
||||
url: null,
|
||||
width: null,
|
||||
height: null,
|
||||
allow: [],
|
||||
},
|
||||
sitename: 'misskey-hub.net',
|
||||
sensitive: false,
|
||||
url: 'https://misskey-hub.net/',
|
||||
}));
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkUrl>;
|
@@ -0,0 +1,57 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { expect } from '@storybook/jest';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { userDetailed } from '../../../.storybook/fakes';
|
||||
import MkUserName from './MkUserName.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkUserName,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkUserName v-bind="props"/>',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(userDetailed().name);
|
||||
},
|
||||
args: {
|
||||
user: userDetailed(),
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkUserName>;
|
||||
export const Anonymous = {
|
||||
...Default,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(userDetailed().username);
|
||||
},
|
||||
args: {
|
||||
...Default.args,
|
||||
user: {
|
||||
...userDetailed(),
|
||||
name: null,
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkUserName>;
|
||||
export const Wrap = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
nowrap: false,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkUserName>;
|
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import RouterView from './RouterView.vue';
|
||||
void RouterView;
|
@@ -20,26 +20,32 @@ import MkSpacer from './global/MkSpacer.vue';
|
||||
import MkStickyContainer from './global/MkStickyContainer.vue';
|
||||
|
||||
export default function(app: App) {
|
||||
app.component('I18n', I18n);
|
||||
app.component('RouterView', RouterView);
|
||||
app.component('Mfm', Mfm);
|
||||
app.component('MkA', MkA);
|
||||
app.component('MkAcct', MkAcct);
|
||||
app.component('MkAvatar', MkAvatar);
|
||||
app.component('MkEmoji', MkEmoji);
|
||||
app.component('MkCustomEmoji', MkCustomEmoji);
|
||||
app.component('MkUserName', MkUserName);
|
||||
app.component('MkEllipsis', MkEllipsis);
|
||||
app.component('MkTime', MkTime);
|
||||
app.component('MkUrl', MkUrl);
|
||||
app.component('MkLoading', MkLoading);
|
||||
app.component('MkError', MkError);
|
||||
app.component('MkAd', MkAd);
|
||||
app.component('MkPageHeader', MkPageHeader);
|
||||
app.component('MkSpacer', MkSpacer);
|
||||
app.component('MkStickyContainer', MkStickyContainer);
|
||||
for (const [key, value] of Object.entries(components)) {
|
||||
app.component(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export const components = {
|
||||
I18n: I18n,
|
||||
RouterView: RouterView,
|
||||
Mfm: Mfm,
|
||||
MkA: MkA,
|
||||
MkAcct: MkAcct,
|
||||
MkAvatar: MkAvatar,
|
||||
MkEmoji: MkEmoji,
|
||||
MkCustomEmoji: MkCustomEmoji,
|
||||
MkUserName: MkUserName,
|
||||
MkEllipsis: MkEllipsis,
|
||||
MkTime: MkTime,
|
||||
MkUrl: MkUrl,
|
||||
MkLoading: MkLoading,
|
||||
MkError: MkError,
|
||||
MkAd: MkAd,
|
||||
MkPageHeader: MkPageHeader,
|
||||
MkSpacer: MkSpacer,
|
||||
MkStickyContainer: MkStickyContainer,
|
||||
};
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
export interface GlobalComponents {
|
||||
I18n: typeof I18n;
|
||||
|
@@ -1,21 +1,21 @@
|
||||
<template>
|
||||
<div class="voxdxuby">
|
||||
<XNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/>
|
||||
<XNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/>
|
||||
<MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/>
|
||||
<MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, PropType, Ref, ref } from 'vue';
|
||||
import XNote from '@/components/MkNote.vue';
|
||||
import XNoteDetailed from '@/components/MkNoteDetailed.vue';
|
||||
import MkNote from '@/components/MkNote.vue';
|
||||
import MkNoteDetailed from '@/components/MkNoteDetailed.vue';
|
||||
import * as os from '@/os';
|
||||
import { NoteBlock } from '@/scripts/hpml/block';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
XNote,
|
||||
XNoteDetailed,
|
||||
MkNote,
|
||||
MkNoteDetailed,
|
||||
},
|
||||
props: {
|
||||
block: {
|
||||
|
@@ -16,6 +16,8 @@ import { apiUrl } from '@/config';
|
||||
import * as os from '@/os';
|
||||
import { PostBlock } from '@/scripts/hpml/block';
|
||||
import { Hpml } from '@/scripts/hpml/evaluator';
|
||||
import { defaultStore } from '@/store';
|
||||
import { $i } from '@/account';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -54,9 +56,9 @@ export default defineComponent({
|
||||
canvas.toBlob(blob => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob);
|
||||
formData.append('i', this.$i.token);
|
||||
if (this.$store.state.uploadFolder) {
|
||||
formData.append('folderId', this.$store.state.uploadFolder);
|
||||
formData.append('i', $i.token);
|
||||
if (defaultStore.state.uploadFolder) {
|
||||
formData.append('folderId', defaultStore.state.uploadFolder);
|
||||
}
|
||||
|
||||
window.fetch(apiUrl + '/drive/files/create', {
|
||||
|
@@ -6,11 +6,12 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { TextBlock } from '@/scripts/hpml/block';
|
||||
import { Hpml } from '@/scripts/hpml/evaluator';
|
||||
import { defineAsyncComponent, defineComponent, PropType } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { TextBlock } from '@/scripts/hpml/block';
|
||||
import { Hpml } from '@/scripts/hpml/evaluator';
|
||||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
|
||||
import { $i } from '@/account';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -29,6 +30,7 @@ export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
text: this.hpml.interpolate(this.block.text),
|
||||
$i,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@@ -53,6 +53,7 @@ export const ROLE_POLICIES = [
|
||||
'canPublicNote',
|
||||
'canInvite',
|
||||
'canManageCustomEmojis',
|
||||
'canSearchNotes',
|
||||
'canHideAds',
|
||||
'driveCapacityMb',
|
||||
'pinLimit',
|
||||
|
27
packages/frontend/src/debug.ts
Normal file
27
packages/frontend/src/debug.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { type ComponentInternalInstance, getCurrentInstance } from 'vue';
|
||||
|
||||
export function isDebuggerEnabled(id: number): boolean {
|
||||
try {
|
||||
return localStorage.getItem(`DEBUG_${id}`) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function switchDebuggerEnabled(id: number, enabled: boolean): void {
|
||||
if (enabled) {
|
||||
localStorage.setItem(`DEBUG_${id}`, '');
|
||||
} else {
|
||||
localStorage.removeItem(`DEBUG_${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function stackTraceInstances(): ComponentInternalInstance[] {
|
||||
let instance = getCurrentInstance();
|
||||
const stack: ComponentInternalInstance[] = [];
|
||||
while (instance) {
|
||||
stack.push(instance);
|
||||
instance = instance.parent;
|
||||
}
|
||||
return stack;
|
||||
}
|
@@ -14,17 +14,23 @@ import adaptiveBg from './adaptive-bg';
|
||||
import container from './container';
|
||||
|
||||
export default function(app: App) {
|
||||
app.directive('userPreview', userPreview);
|
||||
app.directive('user-preview', userPreview);
|
||||
app.directive('get-size', getSize);
|
||||
app.directive('ripple', ripple);
|
||||
app.directive('tooltip', tooltip);
|
||||
app.directive('hotkey', hotkey);
|
||||
app.directive('appear', appear);
|
||||
app.directive('anim', anim);
|
||||
app.directive('click-anime', clickAnime);
|
||||
app.directive('panel', panel);
|
||||
app.directive('adaptive-border', adaptiveBorder);
|
||||
app.directive('adaptive-bg', adaptiveBg);
|
||||
app.directive('container', container);
|
||||
for (const [key, value] of Object.entries(directives)) {
|
||||
app.directive(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export const directives = {
|
||||
'userPreview': userPreview,
|
||||
'user-preview': userPreview,
|
||||
'get-size': getSize,
|
||||
'ripple': ripple,
|
||||
'tooltip': tooltip,
|
||||
'hotkey': hotkey,
|
||||
'appear': appear,
|
||||
'anim': anim,
|
||||
'click-anime': clickAnime,
|
||||
'panel': panel,
|
||||
'adaptive-border': adaptiveBorder,
|
||||
'adaptive-bg': adaptiveBg,
|
||||
'container': container,
|
||||
};
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user