Merge branch 'develop' into bh-worker

This commit is contained in:
syuilo 2023-05-08 17:52:44 +09:00
commit 04ddada482
27 changed files with 851 additions and 260 deletions

View file

@ -39,6 +39,7 @@
- Fix: フォローリクエストの通知が残る問題を修正 - Fix: フォローリクエストの通知が残る問題を修正
### Client ### Client
- アカウント作成時に初期設定ウィザードを表示するように
- チャンネル内検索ができるように - チャンネル内検索ができるように
- チャンネル検索ですべてのチャンネルの取得/表示ができるように - チャンネル検索ですべてのチャンネルの取得/表示ができるように
- 通知の表示をカスタマイズできるように - 通知の表示をカスタマイズできるように

View file

@ -1036,6 +1036,21 @@ channelArchiveConfirmTitle: "{name}をアーカイブしますか?"
channelArchiveConfirmDescription: "アーカイブすると、チャンネル一覧や検索結果に表示されなくなり、新たな書き込みもできなくなります。" channelArchiveConfirmDescription: "アーカイブすると、チャンネル一覧や検索結果に表示されなくなり、新たな書き込みもできなくなります。"
thisChannelArchived: "このチャンネルはアーカイブされています。" thisChannelArchived: "このチャンネルはアーカイブされています。"
displayOfNote: "ノートの表示" displayOfNote: "ノートの表示"
initialAccountSetting: "初期設定"
youFollowing: "フォロー中"
_initialAccountSetting:
accountCreated: "アカウントの作成が完了しました!"
letsFillYourProfile: "まずはあなたのプロフィールを設定しましょう。"
profileSetting: "プロフィール設定"
theseSettingsCanEditLater: "これらの設定は後から変更できます。"
youCanEditMoreSettingsInSettingsPageLater: "この他にも様々な設定を「設定」ページから行えます。ぜひ後で確認してみてください。"
followUsers: "タイムラインを構築するため、気になるユーザーをフォローしてみましょう。"
pushNotificationDescription: "プッシュ通知を有効にすると{name}の通知をお使いのデバイスで受け取ることができます。"
initialAccountSettingCompleted: "初期設定が完了しました!"
haveFun: "{name}をお楽しみください!"
ifYouNeedLearnMore: "{name}(Misskey)の使い方などを詳しく知るには{link}をご覧ください。"
skipAreYouSure: "初期設定をスキップしますか?"
_serverRules: _serverRules:
description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。"
@ -1615,32 +1630,16 @@ _time:
hour: "時間" hour: "時間"
day: "日" day: "日"
_tutorial: _timelineTutorial:
title: "Misskeyの使い方" title: "Misskeyの使い方"
step1_1: "ようこそ。" step1_1: "この画面は「タイムライン」です。{name}に投稿された「ノート」が時系列で表示されます。"
step1_2: "この画面は「タイムライン」と呼ばれ、あなたや、あなたが「フォロー」する人の「ノート」が時系列で表示されます。" step1_2: "タイムラインにはいくつか種類があり、例えば「ホームタイムライン」にはあなたがフォローしている人のノートが流れ、「ローカルタイムライン」には{name}全体のノートが流れます。"
step1_3: "あなたはまだ何もノートを投稿しておらず、誰もフォローしていないので、タイムラインには何も表示されていないはずです。" step2_1: "試しに、何かノートを投稿してみましょう。画面上にある鉛筆マークのボタンを押すとフォームが開きます。"
step2_1: "ノートを作成したり誰かをフォローしたりする前に、まずあなたのプロフィールを完成させましょう。" step2_2: "初めてのノートの内容は、あなたの自己紹介や「{name}始めました」などがおすすめです。"
step2_2: "あなたがどんな人かわかると、多くの人にノートを見てもらえたり、フォローしてもらいやすくなります。" step3_1: "投稿できましたか?"
step3_1: "プロフィール設定はうまくできましたか?" step3_2: "あなたのノートがタイムラインに表示されていれば成功です。"
step3_2: "では試しに、何かノートを投稿してみてください。画面上にある鉛筆マークのボタンを押すとフォームが開きます。" step4_1: "ノートには、「リアクション」を付けることができます。"
step3_3: "内容を書いたら、フォーム右上のボタンを押すと投稿できます。" step4_2: "リアクションを付けるには、ノートの「+」マークをクリックして、好きな絵文字を選択します。"
step3_4: "内容が思いつかない「Misskey始めました」というのはいかがでしょう。"
step4_1: "投稿できましたか?"
step4_2: "あなたのノートがタイムラインに表示されていれば成功です。"
step5_1: "次は、他の人をフォローしてタイムラインを賑やかにしたいところです。"
step5_2: "{featured}で人気のノートが見れるので、その中から気になった人を選んでフォローしたり、{explore}で人気のユーザーを探すこともできます。"
step5_3: "ユーザーをフォローするには、ユーザーのアイコンをクリックしてユーザーページを表示し、「フォロー」ボタンを押します。"
step5_4: "ユーザーによっては、フォローが承認されるまで時間がかかる場合があります。"
step6_1: "タイムラインに他のユーザーのノートが表示されていれば成功です。"
step6_2: "他の人のノートには、「リアクション」を付けることができ、簡単にあなたの反応を伝えられます。"
step6_3: "リアクションを付けるには、ノートの「+」マークをクリックして、好きなリアクションを選択します。"
step7_1: "これで、Misskeyの基本的な使い方の説明は終わりました。お疲れ様でした。"
step7_2: "もっとMisskeyについて知りたいときは、{help}を見てみてください。"
step7_3: "では、Misskeyをお楽しみください🚀"
step8_1: "最後に、プッシュ通知を有効化してみませんか?"
step8_2: "プッシュ通知を受け取ることで、Misskeyを開いていない時にもリアクションやフォロー、メンションなどに気づけます。"
step8_3: "通知の設定は後から変更できます。"
_2fa: _2fa:
alreadyRegistered: "既に設定は完了しています。" alreadyRegistered: "既に設定は完了しています。"
@ -1822,7 +1821,7 @@ _profile:
metadataDescription: "プロフィールに表として追加情報を表示することができます。" metadataDescription: "プロフィールに表として追加情報を表示することができます。"
metadataLabel: "ラベル" metadataLabel: "ラベル"
metadataContent: "内容" metadataContent: "内容"
changeAvatar: "アバター画像を変更" changeAvatar: "アイコン画像を変更"
changeBanner: "バナー画像を変更" changeBanner: "バナー画像を変更"
_exportOrImport: _exportOrImport:

