enhance: 初期設定とチュートリアルを統合 (#12141)
* better onboarding experience
* enhance: iroiro
* (add) title
* (enhance) 戻る・次へボタンを全ページでstickyに
* fix merging
* (add) iroiro
* remove unnecessary file
* Update CHANGELOG.md
* tweak texts
* (fix) reactionViewer mock
* change strings
* Update MkTutorialDialog.Note.vue
* Update ja-JP.yml
* (fix) reactionViewer error
* (fix) path
* refactor
* fix
* Update MkPostForm.vue
* Update ja-JP.yml
* Update ja-JP.yml
* tweak text
* Update ja-JP.yml
* Update ja-JP.yml
* Update ja-JP.yml
* (add) achivement
* (add) もう一度見れますよメッセージを追加
* Revert "feat: レジストリAPIをサードパーティから利用可能に (#12229)"
This reverts commit 79346272f8
.
* Revert "(add) もう一度見れますよメッセージを追加"
This reverts commit 6123b35215133f0d5e5db356bb43f4acbafab8fa.
* Revert "Revert "feat: レジストリAPIをサードパーティから利用可能に (#12229)""
This reverts commit bae684e484ef99308d7ac816a822047117efe1c6.
* tweak
---------
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
parent
025ae436b5
commit
24e629ca5c
25 changed files with 1223 additions and 248 deletions
|
@ -34,6 +34,7 @@
|
|||
- 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください
|
||||
https://misskey-hub.net/docs/advanced/publish-on-your-website.html
|
||||
- Feat: 通知をグルーピングして表示するオプション(オプトアウト)
|
||||
- Feat: Misskeyの基本的なチュートリアルを実装
|
||||
- Feat: スワイプしてタイムラインを再読込できるように
|
||||
- PCの場合は右上のボタンからでも再読込できます
|
||||
- Enhance: タイムラインの自動更新を無効にできるように
|
||||
|
|
98
locales/index.d.ts
vendored
98
locales/index.d.ts
vendored
|
@ -1182,10 +1182,91 @@ export interface Locale {
|
|||
"pushNotificationDescription": string;
|
||||
"initialAccountSettingCompleted": string;
|
||||
"haveFun": string;
|
||||
"ifYouNeedLearnMore": string;
|
||||
"youCanContinueTutorial": string;
|
||||
"startTutorial": string;
|
||||
"skipAreYouSure": string;
|
||||
"laterAreYouSure": string;
|
||||
};
|
||||
"_initialTutorial": {
|
||||
"launchTutorial": string;
|
||||
"title": string;
|
||||
"wellDone": string;
|
||||
"skipAreYouSure": string;
|
||||
"_landing": {
|
||||
"title": string;
|
||||
"description": string;
|
||||
};
|
||||
"_note": {
|
||||
"title": string;
|
||||
"description": string;
|
||||
"reply": string;
|
||||
"renote": string;
|
||||
"reaction": string;
|
||||
"menu": string;
|
||||
};
|
||||
"_reaction": {
|
||||
"title": string;
|
||||
"description": string;
|
||||
"letsTryReacting": string;
|
||||
"reactToContinue": string;
|
||||
"reactNotification": string;
|
||||
"reactDone": string;
|
||||
};
|
||||
"_timeline": {
|
||||
"title": string;
|
||||
"description1": string;
|
||||
"home": string;
|
||||
"local": string;
|
||||
"social": string;
|
||||
"global": string;
|
||||
"description2": string;
|
||||
"description3": string;
|
||||
};
|
||||
"_postNote": {
|
||||
"title": string;
|
||||
"description1": string;
|
||||
"_visibility": {
|
||||
"description": string;
|
||||
"public": string;
|
||||
"home": string;
|
||||
"followers": string;
|
||||
"direct": string;
|
||||
"doNotSendConfidencialOnDirect1": string;
|
||||
"doNotSendConfidencialOnDirect2": string;
|
||||
"localOnly": string;
|
||||
};
|
||||
"_cw": {
|
||||
"title": string;
|
||||
"description": string;
|
||||
"_exampleNote": {
|
||||
"cw": string;
|
||||
"note": string;
|
||||
};
|
||||
"useCases": string;
|
||||
};
|
||||
};
|
||||
"_howToMakeAttachmentsSensitive": {
|
||||
"title": string;
|
||||
"description": string;
|
||||
"tryThisFile": string;
|
||||
"_exampleNote": {
|
||||
"note": string;
|
||||
};
|
||||
"method": string;
|
||||
"sensitiveSucceeded": string;
|
||||
"doItToContinue": string;
|
||||
};
|
||||
"_done": {
|
||||
"title": string;
|
||||
"description": string;
|
||||
};
|
||||
};
|
||||
"_timelineDescription": {
|
||||
"home": string;
|
||||
"local": string;
|
||||
"social": string;
|
||||
"global": string;
|
||||
};
|
||||
"_serverRules": {
|
||||
"description": string;
|
||||
};
|
||||
|
@ -1533,6 +1614,10 @@ export interface Locale {
|
|||
"title": string;
|
||||
"description": string;
|
||||
};
|
||||
"_tutorialCompleted": {
|
||||
"title": string;
|
||||
"description": string;
|
||||
};
|
||||
};
|
||||
};
|
||||
"_role": {
|
||||
|
@ -1861,17 +1946,6 @@ export interface Locale {
|
|||
"hour": string;
|
||||
"day": string;
|
||||
};
|
||||
"_timelineTutorial": {
|
||||
"title": string;
|
||||
"step1_1": string;
|
||||
"step1_2": string;
|
||||
"step2_1": string;
|
||||
"step2_2": string;
|
||||
"step3_1": string;
|
||||
"step3_2": string;
|
||||
"step4_1": string;
|
||||
"step4_2": string;
|
||||
};
|
||||
"_2fa": {
|
||||
"alreadyRegistered": string;
|
||||
"registerTOTP": string;
|
||||
|
|
|
@ -1170,7 +1170,7 @@ _announcement:
|
|||
|
||||
_initialAccountSetting:
|
||||
accountCreated: "アカウントの作成が完了しました!"
|
||||
letsStartAccountSetup: "アカウントの初期設定を行いましょう。"
|
||||
letsStartAccountSetup: "さっそくアカウントの初期設定を行いましょう。"
|
||||
letsFillYourProfile: "まずはあなたのプロフィールを設定しましょう。"
|
||||
profileSetting: "プロフィール設定"
|
||||
privacySetting: "プライバシー設定"
|
||||
|
@ -1180,10 +1180,80 @@ _initialAccountSetting:
|
|||
pushNotificationDescription: "プッシュ通知を有効にすると{name}の通知をお使いのデバイスで受け取ることができます。"
|
||||
initialAccountSettingCompleted: "初期設定が完了しました!"
|
||||
haveFun: "{name}をお楽しみください!"
|
||||
ifYouNeedLearnMore: "{name}(Misskey)の使い方などを詳しく知るには{link}をご覧ください。"
|
||||
youCanContinueTutorial: "このまま{name}(Misskey)の使い方についてのチュートリアルに進むこともできますが、ここで中断してすぐに使い始めることもできます。"
|
||||
startTutorial: "チュートリアルを開始"
|
||||
skipAreYouSure: "初期設定をスキップしますか?"
|
||||
laterAreYouSure: "初期設定をあとでやり直しますか?"
|
||||
|
||||
_initialTutorial:
|
||||
launchTutorial: "チュートリアルを見る"
|
||||
title: "チュートリアル"
|
||||
wellDone: "よくできました"
|
||||
skipAreYouSure: "チュートリアルを終了しますか?"
|
||||
_landing:
|
||||
title: "チュートリアルへようこそ"
|
||||
description: "ここでは、Misskeyの基本的な使い方や機能を確認できます。"
|
||||
_note:
|
||||
title: "ノートって何?"
|
||||
description: "Misskeyでの投稿は「ノート」と呼びます。ノートはタイムラインに時系列で並んでいて、リアルタイムで更新されていきます。"
|
||||
reply: "返信することができます。返信に対しての返信も可能で、スレッドのように会話を続けることもできます。"
|
||||
renote: "そのノートを自分のタイムラインに流して共有することができます。テキストを追加して引用することも可能です。"
|
||||
reaction: "リアクションをつけることができます。詳しくは次のページで解説します。"
|
||||
menu: "ノートの詳細を表示したり、リンクをコピーしたりなどの様々な操作が行えます。"
|
||||
_reaction:
|
||||
title: "リアクションって何?"
|
||||
description: "ノートには「リアクション」をつけることができます。「いいね」では伝わらないニュアンスも、リアクションで簡単・気軽に表現できます。"
|
||||
letsTryReacting: "リアクションは、ノートの「+」ボタンをクリックするとつけられます。試しにこのサンプルのノートにリアクションをつけてみてください!"
|
||||
reactToContinue: "リアクションをつけると先に進めるようになります。"
|
||||
reactNotification: "あなたのノートが誰かにリアクションされると、リアルタイムで通知を受け取ります。"
|
||||
reactDone: "「ー」ボタンを押すとリアクションを取り消すことができます。"
|
||||
_timeline:
|
||||
title: "タイムラインのしくみ"
|
||||
description1: "Misskeyには、使い方に応じて複数のタイムラインが用意されています(サーバーによってはいずれかが無効になっていることがあります)。"
|
||||
home: "あなたがフォローしているアカウントの投稿を見られます。"
|
||||
local: "このサーバーにいるユーザー全員の投稿を見られます。"
|
||||
social: "ホームタイムラインとローカルタイムラインの投稿が両方表示されます。"
|
||||
global: "接続している他のすべてのサーバーからの投稿を見られます。"
|
||||
description2: "それぞれのタイムラインは、画面上部でいつでも切り替えられます。"
|
||||
description3: "その他にも、リストタイムラインやチャンネルタイムラインなどがあります。詳しくは{link}をご覧ください。"
|
||||
_postNote:
|
||||
title: "ノートの投稿設定"
|
||||
description1: "Misskeyにノートを投稿する際には、様々なオプションの設定が可能です。投稿フォームはこのようになっています。"
|
||||
_visibility:
|
||||
description: "ノートを表示できる相手を制限できます。"
|
||||
public: "すべてのユーザーに公開。"
|
||||
home: "ホームタイムラインのみに公開。フォロワー・プロフィールを見に来た人・リノートから、他のユーザーも見ることができます。"
|
||||
followers: "フォロワーにのみ公開。本人以外がリノートすることはできず、またフォロワー以外は閲覧できません。"
|
||||
direct: "指定したユーザーにのみ公開され、また相手に通知が入ります。ダイレクトメッセージのかわりにお使いいただけます。"
|
||||
doNotSendConfidencialOnDirect1: "機密情報は送信する際は注意してください。"
|
||||
doNotSendConfidencialOnDirect2: "送信先のサーバーの管理者は投稿内容を見ることが可能なので、信頼できないサーバーのユーザーにダイレクト投稿を送信する場合は、機密情報の扱いに注意が必要です。"
|
||||
localOnly: "他のサーバーに投稿を連合しません。上記の公開範囲に関わらず、他のサーバーのユーザーは、この設定がついたノートを直接閲覧することができなくなります。"
|
||||
_cw:
|
||||
title: "内容を隠す(CW)"
|
||||
description: "本文のかわりに「注釈」に書いた内容が表示されます。「もっと見る」を押すと本文が表示されます。"
|
||||
_exampleNote:
|
||||
cw: "飯テロ注意"
|
||||
note: "チョコのかかったドーナツを食べました🍩😋"
|
||||
useCases: "サーバーのガイドラインにより必要とされるノートに指定したり、ネタバレ投稿やセンシティブな文章を自主規制したりするときに使います。"
|
||||
_howToMakeAttachmentsSensitive:
|
||||
title: "添付ファイルをセンシティブにするには?"
|
||||
description: "サーバーのガイドラインにより必要とされる際や、そのまま見れる状態にしておくべきではない添付ファイルには、「センシティブ」設定を付けます。"
|
||||
tryThisFile: "試しに、このフォームに添付された画像をセンシティブにしてみてください!"
|
||||
_exampleNote:
|
||||
note: "納豆のフタ開けるのミスったわね…"
|
||||
method: "添付ファイルをセンシティブにする際は、そのファイルをクリックしてメニューを開き、「センシティブとして設定」をクリックします。"
|
||||
sensitiveSucceeded: "ファイルを添付する際は、サーバーのガイドラインに従ってセンシティブを適切に設定してください。"
|
||||
doItToContinue: "画像をセンシティブに設定すると先に進めるようになります。"
|
||||
_done:
|
||||
title: "チュートリアルは終了です🎉"
|
||||
description: "ここで紹介した機能はほんの一部にすぎません。Misskeyの使い方をより詳しく知るには、{link}をご覧ください。"
|
||||
|
||||
_timelineDescription:
|
||||
home: "ホームタイムラインでは、あなたがフォローしているアカウントの投稿を見られます。"
|
||||
local: "ローカルタイムラインでは、このサーバーにいるユーザー全員の投稿を見られます。"
|
||||
social: "ソーシャルタイムラインには、ホームタイムラインとローカルタイムラインの投稿が両方表示されます。"
|
||||
global: "グローバルタイムラインでは、接続している他のすべてのサーバーからの投稿を見られます。"
|
||||
|
||||
_serverRules:
|
||||
description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。"
|
||||
|
||||
|
@ -1456,6 +1526,9 @@ _achievements:
|
|||
_smashTestNotificationButton:
|
||||
title: "テスト過剰"
|
||||
description: "通知のテストをごく短時間のうちに連続して行った"
|
||||
_tutorialCompleted:
|
||||
title: "Misskey初心者講座 修了証"
|
||||
description: "チュートリアルを完了した"
|
||||
|
||||
_role:
|
||||
new: "ロールの作成"
|
||||
|
@ -1778,17 +1851,6 @@ _time:
|
|||
hour: "時間"
|
||||
day: "日"
|
||||
|
||||
_timelineTutorial:
|
||||
title: "Misskeyの使い方"
|
||||
step1_1: "この画面は「タイムライン」です。{name}に投稿された「ノート」が時系列で表示されます。"
|
||||
step1_2: "タイムラインにはいくつか種類があり、例えば「ホームタイムライン」にはあなたがフォローしている人のノートが流れ、「ローカルタイムライン」には{name}全体のノートが流れます。"
|
||||
step2_1: "試しに、何かノートを投稿してみましょう。画面上にある鉛筆マークのボタンを押すとフォームが開きます。"
|
||||
step2_2: "初めてのノートの内容は、あなたの自己紹介や「{name}始めました」などがおすすめです。"
|
||||
step3_1: "投稿できましたか?"
|
||||
step3_2: "あなたのノートがタイムラインに表示されていれば成功です。"
|
||||
step4_1: "ノートには、「リアクション」を付けることができます。"
|
||||
step4_2: "リアクションを付けるには、ノートの「+」マークをクリックして、好きな絵文字を選択します。"
|
||||
|
||||
_2fa:
|
||||
alreadyRegistered: "既に設定は完了しています。"
|
||||
registerTOTP: "認証アプリの設定を開始"
|
||||
|
|
|
@ -86,6 +86,7 @@ export const ACHIEVEMENT_TYPES = [
|
|||
'cookieClicked',
|
||||
'brainDiver',
|
||||
'smashTestNotificationButton',
|
||||
'tutorialCompleted',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
|
|
BIN
packages/frontend/assets/tutorial/ai.webp
Normal file
BIN
packages/frontend/assets/tutorial/ai.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
BIN
packages/frontend/assets/tutorial/natto_failed.webp
Normal file
BIN
packages/frontend/assets/tutorial/natto_failed.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
BIN
packages/frontend/assets/tutorial/timeline_tab.png
Normal file
BIN
packages/frontend/assets/tutorial/timeline_tab.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.8 KiB |
|
@ -7,7 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="[$style.root, { [$style.warn]: warn }]">
|
||||
<i v-if="warn" class="ti ti-alert-triangle" :class="$style.i"></i>
|
||||
<i v-else class="ti ti-info-circle" :class="$style.i"></i>
|
||||
<slot></slot>
|
||||
<div><slot></slot></div>
|
||||
<button v-if="closable" :class="$style.button" class="_button" @click="close()"><i class="ti ti-x"></i></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -16,11 +17,23 @@ import { } from 'vue';
|
|||
|
||||
const props = defineProps<{
|
||||
warn?: boolean;
|
||||
closable?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'close'): void;
|
||||
}>();
|
||||
|
||||
function close() {
|
||||
// こいつの中では非表示動作は行わない
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
font-size: 90%;
|
||||
background: var(--infoBg);
|
||||
|
@ -37,4 +50,9 @@ const props = defineProps<{
|
|||
.i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-left: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<article v-else :class="$style.article" @contextmenu.stop="onContextmenu">
|
||||
<div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div>
|
||||
<MkAvatar :class="$style.avatar" :user="appearNote.user" link preview/>
|
||||
<MkAvatar :class="$style.avatar" :user="appearNote.user" :link="!mock" :preview="!mock"/>
|
||||
<div :class="$style.main">
|
||||
<MkNoteHeader :note="appearNote" :mini="true"/>
|
||||
<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
|
||||
|
@ -84,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
|
||||
</div>
|
||||
<MkReactionsViewer :note="appearNote" :maxNumber="16">
|
||||
<MkReactionsViewer :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction">
|
||||
<template #more>
|
||||
<div :class="$style.reactionOmitted">{{ i18n.ts.more }}</div>
|
||||
</template>
|
||||
|
@ -136,7 +136,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, onMounted, ref, shallowRef, Ref, defineAsyncComponent } from 'vue';
|
||||
import { computed, inject, onMounted, ref, shallowRef, Ref, defineAsyncComponent, watch, provide } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkNoteSub from '@/components/MkNoteSub.vue';
|
||||
|
@ -170,9 +170,19 @@ import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
|||
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
|
||||
import { shouldCollapsed } from '@/scripts/collapsed.js';
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
pinned?: boolean;
|
||||
mock?: boolean;
|
||||
}>(), {
|
||||
mock: false,
|
||||
});
|
||||
|
||||
provide('mock', props.mock);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'reaction', emoji: string): void;
|
||||
(ev: 'removeReaction', emoji: string): void;
|
||||
}>();
|
||||
|
||||
const inChannel = inject('inChannel', null);
|
||||
|
@ -232,30 +242,38 @@ const keymap = {
|
|||
's': () => showContent.value !== showContent.value,
|
||||
};
|
||||
|
||||
useNoteCapture({
|
||||
rootEl: el,
|
||||
note: $$(appearNote),
|
||||
pureNote: $$(note),
|
||||
isDeletedRef: isDeleted,
|
||||
});
|
||||
|
||||
useTooltip(renoteButton, async (showing) => {
|
||||
const renotes = await os.api('notes/renotes', {
|
||||
noteId: appearNote.id,
|
||||
limit: 11,
|
||||
if (props.mock) {
|
||||
watch(() => props.note, (to) => {
|
||||
note = deepClone(to);
|
||||
}, { deep: true });
|
||||
} else {
|
||||
useNoteCapture({
|
||||
rootEl: el,
|
||||
note: $$(appearNote),
|
||||
pureNote: $$(note),
|
||||
isDeletedRef: isDeleted,
|
||||
});
|
||||
}
|
||||
|
||||
const users = renotes.map(x => x.user);
|
||||
if (!props.mock) {
|
||||
useTooltip(renoteButton, async (showing) => {
|
||||
const renotes = await os.api('notes/renotes', {
|
||||
noteId: appearNote.id,
|
||||
limit: 11,
|
||||
});
|
||||
|
||||
if (users.length < 1) return;
|
||||
const users = renotes.map(x => x.user);
|
||||
|
||||
os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.renoteCount,
|
||||
targetElement: renoteButton.value,
|
||||
}, {}, 'closed');
|
||||
});
|
||||
if (users.length < 1) return;
|
||||
|
||||
os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.renoteCount,
|
||||
targetElement: renoteButton.value,
|
||||
}, {}, 'closed');
|
||||
});
|
||||
}
|
||||
|
||||
type Visibility = 'public' | 'home' | 'followers' | 'specified';
|
||||
|
||||
|
@ -287,21 +305,25 @@ function renote(viaKeyboard = false) {
|
|||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
}
|
||||
|
||||
os.api('notes/create', {
|
||||
renoteId: appearNote.id,
|
||||
channelId: appearNote.channelId,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
});
|
||||
if (!props.mock) {
|
||||
os.api('notes/create', {
|
||||
renoteId: appearNote.id,
|
||||
channelId: appearNote.channelId,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
});
|
||||
}
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts.inChannelQuote,
|
||||
icon: 'ti ti-quote',
|
||||
action: () => {
|
||||
os.post({
|
||||
renote: appearNote,
|
||||
channel: appearNote.channel,
|
||||
});
|
||||
if (!props.mock) {
|
||||
os.post({
|
||||
renote: appearNote,
|
||||
channel: appearNote.channel,
|
||||
});
|
||||
}
|
||||
},
|
||||
}, null]);
|
||||
}
|
||||
|
@ -327,15 +349,17 @@ function renote(viaKeyboard = false) {
|
|||
visibility = smallerVisibility(visibility, 'home');
|
||||
}
|
||||
|
||||
os.api('notes/create', {
|
||||
localOnly,
|
||||
visibility,
|
||||
renoteId: appearNote.id,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
});
|
||||
if (!props.mock) {
|
||||
os.api('notes/create', {
|
||||
localOnly,
|
||||
visibility,
|
||||
renoteId: appearNote.id,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
});
|
||||
}
|
||||
},
|
||||
}, {
|
||||
}, (props.mock) ? undefined : {
|
||||
text: i18n.ts.quote,
|
||||
icon: 'ti ti-quote',
|
||||
action: () => {
|
||||
|
@ -352,6 +376,9 @@ function renote(viaKeyboard = false) {
|
|||
|
||||
function reply(viaKeyboard = false): void {
|
||||
pleaseLogin();
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
os.post({
|
||||
reply: appearNote,
|
||||
channel: appearNote.channel,
|
||||
|
@ -365,6 +392,10 @@ function react(viaKeyboard = false): void {
|
|||
pleaseLogin();
|
||||
showMovedDialog();
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
os.api('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: '❤️',
|
||||
|
@ -379,6 +410,11 @@ function react(viaKeyboard = false): void {
|
|||
} else {
|
||||
blur();
|
||||
reactionPicker.show(reactButton.value, reaction => {
|
||||
if (props.mock) {
|
||||
emit('reaction', reaction);
|
||||
return;
|
||||
}
|
||||
|
||||
os.api('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
|
@ -395,12 +431,22 @@ function react(viaKeyboard = false): void {
|
|||
function undoReact(note): void {
|
||||
const oldReaction = note.myReaction;
|
||||
if (!oldReaction) return;
|
||||
|
||||
if (props.mock) {
|
||||
emit('removeReaction', oldReaction);
|
||||
return;
|
||||
}
|
||||
|
||||
os.api('notes/reactions/delete', {
|
||||
noteId: note.id,
|
||||
});
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent): void {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isLink = (el: HTMLElement) => {
|
||||
if (el.tagName === 'A') return true;
|
||||
// 再生速度の選択などのために、Audio要素のコンテキストメニューはブラウザデフォルトとする。
|
||||
|
@ -422,6 +468,10 @@ function onContextmenu(ev: MouseEvent): void {
|
|||
}
|
||||
|
||||
function menu(viaKeyboard = false): void {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value });
|
||||
os.popupMenu(menu, menuButton.value, {
|
||||
viaKeyboard,
|
||||
|
@ -429,10 +479,18 @@ function menu(viaKeyboard = false): void {
|
|||
}
|
||||
|
||||
async function clip() {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
function showRenoteMenu(viaKeyboard = false): void {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
function getUnrenote(): MenuItem {
|
||||
return {
|
||||
text: i18n.ts.unrenote,
|
||||
|
@ -490,6 +548,14 @@ function readPromo() {
|
|||
});
|
||||
isDeleted.value = true;
|
||||
}
|
||||
|
||||
function emitUpdReaction(emoji: string, delta: number) {
|
||||
if (delta < 0) {
|
||||
emit('removeReaction', emoji);
|
||||
} else if (delta > 0) {
|
||||
emit('reaction', emoji);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<header :class="$style.root">
|
||||
<MkA v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
|
||||
<div v-if="mock" :class="$style.name">
|
||||
<MkUserName :user="note.user"/>
|
||||
</div>
|
||||
<MkA v-else v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
|
||||
<MkUserName :user="note.user"/>
|
||||
</MkA>
|
||||
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
|
||||
|
@ -14,7 +17,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<img v-for="role in note.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/>
|
||||
</div>
|
||||
<div :class="$style.info">
|
||||
<MkA :to="notePage(note)">
|
||||
<div v-if="mock">
|
||||
<MkTime :time="note.createdAt" colored/>
|
||||
</div>
|
||||
<MkA v-else :to="notePage(note)">
|
||||
<MkTime :time="note.createdAt" colored/>
|
||||
</MkA>
|
||||
<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
|
||||
|
@ -29,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import { inject } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
|
@ -38,6 +44,8 @@ import { userPage } from '@/filters/user.js';
|
|||
defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
}>();
|
||||
|
||||
const mock = inject<boolean>('mock', false);
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
@ -98,7 +98,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { inject, watch, nextTick, onMounted, defineAsyncComponent } from 'vue';
|
||||
import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
|
@ -143,15 +143,22 @@ const props = withDefaults(defineProps<{
|
|||
fixed?: boolean;
|
||||
autofocus?: boolean;
|
||||
freezeAfterPosted?: boolean;
|
||||
mock?: boolean;
|
||||
}>(), {
|
||||
initialVisibleUsers: () => [],
|
||||
autofocus: true,
|
||||
mock: false,
|
||||
});
|
||||
|
||||
provide('mock', props.mock);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'posted'): void;
|
||||
(ev: 'cancel'): void;
|
||||
(ev: 'esc'): void;
|
||||
|
||||
// Mock用
|
||||
(ev: 'fileChangeSensitive', fileId: string, to: boolean): void;
|
||||
}>();
|
||||
|
||||
const textareaEl = $shallowRef<HTMLTextAreaElement | null>(null);
|
||||
|
@ -239,7 +246,7 @@ const maxTextLength = $computed((): number => {
|
|||
});
|
||||
|
||||
const canPost = $computed((): boolean => {
|
||||
return !posting && !posted &&
|
||||
return !props.mock && !posting && !posted &&
|
||||
(1 <= textLength || 1 <= files.length || !!poll || !!props.renote) &&
|
||||
(textLength <= maxTextLength) &&
|
||||
(!poll || poll.choices.length >= 2);
|
||||
|
@ -396,6 +403,8 @@ function focus() {
|
|||
}
|
||||
|
||||
function chooseFileFrom(ev) {
|
||||
if (props.mock) return;
|
||||
|
||||
selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => {
|
||||
for (const file of files_) {
|
||||
files.push(file);
|
||||
|
@ -408,6 +417,9 @@ function detachFile(id) {
|
|||
}
|
||||
|
||||
function updateFileSensitive(file, sensitive) {
|
||||
if (props.mock) {
|
||||
emit('fileChangeSensitive', file.id, sensitive);
|
||||
}
|
||||
files[files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
|
||||
}
|
||||
|
||||
|
@ -420,6 +432,8 @@ function replaceFile(file: Misskey.entities.DriveFile, newFile: Misskey.entities
|
|||
}
|
||||
|
||||
function upload(file: File, name?: string): void {
|
||||
if (props.mock) return;
|
||||
|
||||
uploadFile(file, defaultStore.state.uploadFolder, name).then(res => {
|
||||
files.push(res);
|
||||
});
|
||||
|
@ -545,6 +559,8 @@ function onCompositionEnd(ev: CompositionEvent) {
|
|||
}
|
||||
|
||||
async function onPaste(ev: ClipboardEvent) {
|
||||
if (props.mock) return;
|
||||
|
||||
for (const { item, i } of Array.from(ev.clipboardData.items, (item, i) => ({ item, i }))) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
|
@ -629,7 +645,7 @@ function onDrop(ev): void {
|
|||
}
|
||||
|
||||
function saveDraft() {
|
||||
if (props.instant) return;
|
||||
if (props.instant || props.mock) return;
|
||||
|
||||
const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}');
|
||||
|
||||
|
@ -674,6 +690,8 @@ async function post(ev?: MouseEvent) {
|
|||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
}
|
||||
|
||||
if (props.mock) return;
|
||||
|
||||
const annoying =
|
||||
text.includes('$[x2') ||
|
||||
text.includes('$[x3') ||
|
||||
|
@ -839,6 +857,8 @@ function showActions(ev) {
|
|||
let postAccount = $ref<Misskey.entities.UserDetailed | null>(null);
|
||||
|
||||
function openAccountMenu(ev: MouseEvent) {
|
||||
if (props.mock) return;
|
||||
|
||||
openAccountMenu_({
|
||||
withExtraOperation: false,
|
||||
includeCurrentAccount: true,
|
||||
|
@ -869,7 +889,7 @@ onMounted(() => {
|
|||
|
||||
nextTick(() => {
|
||||
// 書きかけの投稿を復元
|
||||
if (!props.instant && !props.mention && !props.specified) {
|
||||
if (!props.instant && !props.mention && !props.specified && !props.mock) {
|
||||
const draft = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}')[draftKey];
|
||||
if (draft) {
|
||||
text = draft.data.text;
|
||||
|
|
|
@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import { defineAsyncComponent, inject } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
|
||||
import * as os from '@/os.js';
|
||||
|
@ -33,6 +33,8 @@ const props = defineProps<{
|
|||
detachMediaFn?: (id: string) => void;
|
||||
}>();
|
||||
|
||||
const mock = inject<boolean>('mock', false);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update:modelValue', value: any[]): void;
|
||||
(ev: 'detach', id: string): void;
|
||||
|
@ -44,6 +46,8 @@ const emit = defineEmits<{
|
|||
let menuShowing = false;
|
||||
|
||||
function detachMedia(id: string) {
|
||||
if (mock) return;
|
||||
|
||||
if (props.detachMediaFn) {
|
||||
props.detachMediaFn(id);
|
||||
} else {
|
||||
|
@ -52,6 +56,11 @@ function detachMedia(id: string) {
|
|||
}
|
||||
|
||||
function toggleSensitive(file) {
|
||||
if (mock) {
|
||||
emit('changeSensitive', file, !file.isSensitive);
|
||||
return;
|
||||
}
|
||||
|
||||
os.api('drive/files/update', {
|
||||
fileId: file.id,
|
||||
isSensitive: !file.isSensitive,
|
||||
|
@ -61,6 +70,8 @@ function toggleSensitive(file) {
|
|||
}
|
||||
|
||||
async function rename(file) {
|
||||
if (mock) return;
|
||||
|
||||
const { canceled, result } = await os.inputText({
|
||||
title: i18n.ts.enterFileName,
|
||||
default: file.name,
|
||||
|
@ -77,6 +88,8 @@ async function rename(file) {
|
|||
}
|
||||
|
||||
async function describe(file) {
|
||||
if (mock) return;
|
||||
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
|
||||
default: file.comment !== null ? file.comment : '',
|
||||
file: file,
|
||||
|
@ -94,6 +107,8 @@ async function describe(file) {
|
|||
}
|
||||
|
||||
async function crop(file: Misskey.entities.DriveFile): Promise<void> {
|
||||
if (mock) return;
|
||||
|
||||
const newFile = await os.cropImage(file, { aspectRatio: NaN });
|
||||
emit('replaceFile', file, newFile);
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, shallowRef, watch } from 'vue';
|
||||
import { computed, inject, onMounted, shallowRef, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import XDetails from '@/components/MkReactionsViewer.details.vue';
|
||||
import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
||||
|
@ -36,6 +36,12 @@ const props = defineProps<{
|
|||
note: Misskey.entities.Note;
|
||||
}>();
|
||||
|
||||
const mock = inject<boolean>('mock', false);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'reactionToggled', emoji: string, newCount: number): void;
|
||||
}>();
|
||||
|
||||
const buttonEl = shallowRef<HTMLElement>();
|
||||
|
||||
const canToggle = computed(() => !props.reaction.match(/@\w/) && $i);
|
||||
|
@ -53,6 +59,11 @@ async function toggleReaction() {
|
|||
});
|
||||
if (confirm.canceled) return;
|
||||
|
||||
if (mock) {
|
||||
emit('reactionToggled', props.reaction, (props.count - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
os.api('notes/reactions/delete', {
|
||||
noteId: props.note.id,
|
||||
}).then(() => {
|
||||
|
@ -64,6 +75,11 @@ async function toggleReaction() {
|
|||
}
|
||||
});
|
||||
} else {
|
||||
if (mock) {
|
||||
emit('reactionToggled', props.reaction, (props.count + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
os.api('notes/reactions/create', {
|
||||
noteId: props.note.id,
|
||||
reaction: props.reaction,
|
||||
|
@ -92,24 +108,26 @@ onMounted(() => {
|
|||
if (!props.isInitial) anime();
|
||||
});
|
||||
|
||||
useTooltip(buttonEl, async (showing) => {
|
||||
const reactions = await os.apiGet('notes/reactions', {
|
||||
noteId: props.note.id,
|
||||
type: props.reaction,
|
||||
limit: 11,
|
||||
_cacheKey_: props.count,
|
||||
});
|
||||
if (!mock) {
|
||||
useTooltip(buttonEl, async (showing) => {
|
||||
const reactions = await os.apiGet('notes/reactions', {
|
||||
noteId: props.note.id,
|
||||
type: props.reaction,
|
||||
limit: 11,
|
||||
_cacheKey_: props.count,
|
||||
});
|
||||
|
||||
const users = reactions.map(x => x.user);
|
||||
const users = reactions.map(x => x.user);
|
||||
|
||||
os.popup(XDetails, {
|
||||
showing,
|
||||
reaction: props.reaction,
|
||||
users,
|
||||
count: props.count,
|
||||
targetElement: buttonEl.value,
|
||||
}, {}, 'closed');
|
||||
}, 100);
|
||||
os.popup(XDetails, {
|
||||
showing,
|
||||
reaction: props.reaction,
|
||||
users,
|
||||
count: props.count,
|
||||
targetElement: buttonEl.value,
|
||||
}, {}, 'closed');
|
||||
}, 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
@ -12,14 +12,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:moveClass="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" :isInitial="initialReactions.has(reaction)" :note="note"/>
|
||||
<XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/>
|
||||
<slot v-if="hasMoreReactions" name="more"/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { watch } from 'vue';
|
||||
import { inject, watch } from 'vue';
|
||||
import XReaction from '@/components/MkReactionsViewer.reaction.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
|
@ -30,6 +30,12 @@ const props = withDefaults(defineProps<{
|
|||
maxNumber: Infinity,
|
||||
});
|
||||
|
||||
const mock = inject<boolean>('mock', false);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'mockUpdateMyReaction', emoji: string, delta: number): void;
|
||||
}>();
|
||||
|
||||
const initialReactions = new Set(Object.keys(props.note.reactions));
|
||||
|
||||
let reactions = $ref<[string, number][]>([]);
|
||||
|
@ -39,6 +45,15 @@ if (props.note.myReaction && !Object.keys(reactions).includes(props.note.myReact
|
|||
reactions[props.note.myReaction] = props.note.reactions[props.note.myReaction];
|
||||
}
|
||||
|
||||
function onMockToggleReaction(emoji: string, count: number) {
|
||||
if (!mock) return;
|
||||
|
||||
const i = reactions.findIndex((item) => item[0] === emoji);
|
||||
if (i < 0) return;
|
||||
|
||||
emit('mockUpdateMyReaction', emoji, (count - reactions[i][1]));
|
||||
}
|
||||
|
||||
watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumber]) => {
|
||||
let newReactions: [string, number][] = [];
|
||||
hasMoreReactions = Object.keys(newSource).length > maxNumber;
|
||||
|
|
117
packages/frontend/src/components/MkTutorialDialog.Note.vue
Normal file
117
packages/frontend/src/components/MkTutorialDialog.Note.vue
Normal file
|
@ -0,0 +1,117 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="phase === 'aboutNote'" class="_gaps">
|
||||
<div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._note.description }}</div>
|
||||
<MkNote :class="$style.exampleNoteRoot" style="pointer-events: none;" :note="exampleNote" :mock="true"/>
|
||||
<div class="_gaps_s">
|
||||
<div><i class="ti ti-arrow-back-up"></i> <b>{{ i18n.ts.reply }}</b> … {{ i18n.ts._initialTutorial._note.reply }}</div>
|
||||
<div><i class="ti ti-repeat"></i> <b>{{ i18n.ts.renote }}</b> … {{ i18n.ts._initialTutorial._note.renote }}</div>
|
||||
<div><i class="ti ti-plus"></i> <b>{{ i18n.ts.reaction }}</b> … {{ i18n.ts._initialTutorial._note.reaction }}</div>
|
||||
<div><i class="ti ti-dots"></i> <b>{{ i18n.ts.menu }}</b> … {{ i18n.ts._initialTutorial._note.menu }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="phase === 'howToReact'" class="_gaps">
|
||||
<div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._reaction.description }}</div>
|
||||
<div>{{ i18n.ts._initialTutorial._reaction.letsTryReacting }}</div>
|
||||
<MkNote :class="$style.exampleNoteRoot" :note="exampleNote" :mock="true" @reaction="addReaction" @removeReaction="removeReaction" @updateReaction="updateReaction"/>
|
||||
<div v-if="onceReacted"><b style="color: var(--accent);"><i class="ti ti-check"></i> {{ i18n.ts._initialTutorial.wellDone }}</b> {{ i18n.ts._initialTutorial._reaction.reactNotification }}<br>{{ i18n.ts._initialTutorial._reaction.reactDone }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { ref, reactive } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import { $i } from '@/account.js';
|
||||
import MkNote from '@/components/MkNote.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
phase: 'aboutNote' | 'howToReact';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'reacted'): void;
|
||||
}>();
|
||||
|
||||
const exampleNote = reactive<Misskey.entities.Note>({
|
||||
id: '0000000000',
|
||||
createdAt: '2019-04-14T17:30:49.181Z',
|
||||
userId: '0000000001',
|
||||
user: {
|
||||
id: '0000000001',
|
||||
name: '藍',
|
||||
username: 'ai',
|
||||
host: null,
|
||||
avatarDecorations: [],
|
||||
avatarUrl: '/client-assets/tutorial/ai.webp',
|
||||
avatarBlurhash: 'eiKmhHIByXxZ~qWXs:-pR*NbR*s:xuRjoL-oR*WCt6WWf6WVf6oeWB',
|
||||
isBot: false,
|
||||
isCat: true,
|
||||
emojis: {},
|
||||
onlineStatus: null,
|
||||
badgeRoles: [],
|
||||
},
|
||||
text: 'just setting up my msky',
|
||||
cw: null,
|
||||
visibility: 'public',
|
||||
localOnly: false,
|
||||
reactionAcceptance: null,
|
||||
renoteCount: 0,
|
||||
repliesCount: 1,
|
||||
reactions: {},
|
||||
reactionEmojis: {},
|
||||
fileIds: [],
|
||||
files: [],
|
||||
replyId: null,
|
||||
renoteId: null,
|
||||
});
|
||||
const onceReacted = ref<boolean>(false);
|
||||
|
||||
function addReaction(emoji) {
|
||||
onceReacted.value = true;
|
||||
emit('reacted');
|
||||
exampleNote.reactions[emoji] = 1;
|
||||
exampleNote.myReaction = emoji;
|
||||
doNotification(emoji);
|
||||
}
|
||||
|
||||
function doNotification(emoji: string): void {
|
||||
if (!$i || !emoji) return;
|
||||
|
||||
const notification: Misskey.entities.Notification = {
|
||||
id: Math.random().toString(),
|
||||
createdAt: new Date().toUTCString(),
|
||||
isRead: false,
|
||||
type: 'reaction',
|
||||
reaction: emoji,
|
||||
user: $i,
|
||||
userId: $i.id,
|
||||
note: exampleNote,
|
||||
};
|
||||
|
||||
globalEvents.emit('clientNotification', notification);
|
||||
}
|
||||
|
||||
function removeReaction(emoji) {
|
||||
delete exampleNote.reactions[emoji];
|
||||
exampleNote.myReaction = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.exampleNoteRoot {
|
||||
border-radius: var(--radius);
|
||||
border: var(--panelBorder);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
</style>
|
135
packages/frontend/src/components/MkTutorialDialog.PostNote.vue
Normal file
135
packages/frontend/src/components/MkTutorialDialog.PostNote.vue
Normal file
|
@ -0,0 +1,135 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="_gaps">
|
||||
<div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._postNote.description1 }}</div>
|
||||
<MkPostForm :class="$style.exampleRoot" :mock="true"/>
|
||||
<MkFormSection>
|
||||
<template #label>{{ i18n.ts.visibility }}</template>
|
||||
<div class="_gaps">
|
||||
<div>{{ i18n.ts._initialTutorial._postNote._visibility.description }}</div>
|
||||
<div><i class="ti ti-world"></i> <b>{{ i18n.ts._visibility.public }}</b> … {{ i18n.ts._initialTutorial._postNote._visibility.public }}</div>
|
||||
<div><i class="ti ti-home"></i> <b>{{ i18n.ts._visibility.home }}</b> … {{ i18n.ts._initialTutorial._postNote._visibility.home }}</div>
|
||||
<div><i class="ti ti-lock"></i> <b>{{ i18n.ts._visibility.followers }}</b> … {{ i18n.ts._initialTutorial._postNote._visibility.followers }}</div>
|
||||
<div class="_gaps_s">
|
||||
<div><i class="ti ti-mail"></i> <b>{{ i18n.ts._visibility.specified }}</b> … {{ i18n.ts._initialTutorial._postNote._visibility.direct }}</div>
|
||||
<MkInfo :warn="true">
|
||||
<b>{{ i18n.ts._initialTutorial._postNote._visibility.doNotSendConfidencialOnDirect1 }}</b> {{ i18n.ts._initialTutorial._postNote._visibility.doNotSendConfidencialOnDirect2 }}
|
||||
</MkInfo>
|
||||
</div>
|
||||
<div><i class="ti ti-rocket-off"></i> <b>{{ i18n.ts._visibility.disableFederation }}</b> … {{ i18n.ts._initialTutorial._postNote._visibility.localOnly }}</div>
|
||||
</div>
|
||||
</MkFormSection>
|
||||
<MkFormSection>
|
||||
<template #label>{{ i18n.ts._initialTutorial._postNote._cw.title }}</template>
|
||||
<div class="_gaps">
|
||||
<div>{{ i18n.ts._initialTutorial._postNote._cw.description }}</div>
|
||||
<MkNote :class="$style.exampleRoot" :note="exampleCWNote" :mock="true"/>
|
||||
<div>{{ i18n.ts._initialTutorial._postNote._cw.useCases }}</div>
|
||||
</div>
|
||||
</MkFormSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { reactive } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkNote from '@/components/MkNote.vue';
|
||||
import MkPostForm from '@/components/MkPostForm.vue';
|
||||
import MkFormSection from '@/components/form/section.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
|
||||
const exampleCWNote = reactive<Misskey.entities.Note>({
|
||||
id: '0000000000',
|
||||
createdAt: '2019-04-14T17:30:49.181Z',
|
||||
userId: '0000000001',
|
||||
user: {
|
||||
id: '0000000001',
|
||||
name: '藍',
|
||||
username: 'ai',
|
||||
host: null,
|
||||
avatarDecorations: [],
|
||||
avatarUrl: '/client-assets/tutorial/ai.webp',
|
||||
avatarBlurhash: 'eiKmhHIByXxZ~qWXs:-pR*NbR*s:xuRjoL-oR*WCt6WWf6WVf6oeWB',
|
||||
isBot: false,
|
||||
isCat: true,
|
||||
emojis: {},
|
||||
onlineStatus: null,
|
||||
badgeRoles: [],
|
||||
},
|
||||
text: i18n.ts._initialTutorial._postNote._cw._exampleNote.note,
|
||||
cw: i18n.ts._initialTutorial._postNote._cw._exampleNote.cw,
|
||||
visibility: 'public',
|
||||
localOnly: false,
|
||||
reactionAcceptance: null,
|
||||
renoteCount: 0,
|
||||
repliesCount: 1,
|
||||
reactions: {},
|
||||
reactionEmojis: {},
|
||||
fileIds: [],
|
||||
files: [],
|
||||
replyId: null,
|
||||
renoteId: null,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.exampleRoot {
|
||||
max-width: none!important;
|
||||
border-radius: var(--radius);
|
||||
border: var(--panelBorder);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.post {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
color: var(--fgOnAccent);
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: calc(100% - 38px);
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.postIcon {
|
||||
position: relative;
|
||||
margin-left: 30px;
|
||||
margin-right: 8px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.postText {
|
||||
position: relative;
|
||||
line-height: 40px;
|
||||
}
|
||||
</style>
|
144
packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
Normal file
144
packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
Normal file
|
@ -0,0 +1,144 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="_gaps">
|
||||
<div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.description }}</div>
|
||||
<div>{{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.tryThisFile }}</div>
|
||||
<MkInfo>{{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.method }}</MkInfo>
|
||||
<MkPostForm
|
||||
:class="$style.exampleRoot"
|
||||
:mock="true"
|
||||
:initialNote="exampleNote"
|
||||
@fileChangeSensitive="doSucceeded"
|
||||
></MkPostForm>
|
||||
<div v-if="onceSucceeded"><b style="color: var(--accent);"><i class="ti ti-check"></i> {{ i18n.ts._initialTutorial.wellDone }}</b> {{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.sensitiveSucceeded }}</div>
|
||||
<MkFolder>
|
||||
<template #label>{{ i18n.ts.previewNoteText }}</template>
|
||||
<MkNote :mock="true" :note="exampleNote" :class="$style.exampleRoot"></MkNote>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { ref, reactive } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkPostForm from '@/components/MkPostForm.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkNote from '@/components/MkNote.vue';
|
||||
import { $i } from '@/account.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'succeeded'): void;
|
||||
}>();
|
||||
|
||||
const onceSucceeded = ref<boolean>(false);
|
||||
|
||||
function doSucceeded(fileId: string, to: boolean) {
|
||||
if (fileId === exampleNote.fileIds[0] && to) {
|
||||
onceSucceeded.value = true;
|
||||
emit('succeeded');
|
||||
}
|
||||
}
|
||||
|
||||
const exampleNote = reactive<Misskey.entities.Note>({
|
||||
id: '0000000000',
|
||||
createdAt: '2019-04-14T17:30:49.181Z',
|
||||
userId: '0000000001',
|
||||
user: $i!,
|
||||
text: i18n.ts._initialTutorial._howToMakeAttachmentsSensitive._exampleNote.note,
|
||||
cw: null,
|
||||
visibility: 'public',
|
||||
localOnly: false,
|
||||
reactionAcceptance: null,
|
||||
renoteCount: 0,
|
||||
repliesCount: 1,
|
||||
reactions: {},
|
||||
reactionEmojis: {},
|
||||
fileIds: ['0000000002'],
|
||||
files: [{
|
||||
id: '0000000002',
|
||||
createdAt: '2019-04-14T17:30:49.181Z',
|
||||
name: 'natto_failed.webp',
|
||||
type: 'image/webp',
|
||||
md5: 'c44286cf152d0740be0ce5ad45ea85c3',
|
||||
size: 827532,
|
||||
isSensitive: false,
|
||||
blurhash: 'LXNA3TD*XAIA%1%M%gt7.TofRioz',
|
||||
properties: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
},
|
||||
url: '/client-assets/tutorial/natto_failed.webp',
|
||||
thumbnailUrl: '/client-assets/tutorial/natto_failed.webp',
|
||||
comment: null,
|
||||
folderId: null,
|
||||
folder: null,
|
||||
userId: null,
|
||||
user: null,
|
||||
}],
|
||||
replyId: null,
|
||||
renoteId: null,
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.exampleRoot {
|
||||
border-radius: var(--radius);
|
||||
border: var(--panelBorder);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.post {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
color: var(--fgOnAccent);
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: calc(100% - 38px);
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.postIcon {
|
||||
position: relative;
|
||||
margin-left: 30px;
|
||||
margin-right: 8px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.postText {
|
||||
position: relative;
|
||||
line-height: 40px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,87 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="_gaps">
|
||||
<div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._timeline.description1 }}</div>
|
||||
<div class="_gaps_s">
|
||||
<div><i class="ti ti-home"></i> <b>{{ i18n.ts._timelines.home }}</b> … {{ i18n.ts._initialTutorial._timeline.home }}</div>
|
||||
<div><i class="ti ti-planet"></i> <b>{{ i18n.ts._timelines.local }}</b> … {{ i18n.ts._initialTutorial._timeline.local }}</div>
|
||||
<div><i class="ti ti-universe"></i> <b>{{ i18n.ts._timelines.social }}</b> … {{ i18n.ts._initialTutorial._timeline.social }}</div>
|
||||
<div><i class="ti ti-whirl"></i> <b>{{ i18n.ts._timelines.global }}</b> … {{ i18n.ts._initialTutorial._timeline.global }}</div>
|
||||
</div>
|
||||
<div class="_gaps_s">
|
||||
<div>{{ i18n.ts._initialTutorial._timeline.description2 }}</div>
|
||||
<img :class="$style.image" src="/client-assets/tutorial/timeline_tab.png"/>
|
||||
</div>
|
||||
<div :class="$style.divider"></div>
|
||||
<I18n :src="i18n.ts._initialTutorial._timeline.description3" tag="div" style="padding: 0 16px;">
|
||||
<template #link>
|
||||
<a href="https://misskey-hub.net/docs/features/timeline.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
|
||||
</template>
|
||||
</I18n>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { i18n } from '@/i18n.js';
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.exampleNoteRoot {
|
||||
border-radius: var(--radius);
|
||||
border: var(--panelBorder);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.post {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
color: var(--fgOnAccent);
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: calc(100% - 38px);
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.postIcon {
|
||||
position: relative;
|
||||
margin-left: 30px;
|
||||
margin-right: 8px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.postText {
|
||||
position: relative;
|
||||
line-height: 40px;
|
||||
}
|
||||
</style>
|
260
packages/frontend/src/components/MkTutorialDialog.vue
Normal file
260
packages/frontend/src/components/MkTutorialDialog.vue
Normal file
|
@ -0,0 +1,260 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="dialog"
|
||||
:width="600"
|
||||
:height="650"
|
||||
@close="close(true)"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template v-if="page === 1" #header><i class="ti ti-pencil"></i> {{ i18n.ts._initialTutorial._note.title }}</template>
|
||||
<template v-else-if="page === 2" #header><i class="ti ti-mood-smile"></i> {{ i18n.ts._initialTutorial._reaction.title }}</template>
|
||||
<template v-else-if="page === 3" #header><i class="ti ti-home"></i> {{ i18n.ts._initialTutorial._timeline.title }}</template>
|
||||
<template v-else-if="page === 4" #header><i class="ti ti-pencil-plus"></i> {{ i18n.ts._initialTutorial._postNote.title }}</template>
|
||||
<template v-else-if="page === 5" #header><i class="ti ti-eye-exclamation"></i> {{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.title }}</template>
|
||||
<template v-else #header>{{ i18n.ts._initialTutorial.title }}</template>
|
||||
|
||||
<div style="overflow-x: clip;">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
:enterActiveClass="$style.transition_x_enterActive"
|
||||
:leaveActiveClass="$style.transition_x_leaveActive"
|
||||
:enterFromClass="$style.transition_x_enterFrom"
|
||||
:leaveToClass="$style.transition_x_leaveTo"
|
||||
>
|
||||
<template v-if="page === 0">
|
||||
<div :class="$style.centerPage">
|
||||
<MkAnimBg style="position: absolute; top: 0;" :scale="1.5"/>
|
||||
<MkSpacer :marginMin="20" :marginMax="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._initialTutorial._landing.title }}</div>
|
||||
<div>{{ i18n.ts._initialTutorial._landing.description }}</div>
|
||||
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" @click="page++">{{ i18n.ts._initialTutorial.launchTutorial }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
<MkButton style="margin: 0 auto;" transparent rounded @click="close(true)">{{ i18n.ts.close }}</MkButton>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="page === 1">
|
||||
<div style="height: 100cqh; overflow: auto;">
|
||||
<div :class="$style.pageRoot">
|
||||
<MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
|
||||
<XNote phase="aboutNote"/>
|
||||
</MkSpacer>
|
||||
<div :class="$style.pageFooter">
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton v-if="initialPage !== 1" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="page === 2">
|
||||
<div style="height: 100cqh; overflow: auto;">
|
||||
<div :class="$style.pageRoot">
|
||||
<MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
|
||||
<div class="_gaps">
|
||||
<XNote phase="howToReact" @reacted="isReactionTutorialPushed = true"/>
|
||||
<div v-if="!isReactionTutorialPushed">{{ i18n.ts._initialTutorial._reaction.reactToContinue }}</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
<div :class="$style.pageFooter">
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton v-if="initialPage !== 2" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate :disabled="!isReactionTutorialPushed" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="page === 3">
|
||||
<div style="height: 100cqh; overflow: auto;">
|
||||
<div :class="$style.pageRoot">
|
||||
<MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
|
||||
<XTimeline/>
|
||||
</MkSpacer>
|
||||
<div :class="$style.pageFooter">
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton v-if="initialPage !== 3" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="page === 4">
|
||||
<div style="height: 100cqh; overflow: auto;">
|
||||
<div :class="$style.pageRoot">
|
||||
<MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
|
||||
<XPostNote/>
|
||||
</MkSpacer>
|
||||
<div :class="$style.pageFooter">
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton v-if="initialPage !== 3" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="page === 5">
|
||||
<div style="height: 100cqh; overflow: auto;">
|
||||
<div :class="$style.pageRoot">
|
||||
<MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
|
||||
<div class="_gaps">
|
||||
<XSensitive @succeeded="isSensitiveTutorialSucceeded = true"/>
|
||||
<div v-if="!isSensitiveTutorialSucceeded">{{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.doItToContinue }}</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
<div :class="$style.pageFooter">
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton v-if="initialPage !== 2" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate :disabled="!isSensitiveTutorialSucceeded" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="page === 6">
|
||||
<div :class="$style.centerPage">
|
||||
<MkAnimBg style="position: absolute; top: 0;" :scale="1.5"/>
|
||||
<MkSpacer :marginMin="20" :marginMax="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._initialTutorial._done.title }}</div>
|
||||
<I18n :src="i18n.ts._initialTutorial._done.description" tag="div" style="padding: 0 16px;">
|
||||
<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>
|
||||
<div class="_buttonsCenter" style="margin-top: 16px;">
|
||||
<MkButton v-if="initialPage !== 4" rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton rounded primary gradate @click="close(false)">{{ i18n.ts.close }}</MkButton>
|
||||
</div>
|
||||
</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 XNote from '@/components/MkTutorialDialog.Note.vue';
|
||||
import XTimeline from '@/components/MkTutorialDialog.Timeline.vue';
|
||||
import XPostNote from '@/components/MkTutorialDialog.PostNote.vue';
|
||||
import XSensitive from '@/components/MkTutorialDialog.Sensitive.vue';
|
||||
import MkAnimBg from '@/components/MkAnimBg.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { host } from '@/config.js';
|
||||
import { claimAchievement } from '@/scripts/achievements.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
const props = defineProps<{
|
||||
initialPage?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-destructure
|
||||
const page = ref(props.initialPage ?? 0);
|
||||
|
||||
watch(page, (to) => {
|
||||
// チュートリアルの枚数を増やしたら必ず変更すること!!
|
||||
if (to === 6) {
|
||||
claimAchievement('tutorialCompleted');
|
||||
}
|
||||
});
|
||||
|
||||
const isReactionTutorialPushed = ref<boolean>(false);
|
||||
const isSensitiveTutorialSucceeded = ref<boolean>(false);
|
||||
|
||||
async function close(skip: boolean) {
|
||||
if (skip) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts._initialTutorial.skipAreYouSure,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
dialog.value?.close();
|
||||
}
|
||||
</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);
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.progressBarValue {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
|
||||
transition: all 0.5s cubic-bezier(0,.5,.5,1);
|
||||
}
|
||||
|
||||
.centerPage {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100cqh;
|
||||
padding-bottom: 30px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pageRoot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.pageMain {
|
||||
flex-grow: 1;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pageFooter {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
backdrop-filter: blur(15px);
|
||||
}
|
||||
</style>
|
|
@ -46,24 +46,32 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
<template v-else-if="page === 1">
|
||||
<div style="height: 100cqh; overflow: auto;">
|
||||
<MkSpacer :marginMin="20" :marginMax="28">
|
||||
<XProfile/>
|
||||
<div class="_buttonsCenter" style="margin-top: 16px;">
|
||||
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
<div :class="$style.pageRoot">
|
||||
<MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
|
||||
<XProfile/>
|
||||
</MkSpacer>
|
||||
<div :class="$style.pageFooter">
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="page === 2">
|
||||
<div style="height: 100cqh; overflow: auto;">
|
||||
<MkSpacer :marginMin="20" :marginMax="28">
|
||||
<XPrivacy/>
|
||||
<div class="_buttonsCenter" style="margin-top: 16px;">
|
||||
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
<div :class="$style.pageRoot">
|
||||
<MkSpacer :marginMin="20" :marginMax="28" :class="$style.pageMain">
|
||||
<XPrivacy/>
|
||||
</MkSpacer>
|
||||
<div :class="$style.pageFooter">
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="page === 3">
|
||||
|
@ -102,16 +110,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<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>
|
||||
<div>{{ i18n.t('_initialAccountSetting.youCanContinueTutorial', { name: instance.name ?? host }) }}</div>
|
||||
<div class="_buttonsCenter" style="margin-top: 16px;">
|
||||
<MkButton rounded primary gradate data-cy-user-setup-continue @click="launchTutorial()">{{ i18n.ts._initialAccountSetting.startTutorial }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</div>
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||
<MkButton primary rounded gradate data-cy-user-setup-continue @click="close(false)">{{ i18n.ts.close }}</MkButton>
|
||||
<MkButton rounded primary data-cy-user-setup-continue @click="setupComplete()">{{ i18n.ts.close }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
|
@ -123,7 +128,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, watch } from 'vue';
|
||||
import { ref, shallowRef, watch, nextTick, defineAsyncComponent } from 'vue';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import XProfile from '@/components/MkUserSetupDialog.Profile.vue';
|
||||
|
@ -143,6 +148,7 @@ const emit = defineEmits<{
|
|||
|
||||
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-destructure
|
||||
const page = ref(defaultStore.state.accountSetupWizard);
|
||||
|
||||
watch(page, () => {
|
||||
|
@ -158,10 +164,24 @@ async function close(skip: boolean) {
|
|||
if (canceled) return;
|
||||
}
|
||||
|
||||
dialog.value.close();
|
||||
dialog.value?.close();
|
||||
defaultStore.set('accountSetupWizard', -1);
|
||||
}
|
||||
|
||||
function setupComplete() {
|
||||
defaultStore.set('accountSetupWizard', -1);
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function launchTutorial() {
|
||||
setupComplete();
|
||||
nextTick(() => {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {
|
||||
initialPage: 1,
|
||||
}, {}, 'closed');
|
||||
});
|
||||
}
|
||||
|
||||
async function later(later: boolean) {
|
||||
if (later) {
|
||||
const { canceled } = await os.confirm({
|
||||
|
@ -171,7 +191,7 @@ async function later(later: boolean) {
|
|||
if (canceled) return;
|
||||
}
|
||||
|
||||
dialog.value.close();
|
||||
dialog.value?.close();
|
||||
defaultStore.set('accountSetupWizard', 0);
|
||||
}
|
||||
</script>
|
||||
|
@ -214,10 +234,21 @@ async function later(later: boolean) {
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pageRoot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.pageMain {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.pageFooter {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
|
|
|
@ -1,123 +0,0 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.title">
|
||||
<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>
|
||||
</button>
|
||||
<span :class="$style.stepNumber">{{ tutorial + 1 }} / {{ tutorialsNumber }}</span>
|
||||
<button class="_button" :class="$style.stepArrow" :disabled="tutorial === tutorialsNumber - 1" @click="tutorial++">
|
||||
<i class="ti ti-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tutorial === 0" :class="$style.body">
|
||||
<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._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._timelineTutorial.step3_1 }}</div>
|
||||
<div>{{ i18n.ts._timelineTutorial.step3_2 }}</div>
|
||||
</div>
|
||||
<div v-else-if="tutorial === 3" :class="$style.body">
|
||||
<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">
|
||||
<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 rounded gradate @click="tutorial++">{{ i18n.ts.next }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { host } from '@/config.js';
|
||||
|
||||
const tutorialsNumber = 4;
|
||||
|
||||
const tutorial = computed({
|
||||
get() { return defaultStore.reactiveState.timelineTutorial.value || 0; },
|
||||
set(value) { defaultStore.set('timelineTutorial', value); },
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.container {
|
||||
border: solid 2px var(--accent);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 22px 32px;
|
||||
font-weight: bold;
|
||||
|
||||
&Text {
|
||||
margin: 4px 0;
|
||||
padding-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.step {
|
||||
margin-left: auto;
|
||||
|
||||
&Arrow {
|
||||
padding: 4px;
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
&:first-child {
|
||||
padding-right: 8px;
|
||||
}
|
||||
&:last-child {
|
||||
padding-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&Number {
|
||||
font-weight: normal;
|
||||
margin: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
justify-content: right;
|
||||
padding: 22px 32px;
|
||||
|
||||
&Item {
|
||||
margin: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -8,7 +8,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :displayMyAvatar="true"/></template>
|
||||
<MkSpacer :contentMax="800">
|
||||
<div ref="rootEl" v-hotkey.global="keymap">
|
||||
<XTutorial v-if="$i && defaultStore.reactiveState.timelineTutorial.value != -1" class="_panel" style="margin-bottom: var(--margin);"/>
|
||||
<MkInfo v-if="['home', 'local', 'social', 'global'].includes(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--margin);" closable @close="closeTutorial()">
|
||||
{{ i18n.ts._timelineDescription[src] }}
|
||||
</MkInfo>
|
||||
<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" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
|
||||
|
@ -31,9 +33,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, computed, watch, provide } from 'vue';
|
||||
import { computed, watch, provide } from 'vue';
|
||||
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
|
||||
import MkTimeline from '@/components/MkTimeline.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkPostForm from '@/components/MkPostForm.vue';
|
||||
import { scroll } from '@/scripts/scroll.js';
|
||||
import * as os from '@/os.js';
|
||||
|
@ -48,8 +51,6 @@ import { deviceKind } from '@/scripts/device-kind.js';
|
|||
|
||||
provide('shouldOmitHeaderTitle', true);
|
||||
|
||||
const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue'));
|
||||
|
||||
const isLocalTimelineAvailable = ($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable);
|
||||
const isGlobalTimelineAvailable = ($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable);
|
||||
const keymap = {
|
||||
|
@ -140,6 +141,13 @@ function focus(): void {
|
|||
tlComponent.focus();
|
||||
}
|
||||
|
||||
function closeTutorial(): void {
|
||||
if (!['home', 'local', 'social', 'global'].includes(src)) return;
|
||||
const before = defaultStore.state.timelineTutorials;
|
||||
before[src] = true;
|
||||
defaultStore.set('timelineTutorials', before);
|
||||
}
|
||||
|
||||
const headerActions = $computed(() => {
|
||||
const tmp = [
|
||||
{
|
||||
|
|
|
@ -82,6 +82,7 @@ export const ACHIEVEMENT_TYPES = [
|
|||
'cookieClicked',
|
||||
'brainDiver',
|
||||
'smashTestNotificationButton',
|
||||
'tutorialCompleted',
|
||||
] as const;
|
||||
|
||||
export const ACHIEVEMENT_BADGES = {
|
||||
|
@ -460,6 +461,11 @@ export const ACHIEVEMENT_BADGES = {
|
|||
bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'tutorialCompleted': {
|
||||
img: '/fluent-emoji/1f393.png',
|
||||
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||
} as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], {
|
||||
img: string;
|
||||
|
|
|
@ -49,9 +49,14 @@ export const defaultStore = markRaw(new Storage('base', {
|
|||
where: 'account',
|
||||
default: 0,
|
||||
},
|
||||
timelineTutorial: {
|
||||
timelineTutorials: {
|
||||
where: 'account',
|
||||
default: 0,
|
||||
default: {
|
||||
home: false,
|
||||
local: false,
|
||||
social: false,
|
||||
global: false,
|
||||
},
|
||||
},
|
||||
keepCw: {
|
||||
where: 'account',
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import * as os from '@/os.js';
|
||||
import { instance } from '@/instance.js';
|
||||
|
@ -102,7 +103,13 @@ export function openInstanceMenu(ev: MouseEvent) {
|
|||
action: () => {
|
||||
window.open('https://misskey-hub.net/help.html', '_blank');
|
||||
},
|
||||
}, {
|
||||
}, ($i) ? {
|
||||
text: i18n.ts._initialTutorial.launchTutorial,
|
||||
icon: 'ti ti-presentation',
|
||||
action: () => {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {}, {}, 'closed');
|
||||
},
|
||||
} : undefined, {
|
||||
type: 'link',
|
||||
text: i18n.ts.aboutMisskey,
|
||||
to: '/about-misskey',
|
||||
|
|
Loading…
Reference in a new issue