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:
和風ドレッシング
2023-04-08 22:01:55 +09:00
650 changed files with 32472 additions and 9221 deletions

View File

@@ -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',

View 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);

View 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>

View File

@@ -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>;

View 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>;

View File

@@ -0,0 +1,2 @@
import MkCaptcha from './MkCaptcha.vue';
void MkCaptcha;

View File

@@ -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;
}>();

View 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>

View File

@@ -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() {

View File

@@ -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<{

View File

@@ -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`;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -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,
};
},

View File

@@ -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>;

View File

@@ -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;
}
}
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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([{

View File

@@ -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([{

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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));

View File

@@ -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',

View File

@@ -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.`,
};
},

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;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}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&amp;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,
});
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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,
};
},
});

View 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>;

View File

@@ -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>;

View File

@@ -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>

View 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>;

View File

@@ -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;

View File

@@ -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 couldnt 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>;

View File

@@ -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;
}
}
}
}

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -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 {

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -0,0 +1,5 @@
export const argTypes = {
retry: {
action: 'retry',
},
};

View File

@@ -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;

View File

@@ -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>;

View File

@@ -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>

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -0,0 +1,3 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import MkPageHeader_tabs from './MkPageHeader.tabs.vue';
void MkPageHeader_tabs;

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -0,0 +1,3 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import MkStickyContainer from './MkStickyContainer.vue';
void MkStickyContainer;

View 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>;

View File

@@ -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;

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -0,0 +1,3 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import RouterView from './RouterView.vue';
void RouterView;

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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', {

View File

@@ -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: {

View File

@@ -53,6 +53,7 @@ export const ROLE_POLICIES = [
'canPublicNote',
'canInvite',
'canManageCustomEmojis',
'canSearchNotes',
'canHideAds',
'driveCapacityMb',
'pinLimit',

View 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;
}

View File

@@ -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