View file

@ -44,7 +44,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private queryService: QueryService, private queryService: QueryService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder(), ps.sinceId, ps.untilId) const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId)
.andWhere('channel.isArchived = FALSE') .andWhere('channel.isArchived = FALSE')
.andWhere({ userId: me.id }); .andWhere({ userId: me.id });

View file

@ -399,6 +399,8 @@ Promise.all([
glob('src/components/Mk{A,B}*.vue'), glob('src/components/Mk{A,B}*.vue'),
glob('src/components/MkGalleryPostPreview.vue'), glob('src/components/MkGalleryPostPreview.vue'),
glob('src/components/MkSignupServerRules.vue'), glob('src/components/MkSignupServerRules.vue'),
glob('src/components/MkUserSetupDialog.vue'),
glob('src/components/MkUserSetupDialog.*.vue'),
glob('src/pages/user/home.vue'), glob('src/pages/user/home.vue'),
]) ])
.then((globs) => globs.flat()) .then((globs) => globs.flat())

View file

@ -3,6 +3,7 @@ import { FORCE_REMOUNT } from '@storybook/core-events';
import { type Preview, setup } from '@storybook/vue3'; import { type Preview, setup } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic'; import isChromatic from 'chromatic/isChromatic';
import { initialize, mswDecorator } from 'msw-storybook-addon'; import { initialize, mswDecorator } from 'msw-storybook-addon';
import { userDetailed } from './fakes';
import locale from './locale'; import locale from './locale';
import { commonHandlers, onUnhandledRequest } from './mocks'; import { commonHandlers, onUnhandledRequest } from './mocks';
import themes from './themes'; import themes from './themes';
@ -10,6 +11,7 @@ import '../src/style.scss';
const appInitialized = Symbol(); const appInitialized = Symbol();
let lastStory = null;
let moduleInitialized = false; let moduleInitialized = false;
let unobserve = () => {}; let unobserve = () => {};
let misskeyOS = null; let misskeyOS = null;
@ -42,10 +44,16 @@ function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme
unobserve = () => observer.disconnect(); unobserve = () => observer.disconnect();
} }
function initLocalStorage() {
localStorage.clear();
localStorage.setItem('account', JSON.stringify(userDetailed()));
localStorage.setItem('locale', JSON.stringify(locale));
}
initialize({ initialize({
onUnhandledRequest, onUnhandledRequest,
}); });
localStorage.setItem("locale", JSON.stringify(locale)); initLocalStorage();
queueMicrotask(() => { queueMicrotask(() => {
Promise.all([ Promise.all([
import('../src/components'), import('../src/components'),
@ -76,6 +84,27 @@ queueMicrotask(() => {
const preview = { const preview = {
decorators: [ decorators: [
(Story, context) => { (Story, context) => {
if (lastStory === context.id) {
lastStory = null;
} else {
lastStory = context.id;
const channel = addons.getChannel();
const resetIndexedDBPromise = globalThis.indexedDB?.databases
? indexedDB.databases().then((r) => {
for (var i = 0; i < r.length; i++) {
indexedDB.deleteDatabase(r[i].name!);
}
}).catch(() => {})
: Promise.resolve();
const resetDefaultStorePromise = import('../src/store').then(({ defaultStore }) => {
// @ts-expect-error
defaultStore.init();
}).catch(() => {});
Promise.all([resetIndexedDBPromise, resetDefaultStorePromise]).then(() => {
initLocalStorage();
channel.emit(FORCE_REMOUNT, { storyId: context.id });
});
}
const story = Story(); const story = Story();
if (!moduleInitialized) { if (!moduleInitialized) {
const channel = addons.getChannel(); const channel = addons.getChannel();

View file

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<div :class="$style.label" @click="focus"><slot name="label"></slot></div> <div :class="$style.label" @click="focus"><slot name="label"></slot></div>
<div :class="[$style.input, { inline, disabled, focused }]"> <div :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]">
<div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div> <div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div>
<input <input
ref="inputEl" ref="inputEl"

View file

@ -89,7 +89,6 @@ defineExpose({
display: flex; display: flex;
flex-direction: column; flex-direction: column;
contain: content; contain: content;
container-type: inline-size;
border-radius: var(--radius); border-radius: var(--radius);
--root-margin: 24px; --root-margin: 24px;
@ -142,6 +141,7 @@ defineExpose({
flex: 1; flex: 1;
overflow: auto; overflow: auto;
background: var(--panel); background: var(--panel);
container-type: size;
} }
} }
</style> </style>

View file

@ -87,7 +87,7 @@ export default defineComponent({
}, },
async openDrive() { async openDrive() {
os.selectDriveFile(); os.selectDriveFile(false);
}, },
async selectUser() { async selectUser() {

View file

@ -3,7 +3,7 @@ import { expect } from '@storybook/jest';
import { userEvent, waitFor, within } from '@storybook/testing-library'; import { userEvent, waitFor, within } from '@storybook/testing-library';
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import { onBeforeUnmount } from 'vue'; import { onBeforeUnmount } from 'vue';
import MkSignupServerRules from './MkSignupDialog,rules.vue'; import MkSignupServerRules from './MkSignupDialog.rules.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { instance } from '@/instance'; import { instance } from '@/instance';
export const Empty = { export const Empty = {

View file

@ -1,30 +1,30 @@
<template> <template>
<div class="_panel vjnjpkug"> <div class="_panel" :class="$style.root">
<div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
<MkAvatar class="avatar" :user="user" indicator/> <MkAvatar :class="$style.avatar" :user="user" indicator/>
<div class="title"> <div :class="$style.title">
<MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> <MkA :class="$style.name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
<p class="username"><MkAcct :user="user"/></p> <p :class="$style.username"><MkAcct :user="user"/></p>
</div> </div>
<span v-if="$i && $i.id !== user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> <span v-if="$i && $i.id !== user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span>
<div class="description"> <div :class="$style.description">
<div v-if="user.description" class="mfm"> <div v-if="user.description" class="mfm">
<Mfm :text="user.description" :author="user" :i="$i"/> <Mfm :text="user.description" :author="user" :i="$i"/>
</div> </div>
<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span> <span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
</div> </div>
<div class="status"> <div :class="$style.status">
<div> <div :class="$style.statusItem">
<p>{{ i18n.ts.notes }}</p><span>{{ user.notesCount }}</span> <p :class="$style.statusItemLabel">{{ i18n.ts.notes }}</p><span :class="$style.statusItemValue">{{ user.notesCount }}</span>
</div> </div>
<div> <div :class="$style.statusItem">
<p>{{ i18n.ts.following }}</p><span>{{ user.followingCount }}</span> <p :class="$style.statusItemLabel">{{ i18n.ts.following }}</p><span :class="$style.statusItemValue">{{ user.followingCount }}</span>
</div> </div>
<div> <div :class="$style.statusItem">
<p>{{ i18n.ts.followers }}</p><span>{{ user.followersCount }}</span> <p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ user.followersCount }}</span>
</div> </div>
</div> </div>
<MkFollowButton v-if="$i && user.id != $i.id" class="koudoku-button" :user="user" mini/> <MkFollowButton v-if="$i && user.id != $i.id" :class="$style.follow" :user="user" mini/>
</div> </div>
</template> </template>
@ -40,99 +40,99 @@ defineProps<{
}>(); }>();
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.vjnjpkug { .root {
position: relative; position: relative;
}
> .banner { .banner {
height: 84px; height: 84px;
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
background-size: cover; background-size: cover;
background-position: center; background-position: center;
} }
> .avatar { .avatar {
display: block; display: block;
position: absolute; position: absolute;
top: 62px; top: 62px;
left: 13px; left: 13px;
z-index: 2; z-index: 2;
width: 58px; width: 58px;
height: 58px; height: 58px;
border: solid 4px var(--panel); border: solid 4px var(--panel);
} }
> .title { .title {
display: block; display: block;
padding: 10px 0 10px 88px; padding: 10px 0 10px 88px;
}
> .name { .name {
display: inline-block; display: inline-block;
margin: 0; margin: 0;
font-weight: bold; font-weight: bold;
line-height: 16px; line-height: 16px;
word-break: break-all; word-break: break-all;
} }
> .username { .username {
display: block; display: block;
margin: 0; margin: 0;
line-height: 16px; line-height: 16px;
font-size: 0.8em; font-size: 0.8em;
color: var(--fg); color: var(--fg);
opacity: 0.7; opacity: 0.7;
} }
}
> .followed { .followed {
position: absolute; position: absolute;
top: 12px; top: 12px;
left: 12px; left: 12px;
padding: 4px 8px; padding: 4px 8px;
color: #fff; color: #fff;
background: rgba(0, 0, 0, 0.7); background: rgba(0, 0, 0, 0.7);
font-size: 0.7em; font-size: 0.7em;
border-radius: 6px; border-radius: 6px;
} }
> .description { .description {
padding: 16px; padding: 16px;
font-size: 0.8em; font-size: 0.8em;
border-top: solid 0.5px var(--divider); border-top: solid 0.5px var(--divider);
}
> .mfm { .mfm {
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
}
> .status { .status {
padding: 10px 16px; padding: 10px 16px;
border-top: solid 0.5px var(--divider); border-top: solid 0.5px var(--divider);
}
> div { .statusItem {
display: inline-block; display: inline-block;
width: 33%; width: 33%;
}
> p { .statusItemLabel {
margin: 0; margin: 0;
font-size: 0.7em; font-size: 0.7em;
color: var(--fg); color: var(--fg);
} }
> span { .statusItemValue {
font-size: 1em; font-size: 1em;
color: var(--accent); color: var(--accent);
} }
}
}
> .koudoku-button { .follow {
position: absolute; position: absolute;
top: 8px; top: 8px;
right: 8px; right: 8px;
}
} }
</style> </style>

View file

@ -8,7 +8,7 @@
</template> </template>
<template #default="{ items }"> <template #default="{ items }">
<div class="efvhhmdq"> <div :class="$style.root">
<MkUserInfo v-for="item in items" :key="item.id" class="user" :user="extractor(item)"/> <MkUserInfo v-for="item in items" :key="item.id" class="user" :user="extractor(item)"/>
</div> </div>
</template> </template>
@ -29,8 +29,8 @@ const props = withDefaults(defineProps<{
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.efvhhmdq { .root {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-gap: var(--margin); grid-gap: var(--margin);

View file

@ -0,0 +1,51 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw';
import { commonHandlers } from '../../.storybook/mocks';
import { userDetailed } from '../../.storybook/fakes';
import MkUserSetupDialog_Follow from './MkUserSetupDialog.Follow.vue';
export const Default = {
render(args) {
return {
components: {
MkUserSetupDialog_Follow,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkUserSetupDialog_Follow v-bind="props" />',
};
},
args: {
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
rest.post('/api/users', (req, res, ctx) => {
return res(ctx.json([
userDetailed('44'),
userDetailed('49'),
]));
}),
rest.post('/api/pinned-users', (req, res, ctx) => {
return res(ctx.json([
userDetailed('44'),
userDetailed('49'),
]));
}),
],
},
},
} satisfies StoryObj<typeof MkUserSetupDialog_Follow>;

View file

@ -0,0 +1,63 @@
<template>
<div class="_gaps">
<div style="text-align: center;">{{ i18n.ts._initialAccountSetting.followUsers }}</div>
<MkFolder :default-open="true">
<template #label>{{ i18n.ts.recommended }}</template>
<MkPagination :pagination="pinnedUsers">
<template #default="{ items }">
<div :class="$style.users">
<XUser v-for="item in items" :key="item.id" :user="item"/>
</div>
</template>
</MkPagination>
</MkFolder>
<MkFolder :default-open="true">
<template #label>{{ i18n.ts.popularUsers }}</template>
<MkPagination :pagination="popularUsers">
<template #default="{ items }">
<div :class="$style.users">
<XUser v-for="item in items" :key="item.id" :user="item"/>
</div>
</template>
</MkPagination>
</MkFolder>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import XUser from '@/components/MkUserSetupDialog.User.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os';
import { $i } from '@/account';
import MkPagination from '@/components/MkPagination.vue';
const emit = defineEmits<{
(ev: 'done'): void;
}>();
const pinnedUsers = { endpoint: 'pinned-users', noPaging: true };
const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
state: 'alive',
origin: 'local',
sort: '+follower',
} };
</script>
<style lang="scss" module>
.users {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
grid-gap: var(--margin);
justify-content: center;
}
</style>

View file

@ -0,0 +1,31 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
import MkUserSetupDialog_Profile from './MkUserSetupDialog.Profile.vue';
export const Default = {
render(args) {
return {
components: {
MkUserSetupDialog_Profile,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkUserSetupDialog_Profile v-bind="props" />',
};
},
args: {
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkUserSetupDialog_Profile>;

View file

@ -0,0 +1,101 @@
<template>
<div class="_gaps">
<MkInfo>{{ i18n.ts._initialAccountSetting.theseSettingsCanEditLater }}</MkInfo>
<FormSlot>
<template #label>{{ i18n.ts.avatar }}</template>
<div v-adaptive-bg :class="$style.avatarSection" class="_panel">
<MkAvatar :class="$style.avatar" :user="$i" @click="setAvatar"/>
<div style="margin-top: 16px;">
<MkButton primary rounded inline @click="setAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton>
</div>
</div>
</FormSlot>
<MkInput v-model="name" :max="30" manual-save>
<template #label>{{ i18n.ts._profile.name }}</template>
</MkInput>
<MkTextarea v-model="description" :max="500" tall manual-save>
<template #label>{{ i18n.ts._profile.description }}</template>
</MkTextarea>
<MkInfo>{{ i18n.ts._initialAccountSetting.youCanEditMoreSettingsInSettingsPageLater }}</MkInfo>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import FormSlot from '@/components/form/slot.vue';
import MkInfo from '@/components/MkInfo.vue';
import { chooseFileFromPc } from '@/scripts/select-file';
import * as os from '@/os';
import { $i } from '@/account';
const emit = defineEmits<{
(ev: 'done'): void;
}>();
const name = ref('');
const description = ref('');
watch(name, () => {
os.apiWithDialog('i/update', {
// null??使
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
name: name.value || null,
});
});
watch(description, () => {
os.apiWithDialog('i/update', {
// null??使
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
description: description.value || null,
});
});
function setAvatar(ev) {
chooseFileFromPc(false).then(async (files) => {
const file = files[0];
let originalOrCropped = file;
const { canceled } = await os.confirm({
type: 'question',
text: i18n.t('cropImageAsk'),
okText: i18n.ts.cropYes,
cancelText: i18n.ts.cropNo,
});
if (!canceled) {
originalOrCropped = await os.cropImage(file, {
aspectRatio: 1,
});
}
const i = await os.apiWithDialog('i/update', {
avatarId: originalOrCropped.id,
});
$i.avatarId = i.avatarId;
$i.avatarUrl = i.avatarUrl;
});
}
</script>
<style lang="scss" module>
.avatarSection {
text-align: center;
padding: 20px;
}
.avatar {
width: 100px;
height: 100px;
}
</style>

View file

@ -0,0 +1,32 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
import { userDetailed } from '../../.storybook/fakes';
import MkUserSetupDialog_User from './MkUserSetupDialog.User.vue';
export const Default = {
render(args) {
return {
components: {
MkUserSetupDialog_User,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkUserSetupDialog_User v-bind="props" />',
};
},
args: {
user: userDetailed(),
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkUserSetupDialog_User>;

View file

@ -0,0 +1,101 @@
<template>
<div v-adaptive-bg class="_panel" style="position: relative;">
<div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
<MkAvatar :class="$style.avatar" :user="user" indicator/>
<div :class="$style.title">
<div :class="$style.name"><MkUserName :user="user" :nowrap="false"/></div>
<p :class="$style.username"><MkAcct :user="user"/></p>
</div>
<div :class="$style.description">
<div v-if="user.description" :class="$style.mfm">
<Mfm :text="user.description" :author="user" :i="$i"/>
</div>
<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
</div>
<div :class="$style.footer">
<MkButton v-if="!isFollowing" primary gradate rounded full @click="follow"><i class="ti ti-plus"></i> {{ i18n.ts.follow }}</MkButton>
<div v-else style="opacity: 0.7; text-align: center;">{{ i18n.ts.youFollowing }} <i class="ti ti-check"></i></div>
</div>
</div>
</template>
<script lang="ts" setup>
import * as misskey from 'misskey-js';
import { ref } from 'vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n';
import { $i } from '@/account';
import * as os from '@/os';
const props = defineProps<{
user: misskey.entities.UserDetailed;
}>();
const isFollowing = ref(false);
async function follow() {
isFollowing.value = true;
os.api('following/create', {
userId: props.user.id,
});
}
</script>
<style lang="scss" module>
.banner {
height: 60px;
background-color: rgba(0, 0, 0, 0.1);
background-size: cover;
background-position: center;
}
.avatar {
display: block;
position: absolute;
top: 30px;
left: 13px;
z-index: 2;
width: 58px;
height: 58px;
border: solid 4px var(--panel);
}
.title {
display: block;
padding: 10px 0 10px 88px;
}
.name {
display: inline-block;
margin: 0;
font-weight: bold;
line-height: 16px;
word-break: break-all;
}
.username {
display: block;
margin: 0;
line-height: 16px;
font-size: 0.8em;
color: var(--fg);
opacity: 0.7;
}
.description {
padding: 0 16px 16px 88px;
font-size: 0.9em;
}
.mfm {
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
overflow: hidden;
}
.footer {
border-top: solid 0.5px var(--divider);
padding: 16px;
}
</style>

View file

@ -0,0 +1,51 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw';
import { commonHandlers } from '../../.storybook/mocks';
import { userDetailed } from '../../.storybook/fakes';
import MkUserSetupDialog from './MkUserSetupDialog.vue';
export const Default = {
render(args) {
return {
components: {
MkUserSetupDialog,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkUserSetupDialog v-bind="props" />',
};
},
args: {
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
rest.post('/api/users', (req, res, ctx) => {
return res(ctx.json([
userDetailed('44'),
userDetailed('49'),
]));
}),
rest.post('/api/pinned-users', (req, res, ctx) => {
return res(ctx.json([
userDetailed('44'),
userDetailed('49'),
]));
}),
],
},
},
} satisfies StoryObj<typeof MkUserSetupDialog>;

View file

@ -0,0 +1,144 @@
<template>
<MkModalWindow
ref="dialog"
:width="500"
:height="550"
@close="close(true)"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.initialAccountSetting }}</template>
<div style="overflow-x: clip;">
<Transition
mode="out-in"
:enter-active-class="$style.transition_x_enterActive"
:leave-active-class="$style.transition_x_leaveActive"
:enter-from-class="$style.transition_x_enterFrom"
:leave-to-class="$style.transition_x_leaveTo"
>
<template v-if="page === 0">
<div :class="$style.centerPage">
<MkSpacer :margin-min="20" :margin-max="28">
<div class="_gaps" style="text-align: center;">
<i class="ti ti-confetti" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
<div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.accountCreated }}</div>
<div>{{ i18n.ts._initialAccountSetting.letsFillYourProfile }}</div>
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" @click="page++">{{ i18n.ts._initialAccountSetting.profileSetting }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</MkSpacer>
</div>
</template>
<template v-else-if="page === 1">
<div style="height: 100cqh; overflow: auto;">
<MkSpacer :margin-min="20" :margin-max="28">
<XProfile/>
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</MkSpacer>
</div>
</template>
<template v-else-if="page === 2">
<div style="height: 100cqh; overflow: auto;">
<MkSpacer :margin-min="20" :margin-max="28">
<XFollow/>
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</MkSpacer>
</div>
</template>
<template v-else-if="page === 3">
<div :class="$style.centerPage">
<MkSpacer :margin-min="20" :margin-max="28">
<div class="_gaps" style="text-align: center;">
<i class="ti ti-bell-ringing-2" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
<div style="font-size: 120%;">{{ i18n.ts.pushNotification }}</div>
<div style="padding: 0 16px;">{{ i18n.t('_initialAccountSetting.pushNotificationDescription', { name: instance.name ?? host }) }}</div>
<MkPushNotificationAllowButton primary show-only-to-register style="margin: 0 auto;"/>
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</MkSpacer>
</div>
</template>
<template v-else-if="page === 4">
<div :class="$style.centerPage">
<MkSpacer :margin-min="20" :margin-max="28">
<div class="_gaps" style="text-align: center;">
<i class="ti ti-check" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
<div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.initialAccountSettingCompleted }}</div>
<I18n :src="i18n.ts._initialAccountSetting.ifYouNeedLearnMore" tag="div" style="padding: 0 16px;">
<template #name>{{ instance.name ?? host }}</template>
<template #link>
<a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
</template>
</I18n>
<div>{{ i18n.t('_initialAccountSetting.haveFun', { name: instance.name ?? host }) }}</div>
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" @click="close(false)">{{ i18n.ts.close }}</MkButton>
</div>
</MkSpacer>
</div>
</template>
</Transition>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { ref, shallowRef, watch } from 'vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue';
import XProfile from '@/components/MkUserSetupDialog.Profile.vue';
import XFollow from '@/components/MkUserSetupDialog.Follow.vue';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
import { host } from '@/config';
import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
import { defaultStore } from '@/store';
import * as os from '@/os';
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const page = ref(defaultStore.state.accountSetupWizard);
watch(page, () => {
defaultStore.set('accountSetupWizard', page.value);
});
async function close(skip: boolean) {
if (skip) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts._initialAccountSetting.skipAreYouSure,
});
if (canceled) return;
}
dialog.value.close();
defaultStore.set('accountSetupWizard', -1);
}
</script>
<style lang="scss" module>
.transition_x_enterActive,
.transition_x_leaveActive {
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateX(50px);
}
.transition_x_leaveTo {
opacity: 0;
transform: translateX(-50px);
}
.centerPage {
display: flex;
justify-content: center;
align-items: center;
height: 100cqh;
padding-bottom: 30px;
box-sizing: border-box;
}
</style>

View file

@ -222,7 +222,7 @@ watch(() => props.user.avatarBlurhash, () => {
transform: rotate(37.5deg) skew(30deg); transform: rotate(37.5deg) skew(30deg);
&, &::after { &, &::after {
border-radius: 0 75% 75%; border-radius: 25% 75% 75%;
} }
> .layer { > .layer {
@ -251,7 +251,7 @@ watch(() => props.user.avatarBlurhash, () => {
transform: rotate(-37.5deg) skew(-30deg); transform: rotate(-37.5deg) skew(-30deg);
&, &::after { &, &::after {
border-radius: 75% 0 75% 75%; border-radius: 75% 25% 75% 75%;
} }
> .layer { > .layer {

View file

@ -343,6 +343,16 @@ if ($i) {
// only add post shortcuts if logged in // only add post shortcuts if logged in
hotkeys['p|n'] = post; hotkeys['p|n'] = post;
if (defaultStore.state.accountSetupWizard !== -1) {
// このウィザードが実装される前に登録したユーザーには表示させないため
// TODO: そのうち消す
if (Date.now() - new Date($i.createdAt).getTime() < 1000 * 60 * 60 * 24) {
popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {}, 'closed');
} else {
defaultStore.set('accountSetupWizard', -1);
}
}
if ($i.isDeleted) { if ($i.isDeleted) {
alert({ alert({
type: 'warning', type: 'warning',

View file

@ -19,6 +19,7 @@ import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu'; import { MenuItem } from '@/types/menu';
import copyToClipboard from './scripts/copy-to-clipboard'; import copyToClipboard from './scripts/copy-to-clipboard';
import { showMovedDialog } from './scripts/show-moved-dialog'; import { showMovedDialog } from './scripts/show-moved-dialog';
import { DriveFile } from 'misskey-js/built/entities';
export const openingWindowsCount = ref(0); export const openingWindowsCount = ref(0);
@ -420,7 +421,7 @@ export async function selectUser(opts: { includeSelf?: boolean } = {}) {
}); });
} }
export async function selectDriveFile(multiple: boolean) { export async function selectDriveFile(multiple: boolean): Promise<DriveFile[]> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'file', type: 'file',
@ -428,7 +429,7 @@ export async function selectDriveFile(multiple: boolean) {
}, { }, {
done: files => { done: files => {
if (files) { if (files) {
resolve(multiple ? files : files[0]); resolve(files);
} }
}, },
}, 'closed'); }, 'closed');

View file

@ -33,8 +33,8 @@ const emit = defineEmits<{
let file: any = $ref(null); let file: any = $ref(null);
async function choose() { async function choose() {
os.selectDriveFile(false).then((fileResponse: any) => { os.selectDriveFile(false).then((fileResponse) => {
file = fileResponse; file = fileResponse[0];
emit('update:modelValue', { emit('update:modelValue', {
...props.modelValue, ...props.modelValue,
fileId: fileResponse.id, fileId: fileResponse.id,

View file

@ -1,7 +1,7 @@
<template> <template>
<div :class="$style.container"> <div :class="$style.container">
<div :class="$style.title"> <div :class="$style.title">
<div :class="$style.titleText"><i class="ti ti-info-circle"></i> {{ i18n.ts._tutorial.title }}</div> <div :class="$style.titleText"><i class="ti ti-info-circle"></i> {{ i18n.ts._timelineTutorial.title }}</div>
<div :class="$style.step"> <div :class="$style.step">
<button class="_button" :class="$style.stepArrow" :disabled="tutorial === 0" @click="tutorial--"> <button class="_button" :class="$style.stepArrow" :disabled="tutorial === 0" @click="tutorial--">
<i class="ti ti-chevron-left"></i> <i class="ti ti-chevron-left"></i>
@ -12,66 +12,30 @@
</button> </button>
</div> </div>
</div> </div>
<div v-if="tutorial === 0" :class="$style.body"> <div v-if="tutorial === 0" :class="$style.body">
<div>{{ i18n.ts._tutorial.step1_1 }}</div> <div>{{ i18n.t('_timelineTutorial.step1_1', { name: instance.name ?? host }) }}</div>
<div>{{ i18n.ts._tutorial.step1_2 }}</div> <div>{{ i18n.t('_timelineTutorial.step1_2', { name: instance.name ?? host }) }}</div>
<div>{{ i18n.ts._tutorial.step1_3 }}</div>
</div> </div>
<div v-else-if="tutorial === 1" :class="$style.body"> <div v-else-if="tutorial === 1" :class="$style.body">
<div>{{ i18n.ts._tutorial.step2_1 }}</div> <div>{{ i18n.ts._timelineTutorial.step2_1 }}</div>
<div>{{ i18n.ts._tutorial.step2_2 }}</div> <div>{{ i18n.t('_timelineTutorial.step2_2', { name: instance.name ?? host }) }}</div>
<MkA class="_link" to="/settings/profile">{{ i18n.ts.editProfile }}</MkA>
</div> </div>
<div v-else-if="tutorial === 2" :class="$style.body"> <div v-else-if="tutorial === 2" :class="$style.body">
<div>{{ i18n.ts._tutorial.step3_1 }}</div> <div>{{ i18n.ts._timelineTutorial.step3_1 }}</div>
<div>{{ i18n.ts._tutorial.step3_2 }}</div> <div>{{ i18n.ts._timelineTutorial.step3_2 }}</div>
<div>{{ i18n.ts._tutorial.step3_3 }}</div>
<small :class="$style.small">{{ i18n.ts._tutorial.step3_4 }}</small>
</div> </div>
<div v-else-if="tutorial === 3" :class="$style.body"> <div v-else-if="tutorial === 3" :class="$style.body">
<div>{{ i18n.ts._tutorial.step4_1 }}</div> <div>{{ i18n.ts._timelineTutorial.step4_1 }}</div>
<div>{{ i18n.ts._tutorial.step4_2 }}</div> <div>{{ i18n.ts._timelineTutorial.step4_2 }}</div>
</div>
<div v-else-if="tutorial === 4" :class="$style.body">
<div>{{ i18n.ts._tutorial.step5_1 }}</div>
<I18n :src="i18n.ts._tutorial.step5_2" tag="div">
<template #featured>
<MkA class="_link" to="/explore">{{ i18n.ts.featured }}</MkA>
</template>
<template #explore>
<MkA class="_link" to="/explore#users">{{ i18n.ts.explore }}</MkA>
</template>
</I18n>
<div>{{ i18n.ts._tutorial.step5_3 }}</div>
<small :class="$style.small">{{ i18n.ts._tutorial.step5_4 }}</small>
</div>
<div v-else-if="tutorial === 5" :class="$style.body">
<div>{{ i18n.ts._tutorial.step6_1 }}</div>
<div>{{ i18n.ts._tutorial.step6_2 }}</div>
<div>{{ i18n.ts._tutorial.step6_3 }}</div>
</div>
<div v-else-if="tutorial === 6" :class="$style.body">
<div>{{ i18n.ts._tutorial.step7_1 }}</div>
<I18n :src="i18n.ts._tutorial.step7_2" tag="div">
<template #help>
<a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
</template>
</I18n>
<div>{{ i18n.ts._tutorial.step7_3 }}</div>
</div>
<div v-else-if="tutorial === 7" :class="$style.body">
<div>{{ i18n.ts._tutorial.step8_1 }}</div>
<div>{{ i18n.ts._tutorial.step8_2 }}</div>
<small :class="$style.small">{{ i18n.ts._tutorial.step8_3 }}</small>
</div> </div>
<div :class="$style.footer"> <div :class="$style.footer">
<template v-if="tutorial === tutorialsNumber - 1"> <template v-if="tutorial === tutorialsNumber - 1">
<MkPushNotificationAllowButton :class="$style.footerItem" primary show-only-to-register @click="tutorial = -1"/> <MkButton :class="$style.footerItem" primary rounded gradate @click="tutorial = -1">{{ i18n.ts.done }} <i class="ti ti-check"></i></MkButton>
<MkButton :class="$style.footerItem" :primary="false" @click="tutorial = -1">{{ i18n.ts.noThankYou }}</MkButton>
</template> </template>
<template v-else> <template v-else>
<MkButton :class="$style.footerItem" primary @click="tutorial++"><i class="ti ti-check"></i> {{ i18n.ts.next }}</MkButton> <MkButton :class="$style.footerItem" primary rounded gradate @click="tutorial++">{{ i18n.ts.next }} <i class="ti ti-arrow-right"></i></MkButton>
</template> </template>
</div> </div>
</div> </div>
@ -80,15 +44,16 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { instance } from '@/instance';
import { host } from '@/config';
const tutorialsNumber = 8; const tutorialsNumber = 4;
const tutorial = computed({ const tutorial = computed({
get() { return defaultStore.reactiveState.tutorial.value || 0; }, get() { return defaultStore.reactiveState.timelineTutorial.value || 0; },
set(value) { defaultStore.set('tutorial', value); }, set(value) { defaultStore.set('timelineTutorial', value); },
}); });
</script> </script>

View file

@ -3,7 +3,7 @@
<template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :display-my-avatar="true"/></template> <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :display-my-avatar="true"/></template>
<MkSpacer :content-max="800"> <MkSpacer :content-max="800">
<div ref="rootEl" v-hotkey.global="keymap"> <div ref="rootEl" v-hotkey.global="keymap">
<XTutorial v-if="$i && defaultStore.reactiveState.tutorial.value != -1" class="_panel" style="margin-bottom: var(--margin);"/> <XTutorial v-if="$i && defaultStore.reactiveState.timelineTutorial.value != -1" class="_panel" style="margin-bottom: var(--margin);"/>
<MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/> <MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/>
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>

View file

@ -6,71 +6,77 @@ import { i18n } from '@/i18n';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { uploadFile } from '@/scripts/upload'; import { uploadFile } from '@/scripts/upload';
function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> { export function chooseFileFromPc(multiple: boolean, keepOriginal = false): Promise<DriveFile[]> {
return new Promise((res, rej) => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = multiple;
input.onchange = () => {
const promises = Array.from(input.files).map(file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal));
Promise.all(promises).then(driveFiles => {
res(driveFiles);
}).catch(err => {
// アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
});
// 一応廃棄
(window as any).__misskey_input_ref__ = null;
};
// https://qiita.com/fukasawah/items/b9dc732d95d99551013d
// iOS Safari で正常に動かす為のおまじない
(window as any).__misskey_input_ref__ = input;
input.click();
});
}
export function chooseFileFromDrive(multiple: boolean): Promise<DriveFile[]> {
return new Promise((res, rej) => {
os.selectDriveFile(multiple).then(files => {
res(files);
});
});
}
export function chooseFileFromUrl(): Promise<DriveFile> {
return new Promise((res, rej) => {
os.inputText({
title: i18n.ts.uploadFromUrl,
type: 'url',
placeholder: i18n.ts.uploadFromUrlDescription,
}).then(({ canceled, result: url }) => {
if (canceled) return;
const marker = Math.random().toString(); // TODO: UUIDとか使う
const connection = stream.useChannel('main');
connection.on('urlUploadFinished', urlResponse => {
if (urlResponse.marker === marker) {
res(urlResponse.file);
connection.dispose();
}
});
os.api('drive/files/upload-from-url', {
url: url,
folderId: defaultStore.state.uploadFolder,
marker,
});
os.alert({
title: i18n.ts.uploadFromUrlRequested,
text: i18n.ts.uploadFromUrlMayTakeTime,
});
});
});
}
function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile[]> {
return new Promise((res, rej) => { return new Promise((res, rej) => {
const keepOriginal = ref(defaultStore.state.keepOriginalUploading); const keepOriginal = ref(defaultStore.state.keepOriginalUploading);
const chooseFileFromPc = () => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = multiple;
input.onchange = () => {
const promises = Array.from(input.files).map(file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal.value));
Promise.all(promises).then(driveFiles => {
res(multiple ? driveFiles : driveFiles[0]);
}).catch(err => {
// アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
});
// 一応廃棄
(window as any).__misskey_input_ref__ = null;
};
// https://qiita.com/fukasawah/items/b9dc732d95d99551013d
// iOS Safari で正常に動かす為のおまじない
(window as any).__misskey_input_ref__ = input;
input.click();
};
const chooseFileFromDrive = () => {
os.selectDriveFile(multiple).then(files => {
res(files);
});
};
const chooseFileFromUrl = () => {
os.inputText({
title: i18n.ts.uploadFromUrl,
type: 'url',
placeholder: i18n.ts.uploadFromUrlDescription,
}).then(({ canceled, result: url }) => {
if (canceled) return;
const marker = Math.random().toString(); // TODO: UUIDとか使う
const connection = stream.useChannel('main');
connection.on('urlUploadFinished', urlResponse => {
if (urlResponse.marker === marker) {
res(multiple ? [urlResponse.file] : urlResponse.file);
connection.dispose();
}
});
os.api('drive/files/upload-from-url', {
url: url,
folderId: defaultStore.state.uploadFolder,
marker,
});
os.alert({
title: i18n.ts.uploadFromUrlRequested,
text: i18n.ts.uploadFromUrlMayTakeTime,
});
});
};
os.popupMenu([label ? { os.popupMenu([label ? {
text: label, text: label,
type: 'label', type: 'label',
@ -81,23 +87,23 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv
}, { }, {
text: i18n.ts.upload, text: i18n.ts.upload,
icon: 'ti ti-upload', icon: 'ti ti-upload',
action: chooseFileFromPc, action: () => chooseFileFromPc(multiple, keepOriginal.value).then(files => res(files)),
}, { }, {
text: i18n.ts.fromDrive, text: i18n.ts.fromDrive,
icon: 'ti ti-cloud', icon: 'ti ti-cloud',
action: chooseFileFromDrive, action: () => chooseFileFromDrive(multiple).then(files => res(files)),
}, { }, {
text: i18n.ts.fromUrl, text: i18n.ts.fromUrl,
icon: 'ti ti-link', icon: 'ti ti-link',
action: chooseFileFromUrl, action: () => chooseFileFromUrl().then(file => res([file])),
}], src); }], src);
}); });
} }
export function selectFile(src: any, label: string | null = null): Promise<DriveFile> { export function selectFile(src: any, label: string | null = null): Promise<DriveFile> {
return select(src, label, false) as Promise<DriveFile>; return select(src, label, false).then(files => files[0]);
} }
export function selectFiles(src: any, label: string | null = null): Promise<DriveFile[]> { export function selectFiles(src: any, label: string | null = null): Promise<DriveFile[]> {
return select(src, label, true) as Promise<DriveFile[]>; return select(src, label, true);
} }

View file

@ -38,7 +38,11 @@ export const pageViewInterruptors: PageViewInterruptor[] = [];
// TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう) // TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう)
// あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない // あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない
export const defaultStore = markRaw(new Storage('base', { export const defaultStore = markRaw(new Storage('base', {
tutorial: { accountSetupWizard: {
where: 'account',
default: 0,
},
timelineTutorial: {
where: 'account', where: 'account',
default: 0, default: 0,
}, },