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

View file

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

View file

@ -44,7 +44,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private queryService: QueryService,
) {
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({ userId: me.id });

View file

@ -399,6 +399,8 @@ Promise.all([
glob('src/components/Mk{A,B}*.vue'),
glob('src/components/MkGalleryPostPreview.vue'),
glob('src/components/MkSignupServerRules.vue'),
glob('src/components/MkUserSetupDialog.vue'),
glob('src/components/MkUserSetupDialog.*.vue'),
glob('src/pages/user/home.vue'),
])
.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 isChromatic from 'chromatic/isChromatic';
import { initialize, mswDecorator } from 'msw-storybook-addon';
import { userDetailed } from './fakes';
import locale from './locale';
import { commonHandlers, onUnhandledRequest } from './mocks';
import themes from './themes';
@ -10,6 +11,7 @@ import '../src/style.scss';
const appInitialized = Symbol();
let lastStory = null;
let moduleInitialized = false;
let unobserve = () => {};
let misskeyOS = null;
@ -42,10 +44,16 @@ function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme
unobserve = () => observer.disconnect();
}
function initLocalStorage() {
localStorage.clear();
localStorage.setItem('account', JSON.stringify(userDetailed()));
localStorage.setItem('locale', JSON.stringify(locale));
}
initialize({
onUnhandledRequest,
});
localStorage.setItem("locale", JSON.stringify(locale));
initLocalStorage();
queueMicrotask(() => {
Promise.all([
import('../src/components'),
@ -76,6 +84,27 @@ queueMicrotask(() => {
const preview = {
decorators: [
(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();
if (!moduleInitialized) {
const channel = addons.getChannel();

View file

@ -1,7 +1,7 @@
<template>
<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>
<input
ref="inputEl"

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,7 @@
</template>
<template #default="{ items }">
<div class="efvhhmdq">
<div :class="$style.root">
<MkUserInfo v-for="item in items" :key="item.id" class="user" :user="extractor(item)"/>
</div>
</template>
@ -29,8 +29,8 @@ const props = withDefaults(defineProps<{
});
</script>
<style lang="scss" scoped>
.efvhhmdq {
<style lang="scss" module>
.root {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
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);
&, &::after {
border-radius: 0 75% 75%;
border-radius: 25% 75% 75%;
}
> .layer {
@ -251,7 +251,7 @@ watch(() => props.user.avatarBlurhash, () => {
transform: rotate(-37.5deg) skew(-30deg);
&, &::after {
border-radius: 75% 0 75% 75%;
border-radius: 75% 25% 75% 75%;
}
> .layer {

View file

@ -343,6 +343,16 @@ if ($i) {
// only add post shortcuts if logged in
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) {
alert({
type: 'warning',

View file

@ -19,6 +19,7 @@ import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu';
import copyToClipboard from './scripts/copy-to-clipboard';
import { showMovedDialog } from './scripts/show-moved-dialog';
import { DriveFile } from 'misskey-js/built/entities';
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) => {
popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'file',
@ -428,7 +429,7 @@ export async function selectDriveFile(multiple: boolean) {
}, {
done: files => {
if (files) {
resolve(multiple ? files : files[0]);
resolve(files);
}
},
}, 'closed');

View file

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

View file

@ -1,7 +1,7 @@
<template>
<div :class="$style.container">
<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">
<button class="_button" :class="$style.stepArrow" :disabled="tutorial === 0" @click="tutorial--">
<i class="ti ti-chevron-left"></i>
@ -12,66 +12,30 @@
</button>
</div>
</div>
<div v-if="tutorial === 0" :class="$style.body">
<div>{{ i18n.ts._tutorial.step1_1 }}</div>
<div>{{ i18n.ts._tutorial.step1_2 }}</div>
<div>{{ i18n.ts._tutorial.step1_3 }}</div>
<div>{{ i18n.t('_timelineTutorial.step1_1', { name: instance.name ?? host }) }}</div>
<div>{{ i18n.t('_timelineTutorial.step1_2', { name: instance.name ?? host }) }}</div>
</div>
<div v-else-if="tutorial === 1" :class="$style.body">
<div>{{ i18n.ts._tutorial.step2_1 }}</div>
<div>{{ i18n.ts._tutorial.step2_2 }}</div>
<MkA class="_link" to="/settings/profile">{{ i18n.ts.editProfile }}</MkA>
<div>{{ i18n.ts._timelineTutorial.step2_1 }}</div>
<div>{{ i18n.t('_timelineTutorial.step2_2', { name: instance.name ?? host }) }}</div>
</div>
<div v-else-if="tutorial === 2" :class="$style.body">
<div>{{ i18n.ts._tutorial.step3_1 }}</div>
<div>{{ i18n.ts._tutorial.step3_2 }}</div>
<div>{{ i18n.ts._tutorial.step3_3 }}</div>
<small :class="$style.small">{{ i18n.ts._tutorial.step3_4 }}</small>
<div>{{ i18n.ts._timelineTutorial.step3_1 }}</div>
<div>{{ i18n.ts._timelineTutorial.step3_2 }}</div>
</div>
<div v-else-if="tutorial === 3" :class="$style.body">
<div>{{ i18n.ts._tutorial.step4_1 }}</div>
<div>{{ i18n.ts._tutorial.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>{{ i18n.ts._timelineTutorial.step4_1 }}</div>
<div>{{ i18n.ts._timelineTutorial.step4_2 }}</div>
</div>
<div :class="$style.footer">
<template v-if="tutorial === tutorialsNumber - 1">
<MkPushNotificationAllowButton :class="$style.footerItem" primary show-only-to-register @click="tutorial = -1"/>
<MkButton :class="$style.footerItem" :primary="false" @click="tutorial = -1">{{ i18n.ts.noThankYou }}</MkButton>
<MkButton :class="$style.footerItem" primary rounded gradate @click="tutorial = -1">{{ i18n.ts.done }} <i class="ti ti-check"></i></MkButton>
</template>
<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>
</div>
</div>
@ -80,15 +44,16 @@
<script lang="ts" setup>
import { computed } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
import { host } from '@/config';
const tutorialsNumber = 8;
const tutorialsNumber = 4;
const tutorial = computed({
get() { return defaultStore.reactiveState.tutorial.value || 0; },
set(value) { defaultStore.set('tutorial', value); },
get() { return defaultStore.reactiveState.timelineTutorial.value || 0; },
set(value) { defaultStore.set('timelineTutorial', value); },
});
</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>
<MkSpacer :content-max="800">
<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);"/>
<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 { 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) => {
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 ? {
text: label,
type: 'label',
@ -81,23 +87,23 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv
}, {
text: i18n.ts.upload,
icon: 'ti ti-upload',
action: chooseFileFromPc,
action: () => chooseFileFromPc(multiple, keepOriginal.value).then(files => res(files)),
}, {
text: i18n.ts.fromDrive,
icon: 'ti ti-cloud',
action: chooseFileFromDrive,
action: () => chooseFileFromDrive(multiple).then(files => res(files)),
}, {
text: i18n.ts.fromUrl,
icon: 'ti ti-link',
action: chooseFileFromUrl,
action: () => chooseFileFromUrl().then(file => res([file])),
}], src);
});
}
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[]> {
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というキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう)
// あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない
export const defaultStore = markRaw(new Storage('base', {
tutorial: {
accountSetupWizard: {
where: 'account',
default: 0,
},
timelineTutorial: {
where: 'account',
default: 0,
},