diff options
521 files changed, 11151 insertions, 5598 deletions
diff --git a/.autogen/check_pr.jq b/.autogen/check_pr.jq new file mode 100644 index 0000000000..0adb0b503d --- /dev/null +++ b/.autogen/check_pr.jq @@ -0,0 +1,3 @@ +.[] +.head +.label diff --git a/.autogen/next_url.jq b/.autogen/next_url.jq new file mode 100644 index 0000000000..b4c3b819a5 --- /dev/null +++ b/.autogen/next_url.jq @@ -0,0 +1,2 @@ +.links +.next diff --git a/.autogen/patreon.jq b/.autogen/patreon.jq new file mode 100644 index 0000000000..c761d587b8 --- /dev/null +++ b/.autogen/patreon.jq @@ -0,0 +1,39 @@ +( + .data | + map( + select( + .relationships + .currently_entitled_tiers + .data[] + ) + ) | + map( + .relationships + .user + .data + .id + ) +) as $data | +.included | +map( + select( + .id as $id | + $data | + contains( + [ + $id + ] + ) + ) +) | +map( + .attributes | + [ + .full_name, + .thumb_url, + .url + ] | + @tsv +) | +.[] | +@text diff --git a/.autogen/autogen.sh b/.autogen/update_readme_patreon.sh index 30198f8048..d66fa433e5 100755 --- a/.autogen/autogen.sh +++ b/.autogen/update_readme_patreon.sh @@ -5,7 +5,7 @@ # __MISSKEY_HEAD=acid-chicken:patch-autogen # __MISSKEY_REPO=syuilo/misskey # __MISSKEY_BRANCH=develop -test "$(curl -LSs -w '\n' -- "https://api.github.com/repos/$REPO/pulls?access_token=$__MISSKEY_GITHUB_TOKEN" | jq -r '.[].head.label' | grep $__MISSKEY_HEAD)" && exit 1 +test "$(curl -LSs -w '\n' -- "https://api.github.com/repos/$REPO/pulls?access_token=$__MISSKEY_GITHUB_TOKEN" | jq -r -f check_pr.jq | grep $__MISSKEY_HEAD)" && exit 1 cd "$(dirname $0)/.." && \ touch null.cache && \ rm *.cache && \ @@ -30,12 +30,12 @@ while : touch patreon.cache && \ rm patreon.cache && \ cat patreon.raw.cache | \ - jq -r '(.data|map(select(.relationships.currently_entitled_tiers.data[]))|map(.relationships.user.data.id))as$data|.included|map(select(.id as$id|$data|contains([$id])))|map(.attributes|[.full_name,.thumb_url,.url]|@tsv)|.[]|@text' >> patreon.cache && \ + jq -r -f patreon.jq >> patreon.cache && \ echo '<table><tr>' >> patreon.md.cache && \ cat patreon.cache | \ awk -F'\t' '{print $2,$1}' | \ sed -e 's/ /\\" alt=\\"/' | \ - xargs -I% echo '<td><img src="%"></td>' >> patreon.md.cache && \ + xargs -I% echo '<td><img src="%" width="100"></td>' >> patreon.md.cache && \ echo '</tr><tr>' >> patreon.md.cache && \ cat patreon.cache | \ awk -F'\t' '{print $3,$1}' | \ @@ -43,7 +43,7 @@ while : xargs -I% echo '<td><a href="%</a></td>' >> patreon.md.cache && \ echo '</tr></table>' >> patreon.md.cache || \ exit 1 - new_url="$(cat patreon.raw.cache | jq -r '.links.next')" + new_url="$(cat patreon.raw.cache | jq -r -f next_url.jq)" test "$new_url" = 'null' && \ break || \ URL="$url" diff --git a/.circleci/config.yml b/.circleci/config.yml index 5b485768fe..54dff6c3e4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -54,6 +54,8 @@ jobs: - run: name: Build command: | + node-gyp configure + node-gyp build npm run build || (echo -e '\033[0;34mRebuild modules\033[0;39m' && ls -1A node_modules | grep '^[^@]' | xargs npm rebuild && ls -1A node_modules | grep '^@' | xargs -I%1 sh -c 'ls -1A node_modules/'%1' | xargs -P0 -I%2 npm rebuild node_modules/'%1'/%2' && npm run build) ls -1ARl node_modules > ls - save_cache: @@ -88,7 +90,7 @@ jobs: - run: name: Test command: | - npm run test || (npm rebuild && npm run test) || ((node-gyp configure && node-gyp build && npm run build || (echo -e '\033[0;34mRebuild modules\033[0;39m' && ls -1A node_modules | grep '^[^@]' | xargs npm rebuild && ls -1A node_modules | grep '^@' | xargs -I%1 sh -c 'ls -1A node_modules/'%1' | xargs -P0 -I%2 npm rebuild node_modules/'%1'/%2' && npm run build)) && npm run test) + npm run test ls -1ARl node_modules > ls - save_cache: name: Cache npm packages diff --git a/.config/example.yml b/.config/example.yml index 0f416ab96f..036f59052f 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -108,22 +108,5 @@ autoAdmin: true # port: 9200 # pass: null -# ServiceWorker -#sw: -# # Public key of VAPID -# public_key: example-sw-public-key -# -# # Private key of VAPID -# private_key: example-sw-private-key - # Clustering #clusterLimit: 1 - -# Summaly proxy -#summalyProxy: "http://example.com" - -# User recommendation -#user_recommendation: -# external: true -# engine: http://vinayaka.distsn.org/cgi-bin/vinayaka-user-match-misskey-api.cgi?{{host}}+{{user}}+{{limit}}+{{offset}} -# timeout: 300000 @@ -15,6 +15,9 @@ "vue/attributes-order": false, "vue/require-prop-types": false, "vue/require-default-prop": false, + "vue/html-closing-bracket-spacing": false, + "vue/singleline-html-element-content-newline": false, + "vue/no-v-html": false, "no-console": 0, "no-unused-vars": 0, "no-empty": 0 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..c21c4a719e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +# Summary + +<!-- + - + - * Please describe your changes here * + - + - If you are going to resolve some issue, please add this context. + - Resolve #ISSUE_NUMBER + - + - If you are going to fix some bug issue, please add this context. + - Fix #ISSUE_NUMBER + - + --> diff --git a/.gitignore b/.gitignore index 9d109e0c05..98ee82cd7e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ api-docs.json /mongo /elasticsearch *.code-workspace +yarn.lock +.DS_Store diff --git a/.node-version b/.node-version new file mode 100644 index 0000000000..79aa7360f1 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +v11.7.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index b26010b146..2ba42e9d6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,219 @@ ChangeLog ========= -破壊的変更のみ記載。 +10.78.3 +---------- +* 投票未対応インスタンス向けメッセージをわかりやすく +* リバーシが404になる問題を修正 +* デザインの修正 -This document describes breaking changes only. +10.78.2 +---------- +* リバーシが404になる問題を修正 +* ストリームで流れてくる投稿とAPIでタイムラインを取得したときとの不一致を修正 + +10.78.1 +---------- +* 「関係のない返信がタイムラインに流れる問題を修正」を取り消し +* デザインの修正 + +10.78.0 +---------- +* 他のインスタンスからアンケートに投票できるように +* スパムアカウントを報告できるように +* アクティブユーザー数のチャートを追加 +* 管理画面でドライブのファイルをURLやIDから操作できるように +* リアクション解除を他のサーバーと送受信するように +* ログイン時に二段階認証が分かりにくいのを改善 +* 投稿のツールチップを出すのは時間の上だけに変更 +* `*`や`_`でもイタリック構文を使えるように(アルファベットのみ) +* `__`でも太字構文を使えるように(アルファベットのみ) +* ハッシュタグ判定の強化 +* ストーク機能の廃止 +* ソーシャルタイムラインにフォロワー限定投稿が含まれていない問題を修正 +* リストタイムラインでフォロワー限定投稿が含まれていない問題を修正 +* リストタイムラインに自分宛てでないダイレクト投稿が非公開扱いで表示される問題を修正 +* 自分宛てのダイレクト投稿がホーム/ソーシャルタイムラインにストリームで流れない問題を修正 +* ストリームで投稿が流れてきたとき、返信先が「この投稿は非公開です」となる問題を修正 +* 関係のない返信がタイムラインに流れる問題を修正 +* 常にメディアを閲覧注意として投稿するオプションが機能していなかった問題を修正 +* リモートユーザーのアイコンが消えることがある問題を修正 +* ドライブのファイルメニューからアバターやバナーに設定することができない問題を修正 +* クライアントのAPIリクエストをストリーム経由で行うオプションを廃止 +* 一部箇所でカスタム絵文字が適用されていないのを修正 + +10.77.0 +---------- +* ローカルタイムライン無効オプションをグローバルタイムライン無効オプションと分離 +* モデレータはLTL無効時でもUIからLTLを消さない +* インスタンス情報ページに各種タイムラインの有効/無効を表示 + +10.76.0 +---------- +* disableLocalTimeline機能を強化 +* インスタンス情報ページの強化 +* ハッシュタグ判定の強化 +* SVGサムネイルを表示するように +* CWの引き継ぎ機能を無効化 + +10.75.0 +---------- +* ダイレクトを非公開のように使えるように +* モデレーターを凍結できないように +* モデレーター登録を解除できるように +* NSFWなメディアをユーザーページなどで表示しないように +* 管理画面でユーザーを状態でフィルタできるように +* 管理者がサインイン履歴を参照できるツール +* Renote数を再度表示するように +* インスタンス情報ページの追加 +* テーマの調整 +* UIの改善 + +10.74.0 +---------- +* Pleromaとのフェデレーションを修正 +* インスタンスのキャラクター画像を設定できるように +* Catモードの朝鮮語対応 +* CWが付いた投稿に返信する際、そのCWを引き継ぐように +* 投稿のソースをクリップボードにコピーできるように +* i/notifications API で取得する通知の種別を配列で指定できるように +* パフォーマンスの改善 +* バグ修正 + +10.73.0 +------- +* テーマの強化 +* line thiknessの設定はデバイスに保存するように + +10.72.0 +------- +* いくつかのテーマの追加 +* デザインの調整 +* バグ修正 +* など + +10.71.0 +------- +* いくつかのテーマの追加 + +10.70.1 +------- +* notes/mentions にミュートを適用するように +* Add id to return of users/relation +* デザインの調整 + +10.70.0 +------- +* フォローしているユーザーからのフォローを自動承認するオプション +* 「非公開」の公開範囲を廃止 +* Renote数の表示を廃止 +* 投稿のフィルタリングを強化 +* デザインの調整 + +10.69.0 +------- +* 通知の管理を強化 +* ユーザビリティの強化 +* デザインの調整 + +10.68.0 +------- +* 特定ユーザーにメンション付きで新規投稿ができるボタンを追加 +* 自分の投稿にリアクションできないように +* 数式に文法エラーがあるとき、数式のソースをそのまま表示するように +* CWボタンにアンケートの有無を表記するように +* デスクトップ版で設定を新しいタブで開くように +* モバイル版で検索ができない問題を修正 +* i18nの修正 + +10.67.0 +------- +* トークのメッセージを削除できるように +* リアクションを取り消せるように +* Misskey以外のソフトウェアからの「Like」アクティビティをプリンではなく「いいね」として扱うように +* i18nの修正 +* バグ修正 +* など + +10.66.2 +------- +* i18nの修正 +* ドライブのファイル一覧取得APIでファイルサイズによるソートが機能していなかった問題を修正 +* リモートユーザーの更新時に、各ピン留め投稿の取得失敗は無視するように +* リモートMisskeyユーザーの情報が登録/更新出来なくなっていたのを修正 +* メンションのリンク先URLに余計な@がプリフィクスされていたのを修正 +* ダイレクトでリプライする際、リプライ先のユーザーは自動的に公開先として追加するように +* ダイレクトでメンションでもユーザーを指定できるように + +10.66.1 +------- +* ActivityPubのsharedInboxに関して修正 +* MFMでのカッコの判定を改善 +* バグ修正 + +10.66.0 +------- +* ユーザーごとのRSSフィードを提供するように +* リストのユーザーがすべて表示できない問題を修正 +* デザインの調整 +* パフォーマンスの改善 + +10.65.0 +------- +* 検索で投稿やユーザーのURLを入力した際にそれをフェッチして表示するように +* リストのリネームと削除をできるように +* リストからユーザーを削除できるように +* リモートの絵文字を更新するように +* ActivityPubのための絵文字エンドポイントを実装 +* 管理者がドライブのファイルのNSFWを設定できるように +* ServiceWorkerの設定を管理者ページで行えるように +* メンションの判定を改善 +* リモートの投稿を引用した際にオリジナルのURLを挿入するように +* クライアントのパフォーマンス改善 +* CWの内容がタブタイトルに表示されるのを修正 +* アカウントを作成したときにログイン状態にならない問題を修正 +* 時計の針にテーマカラーが適用されていなかったのを修正 +* 一部の日時の表示が日本語で表示されていたのを修正 +* プロフィールの写真欄に画像以外のファイルが含まれる問題を修正 +* メンションが含まれる投稿に返信する際、フォームに予めそれらのメンションがセットされた状態にならない問題を修正 +* デッキのTLにUIの動きを減らすオプションが適用されていなかったのを修正 +* ログイン画面のタイムラインに隠した投稿が表示される問題を修正 +* サジェストが複数開いてしまう問題を修正 +* APから来たタグに登録時の長さ制限が適用されていなかったのを修正 + +10.64.2 +------- +* UIの動きを減らすオプションが一部のアニメーションに適用されなかったのを修正 + +10.64.1 +------- +* レートリミットの調整 +* アニメーションの調整 + +10.64.0 +------- +* いくつかのアニメーションを追加 +* OGP向けにインスタンスのバナー画像を提供するように +* 管理者ページでドライブのファイルを表示できるように +* ユーザビリティの強化 +* バグ修正 + +10.63.1 +------- +* メンションの表示を改善 +* バグ修正 + +10.63.0 +------- +* ActivityPubのユーザーフィールドをユーザーページに表示 +* 404ページの実装 +* パフォーマンスの向上 +* バグ修正 + +10.62.2 +------- +* バグ修正 +* ユーザビリティの向上 10.0.0 ------ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be46ad9130..a6cb4f62a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,3 +25,16 @@ Misskey uses [vue-i18n](https://github.com/kazupon/vue-i18n). ## Continuous integration Misskey uses CircleCI for automated test. Configuration files are located in `/.circleci`. + +## Glossary +### AP +Stands for _**A**ctivity**P**ub_. + +### MFM +Stands for _**M**isskey **F**lavored **M**arkdown_. + +### Mk +Stands for _**M**iss**k**ey_. + +### SW +Stands for _**S**ervice**W**orker_. diff --git a/Dockerfile b/Dockerfile index 078b96a159..8aea9d54a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,18 +8,20 @@ WORKDIR /misskey FROM base AS builder +RUN unlink /usr/bin/free RUN apk add --no-cache \ - gcc \ - g++ \ - libc-dev \ - python \ autoconf \ automake \ file \ + g++ \ + gcc \ + libc-dev \ + libtool \ make \ nasm \ pkgconfig \ - libtool \ + procps \ + python \ zlib-dev RUN npm i -g node-gyp @@ -3,11 +3,11 @@ [](https://misskey.xyz/) ================================================================ -[](https://circleci.com/gh/syuilo/misskey) -[![][dependencies-badge]][dependencies-link] -[](http://makeapullrequest.com) +[](https://circleci.com/gh/syuilo/misskey) +[](https://david-dm.org/syuilo/misskey) +[](http://makeapullrequest.com) -**Sophisticated microblogging platform, evolving forever.** +**A forever evolving, sophisticated microblogging platform.** <p align="justify"> <a href="https://misskey.xyz">Misskey</a> is a decentralized microblogging platform born on Earth. @@ -27,7 +27,7 @@ Why don't you take a short break from the hustle and bustle of the city, and div <h3 align="left">Posting</h3> <p align="justify"> -Just post your idea, hot topics and anything you want to share. You may decorate your words, attach your favorite pictures or movies, and create a poll - those are all supported in Misskey! +Post your ideas, discussion topics, fun moments, or anything else you want to share! Misskey supports text, emoji, pictures, videos, and polls! </p> --- @@ -36,7 +36,7 @@ Just post your idea, hot topics and anything you want to share. You may decorate <h3 align="right">Reactions</h3> <p align="justify"> -The simplest way to tell your emotions to the posts. You can choose the best reaction from various reactions. Reactions on Misskey has much more expressive than other social media which only allows pushing “likes”. +Reactions are the simplest way to respond to others' posts. Simply pick a reaction emote from the list! Reactions on Misskey are much more expressive than other social media services which only allow “liking”. </p> --- @@ -45,7 +45,7 @@ The simplest way to tell your emotions to the posts. You can choose the best rea <h3 align="left">Interface</h3> <p align="justify"> -Highly customizable UI for your taste. We understand no UI fits for everyone. Make your graceful home by editing, adjusting layouts of timeline, and placing widgets. +Customize the UI to your own tastes! No UI will work for everyone, so Misskey is completely customizable. Make Misskey *yours* by editing the style, adjusting timeline layouts, and placing widgets. </p> --- @@ -54,79 +54,103 @@ Highly customizable UI for your taste. We understand no UI fits for everyone. Ma <h3 align="right">Misskey Drive</h3> <p align="justify"> -Organized uploaded files. Wanna post a picture you have already uploaded? Wish to create a folder for your files? Misskey Drive is the best solution for you. +Organize and store your files! Want to post a picture you have already uploaded? Wish you could organize your files into folders? Misskey Drive is a solution! </p> --- -and more! Now it's time to experience the world with your own eyes at [misskey.xyz](https://misskey.xyz) or [other instances](https://joinmisskey.github.io/). +...and more! Experience Misskey with your own eyes at [misskey.xyz](https://misskey.xyz) or join one of the [other instances](https://joinmisskey.github.io/) that are available. -:package: Create your own instance +:package: Create Your Own Instance ---------------------------------------------------------------- -Please see [Setup and installation guide](./docs/setup.en.md). +Please see the [Setup and Installation Guide](./docs/setup.en.md). :wrench: Contribution ---------------------------------------------------------------- -Please see [Contribution guide](./CONTRIBUTING.md). +Please see the [Contribution Guide](./CONTRIBUTING.md). + +### Collaborators +<table> + <tr> + <td><img src="https://avatars3.githubusercontent.com/u/4439005?s=460&v=4" alt="syuilo" width="100"></td> + <td><img src="https://avatars0.githubusercontent.com/u/10798641?s=460&v=4" alt="AyaMorisawa" width="100"></td> + <td><img src="https://avatars1.githubusercontent.com/u/30769358?s=460&v=4" alt="mei23" width="100"></td> + <td><img src="https://avatars2.githubusercontent.com/u/20679825?s=460&v=4" alt="acid-chicken" width="100"></td> + </tr> + <tr> + <td align="center"><a href="https://github.com/syuilo">@syuilo</a></td> + <td align="center"><a href="https://github.com/AyaMorisawa">@AyaMorisawa</a></td> + <td align="center"><a href="https://github.com/mei23">@mei23</a></td> + <td align="center"><a href="https://github.com/acid-chicken">@acid-chicken</a></td> + </tr> +</table> :heart: Backers & Sponsors ---------------------------------------------------------------- <!-- PATREON_START --> <table><tr> -<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12731202/0995c46cdcb54153ab5f073f5869b70a/1?token-time=2145916800&token-hash=Yd60FK_SWfQO56SeiJpy1tDHOnCV4xdEywQe8gn5_Wo%3D" alt="negao"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12913507/f7181eacafe8469a93033d85f5969c29/2?token-time=2145916800&token-hash=mgPdX9TqZxEg4TTPuc477dxhIgYk9246qafjWZEqZ7g%3D" alt="Melilot"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/3384329/8b713330cb27404ea6e9fac50ff96efe/1?token-time=2145916800&token-hash=0eu4-m1gTWA9PhptVZt6rdKcusqcD7RB87rJT23VVFI%3D" alt="べすれい"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12021162/963128bb8d14476dbd8407943db8f31a/1?token-time=2145916800&token-hash=GgJ_NmUB6_nnRNLVGUWjV-WX91On7BOu59LKncYV9fE%3D" alt="gutfuckllc"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/11357794/923ce94cd8c44ba788ee931907881839/1?token-time=2145916800&token-hash=I8lJVM8LeW6TSo5W6uIIRZ42cw83zp1wK_FsbzY0mcQ%3D" alt="mydarkstar"></td> -<td><img src="https://c8.patreon.com/2/100/12718187" alt="Peter G."></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/12190916/fb7fa7983c14425f890369535b1506a4/1?token-time=2145916800&token-hash=WeuDzzz24cRXJogyIkU-mxARqkdyms-rcZKbO-GpGjw%3D" alt="weep" width="100"></td> +<td><img src="https://c8.patreon.com/2/200/12059069" alt="naga_rus" width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/12731202/0995c46cdcb54153ab5f073f5869b70a/1?token-time=2145916800&token-hash=prtYqPOiSHBulhM7NU0VzMaWx39-9ntdq25b6kafDNA%3D" alt="negao" width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/12913507/f7181eacafe8469a93033d85f5969c29/3?token-time=2145916800&token-hash=c8HeVqLtmdgH-gSBJg8i10gmOcwllM87MDHeznl3el0%3D" alt="Melilot" width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/12999811/5f349fafcce44dd1824a8b1ebbec4564/3?token-time=2145916800&token-hash=LtV2lRi3L2jOWMLwccr9qWYfPrFlzIo2jYZHKzHEb6k%3D" alt="Xeltica" width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/3384329/8b713330cb27404ea6e9fac50ff96efe/1?token-time=2145916800&token-hash=Ch3iF81ZGP0LMo894Y9ajpLisgtE91SnxtZE7fxsgrM%3D" alt="べすれい" width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/12021162/963128bb8d14476dbd8407943db8f31a/1?token-time=2145916800&token-hash=1FlxS9MEgmNGH_RHUVHbO5hIXB5I1z0lvA33CTvYvjA%3D" alt="gutfuckllc" width="100"></td> </tr><tr> +<td><a href="https://www.patreon.com/weepjp">weep</a></td> +<td><a href="https://www.patreon.com/user?u=12059069">naga_rus</a></td> <td><a href="https://www.patreon.com/negao">negao</a></td> <td><a href="https://www.patreon.com/user?u=12913507">Melilot</a></td> +<td><a href="https://www.patreon.com/Xeltica">Xeltica</a></td> <td><a href="https://www.patreon.com/user?u=3384329">べすれい</a></td> <td><a href="https://www.patreon.com/gutfuckllc">gutfuckllc</a></td> -<td><a href="https://www.patreon.com/mydarkstar">mydarkstar</a></td> -<td><a href="https://www.patreon.com/user?u=12718187">Peter G.</a></td> </tr></table> <table><tr> -<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/13039004/509d0c412eb14ae08d6a812a3054f7d6/1?token-time=2145916800&token-hash=zwSu01tOtn5xTUucDZHuPsCxF2HBEMVs9ROJKTlEV_o%3D" alt="nemu"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/5881381/6235ca5d3fb04c8e95ef5b4ff2abcc18/3?token-time=2145916800&token-hash=qsdn0-e6yLaLI6hUX9JAkyTR6a5UdnSp7T1foniBvGQ%3D" alt="YUKIMOCHI"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/8241184/39e18850e87a449e9c9a71acb3310ebd/2?token-time=2145916800&token-hash=iUXOQzRyJDv3PJxwS7Mjwg1459dzh2trOq6NFtXu_OM%3D" alt="Acid Chicken"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/13034746/c711c7f58e204ecfbc2fd646bc8a4eee/1?token-time=2145916800&token-hash=UERBN4OyP7Nh5XwwdDg0N0IE5cD6_qUQMO81Z5Wizso%3D" alt="Hiratake"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/10789744/97175095d8f04c0f86225ff47cb98d40/1?token-time=2145916800&token-hash=P4BIzCX2I1CkEP66ottfhsC8Wr6BUSamjA-vq3pLqFI%3D" alt="Naoki Hirayama"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/4503830/ccf2cc867ea64de0b524bb2e24b9a1cb/1?token-time=2145916800&token-hash=S1zP0QyLU52Dqq6dtc9qNYyWfW86XrYHiR4NMbeOrnA%3D" alt="dansup"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/619786/32cf01444db24e578cd1982c197f6fc6/1?token-time=2145916800&token-hash=tB1e_r8RlZ5sFL0KV_e8dugapxatNBRK1Z3h67TO1g8%3D" alt="Gargron"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/5731881/4b6038e6cda34c04b83a5fcce3806a93/1?token-time=2145916800&token-hash=VZUtwrjQa8Jml4twCjHYQQZ64wHEY4oIlGl7Kc-VYUQ%3D" alt="Nokotaro Takeda"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12531784/93a45137841849329ba692da92ac7c60/1?token-time=2145916800&token-hash=tMosUojzUYJCH_3t--tvYA-SMCyrS__hzSndyaRSnbo%3D" alt="Takashi Shibuya"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/11357794/923ce94cd8c44ba788ee931907881839/1?token-time=2145916800&token-hash=0xgcpqvFDqRcV_YIEhcPNVH7gs9sLg_BBnTJXCkN4ao%3D" alt="mydarkstar" width="100"></td> +<td><img src="https://c8.patreon.com/2/200/12718187" alt="Peter G." width="100"></td> +<td><img src="https://c8.patreon.com/2/200/16542964" alt="Takumi Sugita" width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/13039004/509d0c412eb14ae08d6a812a3054f7d6/1?token-time=2145916800&token-hash=2PsbFNw0tnubZzgSXD01R6hIgncfiElG7H7HX2Y3dyo%3D" alt="nemu" width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/5881381/6235ca5d3fb04c8e95ef5b4ff2abcc18/3?token-time=2145916800&token-hash=9JtETp0X8gI280Ne1E8bxn6j4Lw5o2k4mJkICx97V_k%3D" alt="YUKIMOCHI" width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/8241184/39e18850e87a449e9c9a71acb3310ebd/3?token-time=2145916800&token-hash=gMq30aylxu5v3G8pRhWR5jeRBbYWEoRKjGbNeiCQz5g%3D" alt="Acid Chicken" width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/4389829/9f709180ac714651a70f74a82f3ffdb9/2?token-time=2145916800&token-hash=zcwFxb2zopzWwksKVU1YpfAEjsl4yKT02aQ6yiAFRiQ%3D" alt="natalie" width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/13034746/c711c7f58e204ecfbc2fd646bc8a4eee/1?token-time=2145916800&token-hash=5T8XcaAf9Zyzfg3QubR06s_kJZkArVEM2dwObrBVAU4%3D" alt="Hiratake" width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/10789744/97175095d8f04c0f86225ff47cb98d40/1?token-time=2145916800&token-hash=ubVARikVOg3v7NW6LDhtG-ClE1LTU3I2TJ3js2-5xDs%3D" alt="Naoki Hirayama" width="100"></td> </tr><tr> +<td><a href="https://www.patreon.com/mydarkstar">mydarkstar</a></td> +<td><a href="https://www.patreon.com/user?u=12718187">Peter G.</a></td> +<td><a href="https://www.patreon.com/user?u=16542964">Takumi Sugita</a></td> <td><a href="https://www.patreon.com/user?u=13039004">nemu</a></td> <td><a href="https://www.patreon.com/yukimochi">YUKIMOCHI</a></td> <td><a href="https://www.patreon.com/acid_chicken">Acid Chicken</a></td> +<td><a href="https://www.patreon.com/user?u=4389829">natalie</a></td> <td><a href="https://www.patreon.com/hiratake">Hiratake</a></td> <td><a href="https://www.patreon.com/spinlock">Naoki Hirayama</a></td> +</tr></table> +<table><tr> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/4503830/ccf2cc867ea64de0b524bb2e24b9a1cb/1?token-time=2145916800&token-hash=Ksk_2l3gjPDbnzMUOCSW1E-hdPJsNs2tSR4_RAakRK8%3D" alt="dansup" width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/619786/32cf01444db24e578cd1982c197f6fc6/1?token-time=2145916800&token-hash=CXe9AqlZy9AsYfiWd3OBYVOzvODoN47Litz0Tu4BFpU%3D" alt="Gargron" width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/5731881/4b6038e6cda34c04b83a5fcce3806a93/1?token-time=2145916800&token-hash=xhR1n6NAAyEb-IUXLD6_dshkFa3mefU5ZZuk1L8qKTs%3D" alt="Nokotaro Takeda" width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/12531784/93a45137841849329ba692da92ac7c60/1?token-time=2145916800&token-hash=uR-48MQ0A4j0irQSrCAQZJ-sJUSs_Fkihlg3-l59b7c%3D" alt="Takashi Shibuya" width="100"></td> +</tr><tr> <td><a href="https://www.patreon.com/dansup">dansup</a></td> <td><a href="https://www.patreon.com/mastodon">Gargron</a></td> <td><a href="https://www.patreon.com/takenoko">Nokotaro Takeda</a></td> <td><a href="https://www.patreon.com/user?u=12531784">Takashi Shibuya</a></td> </tr></table> -<table><tr> -</tr><tr> -</tr></table> -**Last updated:** Wed, 31 Oct 2018 23:21:06 UTC +**Last updated:** Mon, 21 Jan 2019 06:45:06 UTC <!-- PATREON_END --> :four_leaf_clover: Copyright ---------------------------------------------------------------- -> Copyright (c) 2014-2018 syuilo +> Copyright (c) 2014-2019 syuilo -Misskey is an open-source software licensed under the [GNU AGPLv3](LICENSE). +Misskey is open-source software licensed under the [GNU AGPLv3](LICENSE). [![][agpl-3.0-badge]][AGPL-3.0] [agpl-3.0]: https://www.gnu.org/licenses/agpl-3.0.en.html -[agpl-3.0-badge]: https://img.shields.io/badge/license-AGPL--3.0-444444.svg?style=flat-square -[dependencies-link]: https://david-dm.org/syuilo/misskey -[dependencies-badge]: https://img.shields.io/david/syuilo/misskey.svg?style=flat-square +[agpl-3.0-badge]: https://img.shields.io/badge/license-AGPL--3.0-444444.svg?style=for-the-badge [backer-url]: #backers [backer-badge]: https://opencollective.com/misskey/backers/badge.svg diff --git a/assets/about/drive.png b/assets/about/drive.png Binary files differindex c35de433a8..16037aae39 100644 --- a/assets/about/drive.png +++ b/assets/about/drive.png diff --git a/assets/about/post.png b/assets/about/post.png Binary files differindex ba291ec665..3c55f66c56 100644 --- a/assets/about/post.png +++ b/assets/about/post.png diff --git a/assets/about/ui.png b/assets/about/ui.png Binary files differindex ad102a31af..0601837f4c 100644 --- a/assets/about/ui.png +++ b/assets/about/ui.png diff --git a/assets/ai-orig.png b/assets/ai-orig.png Binary files differindex b684e2c078..2837456a93 100644 --- a/assets/ai-orig.png +++ b/assets/ai-orig.png diff --git a/assets/ai.png b/assets/ai.png Binary files differindex 9c6ca56632..c1eab6608d 100644 --- a/assets/ai.png +++ b/assets/ai.png diff --git a/assets/apple-touch-icon.png b/assets/apple-touch-icon.png Binary files differindex fc91b6bab9..ed7e14368c 100644 --- a/assets/apple-touch-icon.png +++ b/assets/apple-touch-icon.png diff --git a/assets/favicon.ico b/assets/favicon.ico Binary files differindex 8e2b3ff4ca..5b61e8a592 100644 --- a/assets/favicon.ico +++ b/assets/favicon.ico diff --git a/assets/favicon/favicon.png b/assets/favicon/favicon.png Binary files differindex de535d7e2a..88ed86cc3a 100644 --- a/assets/favicon/favicon.png +++ b/assets/favicon/favicon.png diff --git a/assets/favicon/favicon.svg b/assets/favicon/favicon.svg index 9532def728..b0ff390676 100644 --- a/assets/favicon/favicon.svg +++ b/assets/favicon/favicon.svg @@ -1,108 +1,17 @@ <?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!-- Created with Inkscape (http://www.inkscape.org/) --> - <svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="512" - height="512" - viewBox="0 0 135.46667 135.46667" - version="1.1" id="svg8" - inkscape:version="0.92.1 r15371" - sodipodi:docname="favicon.svg" - inkscape:export-filename="C:\Users\syuilo\projects\misskey\assets\favicon\16.png" - inkscape:export-xdpi="3" - inkscape:export-ydpi="3"> + version="1.1" + viewBox="0 0 135.46667 135.46667" + height="512" + width="512"> <defs - id="defs2"> - <inkscape:path-effect - effect="simplify" - id="path-effect5115" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - <inkscape:path-effect - effect="simplify" - id="path-effect5111" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - <inkscape:path-effect - effect="simplify" - id="path-effect5104" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - </defs> - <sodipodi:namedview - id="base" - pagecolor="#ffffff" - bordercolor="#666666" - borderopacity="1.0" - inkscape:pageopacity="0.0" - inkscape:pageshadow="2" - inkscape:zoom="1.4142136" - inkscape:cx="15.466544" - inkscape:cy="235.92965" - inkscape:document-units="px" - inkscape:current-layer="g4502" - showgrid="true" - units="px" - inkscape:snap-bbox="true" - inkscape:bbox-nodes="true" - inkscape:snap-bbox-edge-midpoints="false" - inkscape:snap-smooth-nodes="true" - inkscape:snap-center="true" - inkscape:snap-page="true" - inkscape:window-width="1920" - inkscape:window-height="1027" - inkscape:window-x="-8" - inkscape:window-y="1072" - inkscape:window-maximized="1" - inkscape:snap-object-midpoints="true" - inkscape:snap-midpoints="true" - inkscape:object-paths="true" - fit-margin-top="0" - fit-margin-left="0" - fit-margin-right="0" - fit-margin-bottom="0" - objecttolerance="1" - guidetolerance="1" - inkscape:snap-nodes="false" - inkscape:snap-others="false"> - <inkscape:grid - type="xygrid" - id="grid4504" - spacingx="4.2333334" - spacingy="4.2333334" - empcolor="#ff3fff" - empopacity="0.25098039" - empspacing="4" /> - </sodipodi:namedview> + id="defs2" /> <metadata id="metadata5"> <rdf:RDF> @@ -116,32 +25,27 @@ </rdf:RDF> </metadata> <g - inkscape:label="レイヤー 1" - inkscape:groupmode="layer" - id="layer1" - transform="translate(-30.809093,-111.78601)"> + style="fill:#2fa3bc;fill-opacity:1" + transform="translate(-30.809093,-111.78601)" + id="layer1"> <g - id="g4502" - transform="matrix(1.096096,0,0,1.096096,-2.960633,-44.023579)"> + style="fill:#2fa3bc;fill-opacity:1" + transform="matrix(1.096096,0,0,1.096096,-2.960633,-44.023579)" + id="g4502"> <g - style="fill:#2fa1bb;fill-opacity:1" - id="g5125"> + id="g5125" + transform="translate(-1.3333333e-6,-1.3439941e-6)" + style="fill:#2fa3bc;fill-opacity:1"> <g - transform="matrix(0.91391326,0,0,0.91391326,7.9719907,17.595761)" + aria-label="Mi" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#2fa3bc;fill-opacity:1;stroke:none;stroke-width:0.28950602px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" id="text4489" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#2fa1bb;fill-opacity:1;stroke:none;stroke-width:0.28950602px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - aria-label="Mi"> + transform="matrix(0.91391326,0,0,0.91391326,7.9719907,17.595761)"> <path - sodipodi:nodetypes="zccssscssccscczzzccsccsscscsccz" - inkscape:connector-curvature="0" id="path5210" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#2fa1bb;fill-opacity:1;stroke-width:0.28950602px" - d="m 75.196381,231.17126 c -5.855419,0.0202 -10.885068,-3.50766 -13.2572,-7.61584 -1.266603,-1.79454 -3.772419,-2.43291 -3.807919,0 v 11.2332 c 0,4.51309 -1.645397,8.41504 -4.936191,11.70583 -3.196772,3.19677 -7.098714,4.79516 -11.705826,4.79516 -4.513089,0 -8.415031,-1.59839 -11.705825,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -61.7729 c 0,-3.47884 0.987238,-6.6286 2.961715,-9.44928 2.068499,-2.91471 4.701135,-4.9362 7.897906,-6.06447 1.786431,-0.65816 3.666885,-0.98724 5.641362,-0.98724 5.077225,0 9.308247,1.97448 12.693064,5.92343 1.786431,1.97448 2.820681,3.00873 3.102749,3.10275 0,0 13.408119,16.21319 13.78421,16.49526 0.376091,0.28206 1.480789,2.43848 4.127113,2.43848 2.646324,0 3.89218,-2.15642 4.26827,-2.43848 0.376091,-0.28207 13.784088,-16.49526 13.784088,-16.49526 0.09402,0.094 1.081261,-0.94022 2.961715,-3.10275 3.478837,-3.94895 7.756866,-5.92343 12.834096,-5.92343 1.88045,0 3.76091,0.32908 5.64136,0.98724 3.19677,1.12827 5.7824,3.14976 7.75688,6.06447 2.06849,2.82068 3.10274,5.97044 3.10274,9.44928 v 61.7729 c 0,4.51309 -1.6454,8.41504 -4.93619,11.70583 -3.19677,3.19677 -7.09871,4.79516 -11.70582,4.79516 -4.51309,0 -8.41504,-1.59839 -11.705828,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -11.2332 c -0.277898,-3.06563 -2.987588,-1.13379 -3.948953,0 -2.538613,4.70114 -7.401781,7.59567 -13.2572,7.61584 z" /> - <path - inkscape:connector-curvature="0" - id="path5212" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#2fa1bb;fill-opacity:1;stroke-width:0.28950602px" - d="m 145.83461,185.00361 q -5.92343,0 -10.15445,-4.08999 -4.08999,-4.23102 -4.08999,-10.15445 0,-5.92343 4.08999,-10.01342 4.23102,-4.23102 10.15445,-4.23102 5.92343,0 10.15445,4.23102 4.23102,4.08999 4.23102,10.01342 0,5.92343 -4.23102,10.15445 -4.23102,4.08999 -10.15445,4.08999 z m 0.14103,2.82068 q 5.92343,0 10.01342,4.23102 4.23102,4.23102 4.23102,10.15445 v 34.83541 q 0,5.92343 -4.23102,10.15445 -4.08999,4.08999 -10.01342,4.08999 -5.92343,0 -10.15445,-4.08999 -4.23102,-4.23102 -4.23102,-10.15445 v -34.83541 q 0,-5.92343 4.23102,-10.15445 4.23102,-4.23102 10.15445,-4.23102 z" /> + transform="matrix(0.26412464,0,0,0.26412464,24.988264,136.28626)" + d="m 62.474609,76.585938 c -7.47555,0 -14.595784,1.246427 -21.359375,3.738281 C 29.011968,84.595952 19.044417,92.249798 11.212891,103.28516 3.7373405,113.96451 0,125.88934 0,139.06055 v 233.8789 c 0,17.08697 6.0510264,31.85913 18.154297,44.31836 12.459246,12.10327 27.233346,18.15625 44.320312,18.15625 17.442947,0 32.215089,-6.05298 44.318361,-18.15625 12.45925,-12.45923 18.68945,-27.23139 18.68945,-44.31836 V 330.4082 c 0.13441,-9.21122 9.6225,-6.79429 14.41797,0 8.98111,15.55395 28.02226,28.91242 50.19141,28.83594 22.16915,-0.0764 40.58194,-11.03699 50.19336,-28.83594 3.63981,-4.29263 13.89902,-11.60675 14.95117,0 v 42.53125 c 0,17.08697 6.05102,31.85913 18.15429,44.31836 12.45923,12.10327 27.23335,18.15625 44.32032,18.15625 17.44294,0 32.21509,-6.05298 44.31836,-18.15625 12.45923,-12.45923 18.68945,-27.23139 18.68945,-44.31836 v -233.8789 c 0,-13.17121 -3.9146,-25.09604 -11.74609,-35.77539 -7.47557,-11.035362 -17.26588,-18.689208 -29.36914,-22.960941 -7.11956,-2.491854 -14.23982,-3.738281 -21.35938,-3.738281 -19.22286,0 -35.41865,7.476649 -48.58984,22.427734 l -63.40235,74.199218 c -1.42391,1.06791 -6.14093,9.23242 -16.16015,9.23242 -10.01923,0 -14.20109,-8.16451 -15.625,-9.23242 L 110.53125,99.013672 C 97.716024,84.062587 81.697447,76.585938 62.474609,76.585938 Z m 395.060551,0 c -14.9511,-10e-7 -27.76596,5.340179 -38.44532,16.019531 -10.32338,10.323381 -15.48437,22.961011 -15.48437,37.912111 0,14.9511 5.16099,27.76596 15.48437,38.44531 10.67936,10.32338 23.49422,15.48633 38.44532,15.48633 14.95109,0 27.76596,-5.16295 38.44531,-15.48633 C 506.65982,158.28354 512,145.46868 512,130.51758 512,115.56648 506.65982,102.92885 495.98047,92.605469 485.30112,81.926117 472.48625,76.585938 457.53516,76.585938 Z m 0.5332,118.541012 c -14.9511,0 -27.76596,5.34018 -38.44531,16.01953 -10.67936,10.67936 -16.01758,23.49422 -16.01758,38.44532 v 131.89062 c 0,14.9511 5.33822,27.76596 16.01758,38.44531 10.67935,10.32339 23.49421,15.48633 38.44531,15.48633 14.9511,0 27.58873,-5.16294 37.91211,-15.48633 C 506.65982,409.24838 512,396.43352 512,381.48242 V 249.5918 c 0,-14.9511 -5.34018,-27.76596 -16.01953,-38.44532 -10.32338,-10.67935 -22.96101,-16.01953 -37.91211,-16.01953 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#2fa3bc;fill-opacity:1;stroke-width:1.09609616px" /> </g> </g> </g> diff --git a/assets/icons/128.png b/assets/icons/128.png Binary files differindex c0f5db0cef..a0682baf69 100644 --- a/assets/icons/128.png +++ b/assets/icons/128.png diff --git a/assets/icons/16.png b/assets/icons/16.png Binary files differindex 9d3226d37a..138bb83c5f 100644 --- a/assets/icons/16.png +++ b/assets/icons/16.png diff --git a/assets/icons/192.png b/assets/icons/192.png Binary files differindex 0f92a83e45..c07c112c17 100644 --- a/assets/icons/192.png +++ b/assets/icons/192.png diff --git a/assets/icons/256.png b/assets/icons/256.png Binary files differindex fc91b6bab9..ed7e14368c 100644 --- a/assets/icons/256.png +++ b/assets/icons/256.png diff --git a/assets/icons/32.png b/assets/icons/32.png Binary files differindex 29b3876cb8..54a8da6452 100644 --- a/assets/icons/32.png +++ b/assets/icons/32.png diff --git a/assets/icons/64.png b/assets/icons/64.png Binary files differindex 2b14ba3b5f..d658f0b4e0 100644 --- a/assets/icons/64.png +++ b/assets/icons/64.png diff --git a/assets/mi.svg b/assets/mi.svg index 44e5c74d0c..8ceb12ad98 100644 --- a/assets/mi.svg +++ b/assets/mi.svg @@ -1,108 +1,17 @@ <?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!-- Created with Inkscape (http://www.inkscape.org/) --> - <svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="512" - height="512" - viewBox="0 0 135.46667 135.46667" - version="1.1" id="svg8" - inkscape:version="0.92.1 r15371" - sodipodi:docname="mi.svg" - inkscape:export-filename="C:\Users\syuilo\projects\misskey\assets\favicon\32.png" - inkscape:export-xdpi="6" - inkscape:export-ydpi="6"> + version="1.1" + viewBox="0 0 135.46667 135.46667" + height="512" + width="512"> <defs - id="defs2"> - <inkscape:path-effect - effect="simplify" - id="path-effect5115" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - <inkscape:path-effect - effect="simplify" - id="path-effect5111" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - <inkscape:path-effect - effect="simplify" - id="path-effect5104" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - </defs> - <sodipodi:namedview - id="base" - pagecolor="#ffffff" - bordercolor="#666666" - borderopacity="1.0" - inkscape:pageopacity="0.0" - inkscape:pageshadow="2" - inkscape:zoom="1.4142136" - inkscape:cx="232.39583" - inkscape:cy="251.50613" - inkscape:document-units="px" - inkscape:current-layer="g4502" - showgrid="true" - units="px" - inkscape:snap-bbox="true" - inkscape:bbox-nodes="true" - inkscape:snap-bbox-edge-midpoints="false" - inkscape:snap-smooth-nodes="true" - inkscape:snap-center="true" - inkscape:snap-page="true" - inkscape:window-width="1920" - inkscape:window-height="1027" - inkscape:window-x="-8" - inkscape:window-y="1072" - inkscape:window-maximized="1" - inkscape:snap-object-midpoints="true" - inkscape:snap-midpoints="true" - inkscape:object-paths="true" - fit-margin-top="0" - fit-margin-left="0" - fit-margin-right="0" - fit-margin-bottom="0" - objecttolerance="1" - guidetolerance="1" - inkscape:snap-nodes="false" - inkscape:snap-others="false"> - <inkscape:grid - type="xygrid" - id="grid4504" - spacingx="4.2333334" - spacingy="4.2333334" - empcolor="#ff3fff" - empopacity="0.25098039" - empspacing="4" /> - </sodipodi:namedview> + id="defs2" /> <metadata id="metadata5"> <rdf:RDF> @@ -111,32 +20,30 @@ <dc:format>image/svg+xml</dc:format> <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> - <dc:title /> + <dc:title></dc:title> </cc:Work> </rdf:RDF> </metadata> <g - inkscape:label="レイヤー 1" - inkscape:groupmode="layer" - id="layer1" - transform="translate(-30.809093,-111.78601)"> + transform="translate(-30.809093,-111.78601)" + id="layer1"> <g - id="g4502" - transform="matrix(1.096096,0,0,1.096096,-2.960633,-44.023579)"> + transform="matrix(1.096096,0,0,1.096096,-2.960633,-44.023579)" + id="g4502"> <g - style="fill:#000000;fill-opacity:1" + id="g5125" transform="translate(-1.3333333e-6,-1.3439941e-6)" - id="g5125"> + style="fill:#000000;fill-opacity:1"> <g - transform="matrix(0.91391326,0,0,0.91391326,7.9719907,17.595761)" - id="text4489" + aria-label="Mi" style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.28950602px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - aria-label="Mi"> + id="text4489" + transform="matrix(0.91391326,0,0,0.91391326,7.9719907,17.595761)"> <path - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#000000;fill-opacity:1;stroke-width:1.09609616px" - d="M 62.474609 76.585938 C 54.999059 76.585938 47.878825 77.832365 41.115234 80.324219 C 29.011968 84.595952 19.044417 92.249798 11.212891 103.28516 C 3.7373405 113.96451 0 125.88934 0 139.06055 L 0 372.93945 C 0 390.02642 6.0510264 404.79858 18.154297 417.25781 C 30.613543 429.36108 45.387643 435.41406 62.474609 435.41406 C 79.917556 435.41406 94.689698 429.36108 106.79297 417.25781 C 119.25222 404.79858 125.48242 390.02642 125.48242 372.93945 L 125.48242 330.4082 C 125.61683 321.19698 135.10492 323.61391 139.90039 330.4082 C 148.8815 345.96215 167.92265 359.32062 190.0918 359.24414 C 212.26095 359.16778 230.67374 348.20715 240.28516 330.4082 C 243.92497 326.11557 254.18418 318.80145 255.23633 330.4082 L 255.23633 372.93945 C 255.23633 390.02642 261.28735 404.79858 273.39062 417.25781 C 285.84985 429.36108 300.62397 435.41406 317.71094 435.41406 C 335.15388 435.41406 349.92603 429.36108 362.0293 417.25781 C 374.48853 404.79858 380.71875 390.02642 380.71875 372.93945 L 380.71875 139.06055 C 380.71875 125.88934 376.80415 113.96451 368.97266 103.28516 C 361.49709 92.249798 351.70678 84.595952 339.60352 80.324219 C 332.48396 77.832365 325.3637 76.585938 318.24414 76.585938 C 299.02128 76.585938 282.82549 84.062587 269.6543 99.013672 C 262.53473 107.20121 258.79542 111.11761 258.43945 110.76172 C 258.43945 110.76172 207.67587 172.14495 206.25195 173.21289 C 204.82804 174.2808 200.11102 182.44531 190.0918 182.44531 C 180.07257 182.44531 175.89071 174.2808 174.4668 173.21289 C 173.04288 172.14495 122.2793 110.76172 122.2793 110.76172 C 121.21136 110.40575 117.29484 106.48923 110.53125 99.013672 C 97.716024 84.062587 81.697447 76.585938 62.474609 76.585938 z M 457.53516 76.585938 C 442.58406 76.585937 429.7692 81.926117 419.08984 92.605469 C 408.76646 102.92885 403.60547 115.56648 403.60547 130.51758 C 403.60547 145.46868 408.76646 158.28354 419.08984 168.96289 C 429.7692 179.28627 442.58406 184.44922 457.53516 184.44922 C 472.48625 184.44922 485.30112 179.28627 495.98047 168.96289 C 506.65982 158.28354 512 145.46868 512 130.51758 C 512 115.56648 506.65982 102.92885 495.98047 92.605469 C 485.30112 81.926117 472.48625 76.585938 457.53516 76.585938 z M 458.06836 195.12695 C 443.11726 195.12695 430.3024 200.46713 419.62305 211.14648 C 408.94369 221.82584 403.60547 234.6407 403.60547 249.5918 L 403.60547 381.48242 C 403.60547 396.43352 408.94369 409.24838 419.62305 419.92773 C 430.3024 430.25112 443.11726 435.41406 458.06836 435.41406 C 473.01946 435.41406 485.65709 430.25112 495.98047 419.92773 C 506.65982 409.24838 512 396.43352 512 381.48242 L 512 249.5918 C 512 234.6407 506.65982 221.82584 495.98047 211.14648 C 485.65709 200.46713 473.01946 195.12695 458.06836 195.12695 z " + id="path5210" transform="matrix(0.26412464,0,0,0.26412464,24.988264,136.28626)" - id="path5210" /> + d="m 62.474609,76.585938 c -7.47555,0 -14.595784,1.246427 -21.359375,3.738281 C 29.011968,84.595952 19.044417,92.249798 11.212891,103.28516 3.7373405,113.96451 0,125.88934 0,139.06055 v 233.8789 c 0,17.08697 6.0510264,31.85913 18.154297,44.31836 12.459246,12.10327 27.233346,18.15625 44.320312,18.15625 17.442947,0 32.215089,-6.05298 44.318361,-18.15625 12.45925,-12.45923 18.68945,-27.23139 18.68945,-44.31836 V 330.4082 c 0.13441,-9.21122 9.6225,-6.79429 14.41797,0 8.98111,15.55395 28.02226,28.91242 50.19141,28.83594 22.16915,-0.0764 40.58194,-11.03699 50.19336,-28.83594 3.63981,-4.29263 13.89902,-11.60675 14.95117,0 v 42.53125 c 0,17.08697 6.05102,31.85913 18.15429,44.31836 12.45923,12.10327 27.23335,18.15625 44.32032,18.15625 17.44294,0 32.21509,-6.05298 44.31836,-18.15625 12.45923,-12.45923 18.68945,-27.23139 18.68945,-44.31836 v -233.8789 c 0,-13.17121 -3.9146,-25.09604 -11.74609,-35.77539 -7.47557,-11.035362 -17.26588,-18.689208 -29.36914,-22.960941 -7.11956,-2.491854 -14.23982,-3.738281 -21.35938,-3.738281 -19.22286,0 -35.41865,7.476649 -48.58984,22.427734 l -63.40235,74.199218 c -1.42391,1.06791 -6.14093,9.23242 -16.16015,9.23242 -10.01923,0 -14.20109,-8.16451 -15.625,-9.23242 L 110.53125,99.013672 C 97.716024,84.062587 81.697447,76.585938 62.474609,76.585938 Z m 395.060551,0 c -14.9511,-10e-7 -27.76596,5.340179 -38.44532,16.019531 -10.32338,10.323381 -15.48437,22.961011 -15.48437,37.912111 0,14.9511 5.16099,27.76596 15.48437,38.44531 10.67936,10.32338 23.49422,15.48633 38.44532,15.48633 14.95109,0 27.76596,-5.16295 38.44531,-15.48633 C 506.65982,158.28354 512,145.46868 512,130.51758 512,115.56648 506.65982,102.92885 495.98047,92.605469 485.30112,81.926117 472.48625,76.585938 457.53516,76.585938 Z m 0.5332,118.541012 c -14.9511,0 -27.76596,5.34018 -38.44531,16.01953 -10.67936,10.67936 -16.01758,23.49422 -16.01758,38.44532 v 131.89062 c 0,14.9511 5.33822,27.76596 16.01758,38.44531 10.67935,10.32339 23.49421,15.48633 38.44531,15.48633 14.9511,0 27.58873,-5.16294 37.91211,-15.48633 C 506.65982,409.24838 512,396.43352 512,381.48242 V 249.5918 c 0,-14.9511 -5.34018,-27.76596 -16.01953,-38.44532 -10.32338,-10.67935 -22.96101,-16.01953 -37.91211,-16.01953 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#000000;fill-opacity:1;stroke-width:1.09609616px" /> </g> </g> </g> diff --git a/assets/title.png b/assets/title.png Binary files differindex 7cb7dcdd6e..b94e66c20c 100644 --- a/assets/title.png +++ b/assets/title.png diff --git a/cli/reset-password.js b/cli/reset-password.js deleted file mode 100644 index d94c90f3d5..0000000000 --- a/cli/reset-password.js +++ /dev/null @@ -1,29 +0,0 @@ -const mongo = require('mongodb'); -const bcrypt = require('bcryptjs'); -const User = require('../built/models/user').default; - -const args = process.argv.slice(2); - -const user = args[0]; - -const q = user.startsWith('@') ? { - username: user.split('@')[1], - host: user.split('@')[2] || null -} : { _id: new mongo.ObjectID(user) }; - -console.log(`Resetting password for ${user}...`); - -const passwd = 'yo'; - -// Generate hash of password -const hash = bcrypt.hashSync(passwd); - -User.update(q, { - $set: { - password: hash - } -}).then(() => { - console.log(`Password of ${user} is now '${passwd}'`); -}, e => { - console.error(e); -}); diff --git a/cli/suspend.js b/cli/suspend.js deleted file mode 100644 index 877b37dbca..0000000000 --- a/cli/suspend.js +++ /dev/null @@ -1,23 +0,0 @@ -const mongo = require('mongodb'); -const User = require('../built/models/user').default; - -const args = process.argv.slice(2); - -const user = args[0]; - -const q = user.startsWith('@') ? { - username: user.split('@')[1], - host: user.split('@')[2] || null -} : { _id: new mongo.ObjectID(user) }; - -console.log(`Suspending ${user}...`); - -User.update(q, { - $set: { - isSuspended: true - } -}).then(() => { - console.log(`Suspended ${user}`); -}, e => { - console.error(e); -}); diff --git a/docs/backup.fr.md b/docs/backup.fr.md new file mode 100644 index 0000000000..19e99068ce --- /dev/null +++ b/docs/backup.fr.md @@ -0,0 +1,22 @@ +Comment faire une sauvegarde de votre Misskey ? +========================== + +Assurez-vous d'avoir installé **mongodb-tools**. + +--- + +Dans votre terminal : +``` shell +$ mongodump --archive=db-backup -u <VotreNomdUtilisateur> -p <VotreMotDePasse> +``` + +Pour plus de détails, merci de consulter [la documentation de mongodump](https://docs.mongodb.com/manual/reference/program/mongodump/). + +Restauration +------- + +``` shell +$ mongorestore --archive=db-backup +``` + +Pour plus de détails, merci de consulter [la documentation de mongorestore](https://docs.mongodb.com/manual/reference/program/mongorestore/). diff --git a/docs/backup.md b/docs/backup.md index 74ec2678e5..a69af0255b 100644 --- a/docs/backup.md +++ b/docs/backup.md @@ -10,7 +10,7 @@ In your shell: $ mongodump --archive=db-backup -u <YourUserName> -p <YourPassword> ``` -For details, plese see [mongodump docs](https://docs.mongodb.com/manual/reference/program/mongodump/). +For details, please see [mongodump docs](https://docs.mongodb.com/manual/reference/program/mongodump/). Restore ------- diff --git a/docs/docker.en.md b/docs/docker.en.md index d1ca144f23..f0fcdb66d5 100644 --- a/docs/docker.en.md +++ b/docs/docker.en.md @@ -17,7 +17,7 @@ This guide describes how to install and setup Misskey with Docker. ---------------------------------------------------------------- 1. `cp .config/example.yml .config/default.yml` Copy the `.config/example.yml` and rename it to `default.yml`. 2. `cp .config/mongo_initdb_example.js .config/mongo_initdb.js` Copy the `.config/mongo_initdb_example.js` and rename it to `mongo_initdb.js`. -2. Edit `default.yml` and `mongo_initdb.js`. +3. Edit `default.yml` and `mongo_initdb.js`. *3.* Configure Docker ---------------------------------------------------------------- diff --git a/docs/docker.fr.md b/docs/docker.fr.md new file mode 100644 index 0000000000..8f7e9f4294 --- /dev/null +++ b/docs/docker.fr.md @@ -0,0 +1,67 @@ +Guide Docker +================================================================ + +Ce guide explique comment installer et configurer Misskey avec Docker. + +[Version japonaise également disponible - Japanese version also available - 日本語版もあります](./docker.ja.md) +[Version anglaise également disponible - English version also available - 英語版もあります](./docker.en.md) + +---------------------------------------------------------------- + +*1.* Télécharger Misskey +---------------------------------------------------------------- +1. `git clone -b master git://github.com/syuilo/misskey.git` Clone le dépôt de Misskey sur la branche master. +2. `cd misskey` Naviguez dans le dossier du dépôt. +3. `git checkout $(git tag -l | grep -v 'rc[0-9]*$' | sort -V | tail -n 1)` Checkout sur le tag de la [dernière version](https://github.com/syuilo/misskey/releases/latest). + +*2.* Configuration de Misskey +---------------------------------------------------------------- +1. `cp .config/example.yml .config/default.yml` Copiez le fichier `.config/example.yml` et renommez-le `default.yml`. +2. `cp .config/mongo_initdb_example.js .config/mongo_initdb.js` Copie le fichier `.config/mongo_initdb_example.js` et le renomme en `mongo_initdb.js`. +3. Editez `default.yml` et `mongo_initdb.js`. + +*3.* Configurer Docker +---------------------------------------------------------------- +Editez `docker-compose.yml`. + +*4.* Contruire Misskey +---------------------------------------------------------------- +Contruire l'image Docker avec: + +`docker-compose build` + +*5.* C'est tout ! +---------------------------------------------------------------- +Parfait, Vous avez un environnement prêt pour démarrer Misskey. + +### Lancer normalement +Utilisez la commande `docker-compose up -d`. GLHF! + +### How to update your Misskey server to the latest version +1. `git fetch` +2. `git stash` +3. `git checkout $(git tag -l | grep -v 'rc[0-9]*$' | sort -V | tail -n 1)` +4. `git stash pop` +5. `docker-compose build` +6. Consultez le [ChangeLog](../CHANGELOG.md) pour avoir les éventuelles informations de migration +7. `docker-compose stop && docker-compose up -d` + +### Comment exécuter des [commandes](manage.fr.md) +`docker-compose run --rm web node cli/mark-admin @example` + +### Configuration d'ElasticSearch (pour la fonction de recherche) +*1.* Préparation de l'environnement +---------------------------------------------------------------- +1. `mkdir elasticsearch && chown 1000:1000 elasticsearch` Permet de créer le dossier d'accueil de la base ElasticSearch aves les bons droits +2. `sysctl -w vm.max_map_count=262144` Augmente la valeur max du paramètre map_count du système (valeur minimum pour pouvoir lancer ES) + +*2.* Après lancement du docker-compose, initialisation de la base ElasticSearch +---------------------------------------------------------------- +1. `docker-compose -it web /bin/sh` Connexion dans le conteneur web +2. `apk add curl` Ajout du paquet curl +3. `curl -X PUT "es:9200/misskey" -H 'Content-Type: application/json' -d'{ "settings" : { "index" : { } }}'` Création de la base ES +4. `exit` + +---------------------------------------------------------------- + +Si vous avez des questions ou des problèmes, n'hésitez pas à nous contacter ! diff --git a/docs/examples/misskey.nginx b/docs/examples/misskey.nginx new file mode 100644 index 0000000000..2b4a0548e6 --- /dev/null +++ b/docs/examples/misskey.nginx @@ -0,0 +1,70 @@ +# Sample nginx configuration for Misskey +# +# 1. Replace example.tld to your domain +# 2. Copy to /etc/nginx/sites-available/ and then symlink from /etc/nginx/sites-ebabled/ +# or copy to /etc/nginx/conf.d/ + +# For WebSocket +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=cache1:16m max_size=1g inactive=720m use_temp_path=off; + +server { + listen 80; + listen [::]:80; + server_name example.tld; + + # For SSL domain validation + root /var/www/html; + location /.well-known/acme-challenge/ { allow all; } + location /.well-known/pki-validation/ { allow all; } + location / { return 301 https://$server_name$request_uri; } +} + +server { + listen 443 http2; + listen [::]:443 http2; + server_name example.tld; + ssl on; + ssl_session_cache shared:ssl_session_cache:10m; + + # To use Let's Encrypt certificate + ssl_certificate /etc/letsencrypt/live/example.tld/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.tld/privkey.pem; + + # To use Debian/Ubuntu's self-signed certificate (For testing or before issuing a certificate) + #ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem; + #ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key; + + # SSL protocol settings + ssl_protocols TLSv1 TLSv1.2; + ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:AES128-SHA; + ssl_prefer_server_ciphers on; + + # Change to your upload limit + client_max_body_size 80m; + + # Proxy to Node + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_http_version 1.1; + proxy_redirect off; + + # For WebSocket + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Cache settings + proxy_cache cache1; + proxy_cache_lock on; + proxy_cache_use_stale updating; + add_header X-Cache $upstream_cache_status; + } +} diff --git a/docs/manage.en.md b/docs/manage.en.md index 713070517c..85c965a166 100644 --- a/docs/manage.en.md +++ b/docs/manage.en.md @@ -8,28 +8,11 @@ coming soon node cli/mark-admin (User-ID or Username) ``` -## Mark as 'verified' user -``` shell -node cli/mark-verified (User-ID or Username) -``` - -## Suspend users -``` shell -node cli/suspend (User-ID or Username) -``` e.g. ``` shell -# Use id -node cli/suspend 57d01a501fdf2d07be417afe +# By id +node cli/mark-admin 57d01a501fdf2d07be417afe -# Use username +# By username node cli/suspend @syuilo - -# Use username (remote) -node cli/suspend @syuilo@misskey.xyz -``` - -## Reset password -``` shell -node cli/reset-password (User-ID or Username) ``` diff --git a/docs/manage.fr.md b/docs/manage.fr.md new file mode 100644 index 0000000000..bf38e5ed97 --- /dev/null +++ b/docs/manage.fr.md @@ -0,0 +1,18 @@ +# Guide d'administration + +## Vérifier le status de la file d'attente des taches +coming soon + +## Marquer un utilisateur en tant que 'admin' +``` shell +node cli/mark-admin (ID utilisateur ou nom d'utilisateur) +``` + +Exemple : +``` shell +# Par id +node cli/mark-admin 57d01a501fdf2d07be417afe + +# Par nom d'utilisateur +node cli/suspend @syuilo +``` diff --git a/docs/manage.ja.md b/docs/manage.ja.md index 897fae7ec2..4a9a3e261f 100644 --- a/docs/manage.ja.md +++ b/docs/manage.ja.md @@ -8,28 +8,11 @@ coming soon node cli/mark-admin (ユーザーID または ユーザー名) ``` -## 'verified'ユーザーを設定する -``` shell -node cli/mark-verified (ユーザーID または ユーザー名) -``` - -## ユーザーを凍結する -``` shell -node cli/suspend (ユーザーID または ユーザー名) -``` 例: ``` shell # ユーザーID -node cli/suspend 57d01a501fdf2d07be417afe +node cli/mark-admin 57d01a501fdf2d07be417afe # ユーザー名 -node cli/suspend @syuilo - -# ユーザー名 (リモート) -node cli/suspend @syuilo@misskey.xyz -``` - -## ユーザーのパスワードをリセットする -``` shell -node cli/reset-password (ユーザーID または ユーザー名) +node cli/mark-admin @syuilo ``` diff --git a/docs/setup.en.md b/docs/setup.en.md index 4b8ea45e94..e8e1950636 100644 --- a/docs/setup.en.md +++ b/docs/setup.en.md @@ -47,16 +47,6 @@ As root: 4. `git checkout $(git tag -l | grep -v 'rc[0-9]*$' | sort -V | tail -n 1)` Checkout to the [latest release](https://github.com/syuilo/misskey/releases/latest) 5. `npm install` Install misskey dependencies. -*(optional)* Generate VAPID keys ----------------------------------------------------------------- -If you want to enable ServiceWorker, you need to generate VAPID keys: -Unless you have set your global node_modules location elsewhere, you need to run this as root. - -``` shell -npm install web-push -g -web-push generate-vapid-keys -``` - *5.* Configure Misskey ---------------------------------------------------------------- 1. `cp .config/example.yml .config/default.yml` Copy the `.config/example.yml` and rename it to `default.yml`. @@ -120,6 +110,8 @@ You can check if the service is running with `systemctl status misskey`. 3. `npm install` 4. `npm run build` 5. Check [ChangeLog](../CHANGELOG.md) for migration information +6. Restart your Misskey process to apply changes +7. Enjoy ---------------------------------------------------------------- diff --git a/docs/setup.fr.md b/docs/setup.fr.md index 0a0c8e9281..80dca855c1 100644 --- a/docs/setup.fr.md +++ b/docs/setup.fr.md @@ -10,8 +10,8 @@ Ce guide décrit les étapes à suivre afin d'installer et de configurer une ins *1.* Création de l'utilisateur Misskey ---------------------------------------------------------------- -Lancer misskey en tant qu'utilisateur est une mauvaise idée, nous avons besoin de créer un utilisateur dédié. -Sur Debian, à titre d'exemple : +Executer misskey en tant que super-utilisateur étant une mauvaise idée, nous allons créer un utilisateur dédié. +Sous Debian, par exemple : ``` adduser --disabled-password --disabled-login misskey @@ -32,10 +32,10 @@ Installez les paquets suivants : *3.* Paramètrage de MongoDB ---------------------------------------------------------------- -En mode root : -1. `mongo` Accédez au shell de mango +En root : +1. `mongo` Ouvrez le shell mongo 2. `use misskey` Utilisez la base de données misskey -3. `db.users.save( {dummy:"dummy"} )` Write dummy data to initialize the db. +3. `db.users.save( {dummy:"dummy"} )` Écrivez une donnée factice pour initialiser la base de données. 4. `db.createUser( { user: "misskey", pwd: "<password>", roles: [ { role: "readWrite", db: "misskey" } ] } )` Créez l'utilisateur misskey. 5. `exit` Vous avez terminé ! @@ -44,22 +44,12 @@ En mode root : 1. `su - misskey` Basculez vers l'utilisateur misskey. 2. `git clone -b master git://github.com/syuilo/misskey.git` Clonez la branche master du dépôt misskey. 3. `cd misskey` Accédez au dossier misskey. -4. `git checkout $(git tag -l | grep -v 'rc[0-9]*$' | sort -V | tail -n 1)` Télécharge la [version la plus récente](https://github.com/syuilo/misskey/releases/latest) +4. `git checkout $(git tag -l | grep -v 'rc[0-9]*$' | sort -V | tail -n 1)` Checkout sur le tag de la [version la plus récente](https://github.com/syuilo/misskey/releases/latest) 5. `npm install` Installez les dépendances de misskey. -*(optionnel)* Génération des clés VAPID ----------------------------------------------------------------- -Si vous désirez activer ServiceWorker, vous devez générer les clés VAPID : -Unless you have set your global node_modules location elsewhere, vous devez lancer ceci en mode root. - -``` shell -npm install web-push -g -web-push generate-vapid-keys -``` - *5.* Création du fichier de configuration ---------------------------------------------------------------- -1. `cp .config/example.yml .config/default.yml` Copiez le fichier `.config/example.yml` et renommez-le `default.yml`. +1. `cp .config/example.yml .config/default.yml` Copiez le fichier `.config/example.yml` et renommez-le`default.yml`. 2. Editez le fichier `default.yml` *6.* Construction de Misskey @@ -69,7 +59,7 @@ Construisez Misskey comme ceci : `npm run build` -Si vous êtes sous Debian, vous serez amené à installer les paquets `build-essential`, `python`. +Si vous êtes sous Debian, vous serez amené à installer les paquets `build-essential` et `python`. Si vous rencontrez des erreurs concernant certains modules, utilisez node-gyp: @@ -87,7 +77,7 @@ Lancez tout simplement `npm start`. Bonne chance et amusez-vous bien ! ### Démarrage avec systemd -1. Créez une service systemd sur : `/etc/systemd/system/misskey.service` +1. Créez un service systemd sur : `/etc/systemd/system/misskey.service` 2. Editez-le puis copiez et coller ceci dans le fichier : ``` diff --git a/docs/setup.ja.md b/docs/setup.ja.md index 79be1fb881..9ac0931280 100644 --- a/docs/setup.ja.md +++ b/docs/setup.ja.md @@ -53,15 +53,6 @@ adduser --disabled-password --disabled-login misskey 4. `git checkout $(git tag -l | grep -v 'rc[0-9]*$' | sort -V | tail -n 1)` [最新のリリース](https://github.com/syuilo/misskey/releases/latest)を確認 5. `npm install` Misskeyの依存パッケージをインストール -*(オプション)* VAPIDキーペアの生成 ----------------------------------------------------------------- -ServiceWorkerを有効にする場合、VAPIDキーペアを生成する必要があります: - -``` shell -npm install web-push -g -web-push generate-vapid-keys -``` - *5.* 設定ファイルを作成する ---------------------------------------------------------------- 1. `cp .config/example.yml .config/default.yml` `.config/example.yml`をコピーし名前を`default.yml`にする。 diff --git a/gulpfile.ts b/gulpfile.ts index 7aa582abf9..08175914a1 100644 --- a/gulpfile.ts +++ b/gulpfile.ts @@ -11,14 +11,12 @@ import tslint from 'gulp-tslint'; const cssnano = require('gulp-cssnano'); const stylus = require('gulp-stylus'); import * as uglifyComposer from 'gulp-uglify/composer'; -import pug = require('gulp-pug'); import * as rimraf from 'rimraf'; import chalk from 'chalk'; const imagemin = require('gulp-imagemin'); import * as rename from 'gulp-rename'; import * as mocha from 'gulp-mocha'; import * as replace from 'gulp-replace'; -import * as htmlmin from 'gulp-htmlmin'; const uglifyes = require('uglify-es'); const locales = require('./locales'); @@ -34,8 +32,6 @@ if (isDebug) { console.warn(chalk.yellow.bold(' built script will not be compressed.')); } -const constants = require('./src/const.json'); - gulp.task('build', [ 'build:ts', 'build:copy', @@ -109,7 +105,7 @@ gulp.task('default', ['build']); gulp.task('build:client', [ 'build:ts', 'build:client:script', - 'build:client:pug', + 'build:client:styles', 'copy:client' ]); @@ -148,52 +144,6 @@ gulp.task('copy:client', [ .pipe(gulp.dest('./built/client/assets/')) ); -gulp.task('build:client:pug', [ - 'copy:client', - 'build:client:script', - 'build:client:styles' -], () => - gulp.src('./src/client/app/base.pug') - .pipe(pug({ - locals: { - themeColor: constants.themeColor - } - })) - .pipe(htmlmin({ - // 真理値属性の簡略化 e.g. - // <input value="foo" readonly="readonly"> to - // <input value="foo" readonly> - collapseBooleanAttributes: true, - - // テキストの一部かもしれない空白も削除する e.g. - // <div> <p> foo </p> </div> to - // <div><p>foo</p></div> - collapseWhitespace: true, - - // タグ間の改行を保持する - preserveLineBreaks: true, - - // (できる場合は)属性のクォーテーション削除する e.g. - // <p class="foo-bar" id="moo" title="blah blah">foo</p> to - // <p class=foo-bar id=moo title="blah blah">foo</p> - removeAttributeQuotes: true, - - // 省略可能なタグを省略する e.g. - // <html><p>yo</p></html> ro - // <p>yo</p> - removeOptionalTags: true, - - // 属性の値がデフォルトと同じなら省略する e.g. - // <input type="text"> to - // <input> - removeRedundantAttributes: true, - - // CSSも圧縮する - minifyCSS: true - })) - .pipe(gulp.dest('./built/client/app/')) -); - gulp.task('locales', () => gulp.src('./locales/*.yml') .pipe(yaml({ schema: 'DEFAULT_SAFE_SCHEMA' })) diff --git a/locales/en-US.yml b/locales/en-US.yml index 9b597e52b8..a22491815e 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1010,6 +1010,7 @@ admin/views/instance.vue: instance-name: "Instance name" instance-description: "Instance description" host: "Host" + logo-url: "Logo image URL" banner-url: "Banner image URL" error-image-url: "Error image URL" languages: "Language of this instance" diff --git a/locales/index.js b/locales/index.js index f8b836759c..96a1af38b1 100644 --- a/locales/index.js +++ b/locales/index.js @@ -5,7 +5,7 @@ const fs = require('fs'); const yaml = require('js-yaml'); -const langs = ['de-DE', 'en-US', 'fr-FR', 'ja-JP', 'ja-KS', 'pl-PL', 'es-ES', 'nl-NL', 'zh-CN']; +const langs = ['de-DE', 'en-US', 'fr-FR', 'ja-JP', 'ja-KS', 'pl-PL', 'es-ES', 'nl-NL', 'zh-CN', 'ko-KR']; const loadLocale = lang => yaml.safeLoad(fs.readFileSync(`${__dirname}/${lang}.yml`, 'utf-8')); const locales = langs.map(lang => ({ [lang]: loadLocale(lang) })); diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 8866f60e2d..5ab57da71e 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -26,6 +26,8 @@ common: close: "閉じる" do-not-copy-paste: "ここにコードを入力したり張り付けたりしないでください。アカウントが不正利用される可能性があります。" load-more: "もっと読み込む" + enter-password: "パスワードを入力してください" + 2fa: "二段階認証" got-it: "わかった" customization-tips: @@ -95,7 +97,6 @@ common: followers-desc: "自分のフォロワーにのみ公開" specified: "ダイレクト" specified-desc: "指定したユーザーにのみ公開" - private: "非公開" local-public: "公開 (ローカルのみ)" local-home: "ホーム (ローカルのみ)" local-followers: "フォロワー (ローカルのみ)" @@ -117,9 +118,10 @@ common: my-token-regenerated: "あなたのトークンが更新されたのでサインアウトします。" i-like-sushi: "私は(プリンよりむしろ)寿司が好き" show-reversi-board-labels: "リバーシのボードの行と列のラベルを表示" - use-contrast-reversi-stones: "リバーシのアイコンにコントラストを付ける" + use-avatar-reversi-stones: "リバーシの石にアバターを使う" verified-user: "公式アカウント" disable-animated-mfm: "投稿内の動きのあるテキストを無効にする" + suggest-recent-hashtags: "最近のハッシュタグを投稿フォームに表示する" always-show-nsfw: "常に閲覧注意のメディアを表示する" always-mark-nsfw: "常にメディアを閲覧注意として投稿" show-full-acct: "ユーザー名のホストを省略しない" @@ -127,16 +129,23 @@ common: reduce-motion: "UIの動きを減らす" this-setting-is-this-device-only: "このデバイスのみ" use-os-default-emojis: "OS標準の絵文字を使用" + line-width: "線の太さ" + line-width-thin: "細い" + line-width-normal: "普通" + line-width-thick: "太い" + hide-password: "パスワードを隠す" + show-password: "パスワードを表示する" - do-not-use-in-production: 'これは開発ビルドです。本番環境で使用しないでください。' - + do-not-use-in-production: "これは開発ビルドです。本番環境で使用しないでください。" + user-suspended: "このユーザーは凍結されています。" is-remote-user: "このユーザー情報はコピーです。" is-remote-post: "この投稿情報はコピーです。" view-on-remote: "正確な情報を見る" + renoted-by: "{user}がRenote" error: - title: '問題が発生しました' - retry: 'やり直す' + title: "問題が発生しました" + retry: "やり直す" reversi: drawn: "引き分け" @@ -169,13 +178,13 @@ common: polls: "アンケート" post-form: "投稿フォーム" server: "サーバー情報" - donation: "寄付のお願い" nav: "ナビゲーション" tips: "ヒント" hashtags: "ハッシュタグ" dev: "アプリの作成に失敗しました。再度お試しください。" ai-chan-kawaii: "藍ちゃかわいい" + you: "あなた" auth/views/form.vue: share-access: "<i>{name}</i>があなたのアカウントにアクセスすることを許可しますか?" @@ -281,6 +290,7 @@ common/views/components/media-banner.vue: click-to-show: "クリックして表示" common/views/components/theme.vue: + theme: "テーマ" light-theme: "非ダークモード時に使用するテーマ" dark-theme: "ダークモード時に使用するテーマ" light-themes: "明るいテーマ" @@ -297,6 +307,7 @@ common/views/components/theme.vue: base-theme: "ベーステーマ" base-theme-light: "Light" base-theme-dark: "Dark" + find-more-theme: "その他のテーマを入手" theme-name: "テーマ名" preview-created-theme: "プレビュー" invalid-theme: "テーマが正しくありません。" @@ -319,6 +330,9 @@ common/views/components/theme.vue: common/views/components/cw-button.vue: hide: "隠す" show: "もっと見る" + chars: "{count}文字" + files: "{count}ファイル" + poll: "アンケート" common/views/components/messaging.vue: search-user: "ユーザーを探す" @@ -354,7 +368,9 @@ common/views/components/nav.vue: feedback: "フィードバック" common/views/components/note-menu.vue: + mention: "メンション" detail: "詳細" + copy-content: "内容をコピー" copy-link: "リンクをコピー" favorite: "お気に入り" unfavorite: "お気に入り解除" @@ -364,6 +380,18 @@ common/views/components/note-menu.vue: delete-confirm: "この投稿を削除しますか?" remote: "投稿元で見る" +common/views/components/user-menu.vue: + mention: "メンション" + mute: "ミュート" + unmute: "ミュート解除" + block: "ブロック" + unblock: "ブロック解除" + push-to-list: "リストに追加" + select-list: "リストを選択してください" + report-abuse: "スパムを報告" + report-abuse-detail: "どのような迷惑行為を行っていますか?" + report-abuse-reported: "管理者に報告されました。ご協力ありがとうございました。" + common/views/components/poll.vue: vote-to: "「{}」に投票する" vote-count: "{}票" @@ -438,13 +466,19 @@ common/views/components/stream-indicator.vue: reconnecting: "再接続中" connected: "接続完了" -common/views/components/twitter-setting.vue: - description: "お使いのTwitterアカウントをお使いのMisskeyアカウントに接続しておくと、プロフィールでTwitterアカウント情報が表示されるようになったり、Twitterを用いた便利なサインインを利用できるようになります。" - connected-to: "次のTwitterアカウントに接続されています" - detail: "詳細..." - reconnect: "再接続する" - connect: "Twitterと接続する" +common/views/components/notification-settings.vue: + title: "通知" + mark-as-read-all-notifications: "すべての通知を既読にする" + mark-as-read-all-unread-notes: "すべての投稿を既読にする" + mark-as-read-all-talk-messages: "すべてのトークを既読にする" + auto-watch: "投稿の自動ウォッチ" + auto-watch-desc: "リアクションしたり返信したりした投稿に関する通知を自動的に受け取るようにします。" + +common/views/components/integration-settings.vue: + title: "サービス連携" + connect: "接続する" disconnect: "切断する" + connected-to: "次のアカウントに接続されています" common/views/components/github-setting.vue: description: "お使いのGitHubアカウントをお使いのMisskeyアカウントに接続しておくと、プロフィールでGitHubアカウント情報が表示されるようになったり、GitHubを用いた便利なサインインを利用できるようになります。" @@ -473,7 +507,6 @@ common/views/components/visibility-chooser.vue: followers-desc: "自分のフォロワーにのみ公開" specified: "ダイレクト" specified-desc: "指定したユーザーにのみ公開" - private: "非公開" local-public: "公開 (ローカルのみ)" local-public-desc: "リモートへは公開しない" local-home: "ホーム (ローカルのみ)" @@ -483,12 +516,21 @@ common/views/components/trends.vue: count: "{}人が投稿" empty: "トレンドなし" +common/views/components/language-settings.vue: + title: "表示言語" + pick-language: "言語を選択" + recommended: "推奨" + auto: "自動" + specify-language: "言語を指定" + info: "変更はページの再度読み込み後に反映されます。" + common/views/components/profile-editor.vue: title: "プロフィール" name: "名前" account: "アカウント" location: "場所" description: "自己紹介" + language: "言語" birthday: "誕生日" avatar: "アイコン" banner: "バナー" @@ -496,12 +538,25 @@ common/views/components/profile-editor.vue: is-bot: "このアカウントはBotです" is-locked: "フォローを承認制にする" careful-bot: "Botからのフォローだけ承認制にする" + auto-accept-followed: "フォローしているユーザーからのフォローを自動承認する" advanced: "その他" privacy: "プライバシー" save: "保存" saved: "プロフィールを保存しました" uploading: "アップロード中" upload-failed: "アップロードに失敗しました" + email: "メール設定" + email-address: "メールアドレス" + email-verified: "メールアドレスが確認されました" + email-not-verified: "メールアドレスが確認されていません。メールボックスをご確認ください。" + +common/views/components/user-list-editor.vue: + users: "ユーザー" + rename: "リスト名を変更" + delete: "リストを削除" + remove-user: "このリストから削除" + delete-are-you-sure: "リスト「$1」を削除しますか?" + deleted: "削除しました" common/views/widgets/broadcast.vue: fetching: "確認中" @@ -517,10 +572,6 @@ common/views/widgets/calendar.vue: this-month: "今月:" this-year: "今年:" -common/views/widgets/donation.vue: - title: "寄付のお願い" - text: "Misskeyの運営にはドメイン、サーバー等のコストが掛かります。Misskeyは広告を掲載したりしないため、収入を皆様からの寄付に頼っています。もしご興味があれば、{}までご連絡ください。ご協力ありがとうございます。" - common/views/widgets/photo-stream.vue: title: "フォトストリーム" no-photos: "写真はありません" @@ -564,10 +615,13 @@ common/views/widgets/tips.vue: tips-line19: "いくつかのウィンドウはブラウザの外に切り離すことができます" tips-line20: "カレンダーウィジェットのパーセンテージは、経過の割合を示しています" tips-line21: "APIを利用してbotの開発なども行えます" - tips-line23: "まゆかわいいよまゆ" + tips-line23: "藍かわいいよ藍" tips-line24: "Misskeyは2014年にサービスを開始しました" tips-line25: "対応ブラウザではMisskeyを開いていなくても通知を受け取れます" +common/views/pages/not-found.vue: + page-not-found: "ページが見つかりませんでした" + common/views/pages/follow.vue: signed-in-as: "{}としてサインイン中" following: "フォロー中" @@ -724,16 +778,16 @@ desktop/views/components/messaging-window.vue: desktop/views/components/note-detail.vue: private: "この投稿は非公開です" deleted: "この投稿は削除されました" - reposted-by: "{}がRenote" location: "位置情報" renote: "Renote" add-reaction: "リアクション" + undo-reaction: "リアクション解除" desktop/views/components/note.vue: - reposted-by: "{}がRenote" reply: "返信" renote: "Renote" add-reaction: "リアクション" + undo-reaction: "リアクション解除" detail: "詳細" private: "この投稿は非公開です" deleted: "この投稿は削除されました" @@ -814,7 +868,6 @@ desktop/views/components/settings.vue: security: "セキュリティ" signin: "サインイン履歴" password: "パスワード" - 2fa: "二段階認証" other: "その他" license: "ライセンス" theme: "テーマ" @@ -825,11 +878,10 @@ desktop/views/components/settings.vue: note-visibility: "投稿の公開範囲" default-note-visibility: "デフォルトの公開範囲" remember-note-visibility: "投稿の公開範囲を記憶する" + web-search-engine: "ウェブ検索エンジン" + web-search-engine-desc: "例: https://www.google.com/?#q={{query}}" auto-popout: "ウィンドウの自動ポップアウト" auto-popout-desc: "ウィンドウが開かれるとき、ポップアウト(ブラウザ外に切り離す)可能なら自動でポップアウトします。この設定はブラウザに記憶されます。" - advanced: "詳細設定" - api-via-stream: "ストリームを経由したAPIリクエスト" - api-via-stream-desc: "この設定をオンにすると、websocket接続を経由してAPIリクエストが行われます(パフォーマンス向上が期待できます)。オフにすると、ネイティブの fetch APIが利用されます。この設定はこのデバイスのみ有効です。" deck-nav: "デッキ内ナビゲーション" deck-nav-desc: "デッキを使用しているとき、ナビゲーションが発生する際にページ遷移を行わずに一時的なカラムで受けるようにします。" deck-default: "デッキをデフォルトのUIにする" @@ -845,7 +897,6 @@ desktop/views/components/settings.vue: circle-icons: "円形のアイコンを使用" contrasted-acct: "ユーザー名にコントラストを付ける" post-form-on-timeline: "タイムライン上部に投稿フォームを表示する" - suggest-recent-hashtags: "最近のハッシュタグを投稿フォームに表示する" show-clock-on-header: "右上に時計を表示する" show-reply-target: "リプライ先を表示する" timeline: "タイムライン" @@ -854,9 +905,16 @@ desktop/views/components/settings.vue: show-local-renotes: "ローカルの投稿のRenoteをタイムラインに表示する" show-maps: "マップの自動展開" remain-deleted-note: "削除された投稿を表示し続ける" - deck-column-align: "デッキのカラムの位置" + deck-column-align: "デッキのカラムの配置" deck-column-align-center: "中央" deck-column-align-left: "左" + deck-column-align-flexible: "フレキシブル" + deck-column-width: "デッキのカラムの幅" + deck-column-width-narrow: "狭" + deck-column-width-narrower: "やや狭" + deck-column-width-normal: "普通" + deck-column-width-wider: "やや広" + deck-column-width-wide: "広" sound: "サウンド" enable-sounds: "サウンドを有効にする" @@ -864,22 +922,12 @@ desktop/views/components/settings.vue: volume: "ボリューム" test: "テスト" - language: "言語" - pick-language: "言語を選択" - recommended: "推奨" - auto: "自動" - specify-language: "言語を指定" - language-desc: "変更はページの再度読み込み後に反映されます。" - cache: "キャッシュ" clean-cache: "クリーンアップ" cache-warn: "クリーンアップを行うと、ブラウザに記憶されたアカウント情報のキャッシュ、書きかけの投稿・返信・メッセージ、およびその他のデータ(設定情報含む)が削除されます。クリーンアップを行った後はページを再度読み込みする必要があります。" cache-cleared: "キャッシュを削除しました" cache-cleared-desc: "ページを再度読み込みしてください。" - auto-watch: "投稿の自動ウォッチ" - auto-watch-desc: "リアクションしたり返信したりした投稿に関する通知を自動的に受け取るようにします。" - about: "Misskeyについて" operator: "このサーバーの運営者" @@ -922,6 +970,7 @@ desktop/views/components/settings.2fa.vue: enter-password: "パスワードを入力してください" authenticator: "まず、Google Authenticatorをお使いのデバイスにインストールします:" howtoinstall: "インストール方法はこちら" + token: "トークン" scan: "次に、表示されているQRコードをスキャンします:" done: "お使いのデバイスに表示されているトークンを入力して完了します:" submit: "完了" @@ -937,13 +986,13 @@ common/views/components/api-settings.vue: token: "Token:" enter-password: "パスワードを入力してください" console: - title: 'APIコンソール' - endpoint: 'エンドポイント' - parameter: 'パラメータ' + title: "APIコンソール" + endpoint: "エンドポイント" + parameter: "パラメータ" credential-info: "「i」パラメータは自動で付与されます。" - send: '送信' - sending: '応答待ち' - response: '結果' + send: "送信" + sending: "応答待ち" + response: "結果" desktop/views/components/settings.apps.vue: no-apps: "連携しているアプリケーションはありません" @@ -971,6 +1020,7 @@ common/views/components/password-settings.vue: enter-new-password-again: "もう一度新しいパスワードを入力してください" not-match: "新しいパスワードが一致しません" changed: "パスワードを変更しました" + failed: "パスワード変更に失敗しました" desktop/views/components/sub-note-content.vue: private: "この投稿は非公開です" @@ -978,6 +1028,12 @@ desktop/views/components/sub-note-content.vue: media-count: "{}つのメディア" poll: "アンケート" +desktop/views/components/settings.tags.vue: + title: "タグ" + query: "クエリ (省略可)" + add: "追加" + save: "保存" + desktop/views/components/taskmanager.vue: title: "タスクマネージャ" @@ -1059,6 +1115,7 @@ admin/views/index.vue: federation: "連合" announcements: "お知らせ" hashtags: "ハッシュタグ" + abuse: "スパム報告" back-to-misskey: "Misskeyに戻る" admin/views/dashboard.vue: @@ -1070,12 +1127,20 @@ admin/views/dashboard.vue: this-instance: "このインスタンス" federated: "連合" +admin/views/abuse.vue: + title: "スパム報告" + target: "対象" + reporter: "報告者" + details: "詳細" + remove-report: "削除" + admin/views/instance.vue: instance: "インスタンス" instance-name: "インスタンス名" instance-description: "インスタンスの紹介" host: "ホスト" banner-url: "バナー画像URL" + error-image-url: "エラー画像URL" languages: "インスタンスの対象言語" languages-desc: "スペースで区切って複数設定できます。" maintainer-config: "管理者情報" @@ -1093,17 +1158,17 @@ admin/views/instance.vue: recaptcha-site-key: "reCAPTCHA site key" recaptcha-secret-key: "reCAPTCHA secret key" twitter-integration-config: "Twitter連携の設定" - twitter-integration-info: "コールバックURLは /api/tw/cb に設定します。" + twitter-integration-info: "コールバックURLは {url} に設定します。" enable-twitter-integration: "Twitter連携を有効にする" twitter-integration-consumer-key: "Consumer key" twitter-integration-consumer-secret: "Consumer secret" github-integration-config: "GitHub連携の設定" - github-integration-info: "コールバックURLは /api/gh/cb に設定します。" + github-integration-info: "コールバックURLは {url} に設定します。" enable-github-integration: "GitHub連携を有効にする" github-integration-client-id: "Client ID" github-integration-client-secret: "Client Secret" discord-integration-config: "Discord連携の設定" - discord-integration-info: "コールバックURLは /api/dc/cb に設定します。" + discord-integration-info: "コールバックURLは {url} に設定します。" enable-discord-integration: "Discord連携を有効にする" discord-integration-client-id: "Client ID" discord-integration-client-secret: "Client Secret" @@ -1115,9 +1180,33 @@ admin/views/instance.vue: max-note-text-length: "投稿の最大文字数" disable-registration: "ユーザー登録の受付を停止する" disable-local-timeline: "ローカルタイムラインを無効にする" + disable-global-timeline: "グローバルタイムラインを無効にする" + disabling-timelines-info: "これらのタイムラインを無効にしても、管理者およびモデレーターは引き続き利用できます。" invite: "招待" save: "保存" saved: "保存しました" + user-recommendation-config: "おすすめユーザー" + enable-external-user-recommendation: "外部ユーザーレコメンデーションを有効にする" + external-user-recommendation-engine: "エンジン" + external-user-recommendation-engine-desc: "例: https://vinayaka.distsn.org/cgi-bin/vinayaka-user-match-misskey-api.cgi?{{host}}+{{user}}+{{limit}}+{{offset}}" + external-user-recommendation-timeout: "タイムアウト" + external-user-recommendation-timeout-desc: "ミリ秒単位 (例: 300000)" + email-config: "メールサーバーの設定" + email-config-info: "メールアドレス確認やパスワードリセットの際に使われます。" + enable-email: "メール配信を有効にする" + email: "メールアドレス" + smtp-secure: "SMTP接続に暗黙的なSSL/TLSを使用する" + smtp-secure-info: "STARTTLS使用時はオフにします。" + smtp-host: "SMTPホスト" + smtp-port: "SMTPポート" + smtp-user: "SMTPユーザー" + smtp-pass: "SMTPパスワード" + serviceworker-config: "ServiceWorker" + enable-serviceworker: "ServiceWorkerを有効にする" + serviceworker-info: "プッシュ通知を行うには有効する必要があります。" + vapid-publickey: "VAPID公開鍵" + vapid-privatekey: "VAPID秘密鍵" + vapid-info: "ServiceWorkerを有効にする場合、VAPIDキーペアを生成する必要があります。シェルで次のようにします:" admin/views/charts.vue: title: "チャート" @@ -1137,6 +1226,7 @@ admin/views/charts.vue: notes-total: "投稿の積算" users: "ユーザーの増減" users-total: "ユーザーの積算" + active-users: "アクティブユーザー数" drive: "ドライブ使用量の増減" drive-total: "ドライブ使用量の積算" drive-files: "ドライブのファイル数の増減" @@ -1145,25 +1235,75 @@ admin/views/charts.vue: network-time: "応答時間" network-usage: "通信量" +admin/views/drive.vue: + operation: "操作" + fileid-or-url: "ファイルIDまたはファイルURL" + file-not-found: "ファイルが見つかりません" + lookup: "照会" + sort: + title: "ソート" + createdAtAsc: "アップロード日時が古い順" + createdAtDesc: "アップロード日時が新しい順" + sizeAsc: "サイズが小さい順" + sizeDesc: "サイズが大きい順" + origin: + title: "オリジン" + combined: "ローカル+リモート" + local: "ローカル" + remote: "リモート" + delete: "削除" + deleted: "削除しました" + mark-as-sensitive: "閲覧注意に設定" + unmark-as-sensitive: "閲覧注意を解除" + marked-as-sensitive: "閲覧注意に設定しました" + unmarked-as-sensitive: "閲覧注意を解除しました" + admin/views/users.vue: - suspend-user: "ユーザーの凍結" + operation: "操作" + username-or-userid: "ユーザー名またはユーザーID" + user-not-found: "ユーザーが見つかりません" + lookup: "照会" + reset-password: "パスワードをリセット" + password-updated: "パスワードは現在「{password}」です" suspend: "凍結" suspended: "凍結しました" - unsuspend-user: "ユーザーの凍結の解除" unsuspend: "凍結の解除" unsuspended: "凍結を解除しました" - verify-user: "ユーザーの公式アカウント設定" verify: "公式アカウントにする" verified: "公式アカウントにしました" - unverify-user: "ユーザーの公式アカウント解除" unverify: "公式アカウントを解除する" unverified: "公式アカウントを解除しました" + users: + title: "ユーザー" + sort: + title: "ソート" + createdAtAsc: "登録日時が古い順" + createdAtDesc: "登録日時が新しい順" + updatedAtAsc: "更新日時が古い順" + updatedAtDesc: "更新日時が新しい順" + state: + title: "状態" + all: "すべて" + admin: "管理者" + moderator: "モデレーター" + adminOrModerator: "管理者+モデレーター" + verified: "公式アカウント" + suspended: "凍結済み" + origin: + title: "オリジン" + combined: "ローカル+リモート" + local: "ローカル" + remote: "リモート" + createdAt: "登録日時" + updatedAt: "更新日時" admin/views/moderators.vue: add-moderator: title: "モデレーターの登録" add: "登録" added: "モデレーターを登録しました" + remove: "解除" + removed: "モデレーター登録を解除しました" admin/views/emoji.vue: add-emoji: @@ -1261,17 +1401,7 @@ desktop/views/pages/user/user.photos.vue: desktop/views/pages/user/user.profile.vue: follows-you: "フォローされています" - stalk: "ストークする" - stalking: "ストーキングしています" - unstalk: "ストーク解除" - mute: "ミュートする" - muted: "ミュートしています" - unmute: "ミュート解除" - block: "ブロックする" - unblock: "ブロック解除" - block-confirm: "このユーザーをブロックしますか?" - push-to-a-list: "リストに追加" - list-pushed: "{user}を{list}に追加しました。" + menu: "メニュー" desktop/views/pages/user/user.header.vue: posts: "投稿" @@ -1287,6 +1417,7 @@ desktop/views/pages/user/user.timeline.vue: default: "投稿" with-replies: "投稿と返信" with-media: "メディア" + my-posts: "私の投稿" empty: "このユーザーはまだ何も投稿していないようです。" desktop/views/widgets/messaging.vue: @@ -1376,7 +1507,6 @@ mobile/views/components/friends-maker.vue: close: "閉じる" mobile/views/components/note.vue: - reposted-by: "{}がRenote" private: "この投稿は非公開です" deleted: "この投稿は削除されました" location: "位置情報" @@ -1384,7 +1514,6 @@ mobile/views/components/note.vue: mobile/views/components/note-detail.vue: reply: "返信" reaction: "リアクション" - reposted-by: "{}がRenote" private: "この投稿は非公開です" deleted: "この投稿は削除されました" location: "位置情報" @@ -1517,11 +1646,6 @@ mobile/views/pages/selectdrive.vue: mobile/views/pages/settings.vue: signed-in-as: "{}としてサインイン中" - lang: "言語" - lang-tip: "変更はページの再読み込み後に反映されます。" - recommended: "推奨" - auto: "自動" - specify-language: "言語を指定" design: "デザインと表示" dark-mode: "ダークモード" i-am-under-limited-internet: "私は通信を制限されている" @@ -1538,27 +1662,16 @@ mobile/views/pages/settings.vue: notification-position: "通知の表示" notification-position-bottom: "下" notification-position-top: "上" - theme: "テーマ" behavior: "動作" fetch-on-scroll: "スクロールで自動読み込み" note-visibility: "投稿の公開範囲" default-note-visibility: "デフォルトの公開範囲" remember-note-visibility: "投稿の公開範囲を記憶する" + web-search-engine: "ウェブ検索エンジン" + web-search-engine-desc: "例: https://www.google.com/?#q={{query}}" disable-via-mobile: "「モバイルからの投稿」フラグを付けない" load-raw-images: "添付された画像を高画質で表示する" load-remote-media: "リモートサーバーのメディアを表示する" - twitter: "Twitter連携" - twitter-connect: "Twitterアカウントに接続する" - twitter-reconnect: "再接続する" - twitter-disconnect: "切断する" - github: "GitHub連携" - github-connect: "GitHubアカウントに接続する" - github-reconnect: "再接続する" - github-disconnect: "切断する" - discord: "Discord連携" - discord-connect: "Discordアカウントに接続する" - discord-reconnect: "再接続する" - discord-disconnect: "切断する" update: "Misskey Update" version: "バージョン:" latest-version: "最新のバージョン:" @@ -1572,7 +1685,6 @@ mobile/views/pages/settings.vue: signout: "サインアウト" sound: "サウンド" enable-sounds: "サウンドを有効にする" - mark-as-read-all-unread-notes: "すべての投稿を既読にする" password: "パスワード" mobile/views/pages/user.vue: @@ -1583,11 +1695,6 @@ mobile/views/pages/user.vue: overview: "概要" timeline: "タイムライン" media: "メディア" - is-suspended: "このユーザーは凍結されています。" - mute: "ミュート" - unmute: "ミュート解除" - block: "ブロック" - unblock: "ブロック解除" years-old: "{age}歳" mobile/views/pages/user/home.vue: @@ -1646,7 +1753,6 @@ deck/deck.user-column.vue: activity: "アクティビティ" timeline: "タイムライン" pinned-notes: "ピン留めされた投稿" - push-to-a-list: "リストに追加" docs: edit-this-page-on-github: "間違いや改善点を見つけましたか?" diff --git a/package.json b/package.json index 04105687c7..0400891f9a 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "misskey", "author": "syuilo <i@syuilo.com>", - "version": "10.55.0", - "clientVersion": "2.0.11909", + "version": "10.78.3", + "clientVersion": "2.0.13657", "codename": "nighthike", "main": "./built/index.js", "private": true, @@ -20,74 +20,76 @@ "format": "gulp format" }, "dependencies": { - "@fortawesome/fontawesome-svg-core": "1.2.8", - "@fortawesome/free-brands-svg-icons": "5.5.0", + "@fortawesome/fontawesome-svg-core": "1.2.12", + "@fortawesome/free-brands-svg-icons": "5.6.3", "@fortawesome/free-regular-svg-icons": "5.5.0", - "@fortawesome/free-solid-svg-icons": "5.5.0", - "@fortawesome/vue-fontawesome": "0.1.2", - "@koa/cors": "2.2.2", + "@fortawesome/free-solid-svg-icons": "5.6.3", + "@fortawesome/vue-fontawesome": "0.1.5", + "@koa/cors": "2.2.3", "@prezzemolo/rap": "0.1.2", "@prezzemolo/zip": "0.0.3", "@types/bcryptjs": "2.4.2", "@types/chai-http": "3.0.5", - "@types/dateformat": "1.0.1", + "@types/dateformat": "3.0.0", "@types/debug": "0.0.31", "@types/deep-equal": "1.0.1", "@types/double-ended-queue": "2.1.0", - "@types/elasticsearch": "5.0.28", - "@types/file-type": "5.2.1", + "@types/elasticsearch": "5.0.30", + "@types/file-type": "10.6.0", "@types/gulp": "3.8.36", - "@types/gulp-htmlmin": "1.3.32", "@types/gulp-mocha": "0.0.32", "@types/gulp-rename": "0.0.33", "@types/gulp-replace": "0.0.31", "@types/gulp-uglify": "3.0.6", "@types/gulp-util": "3.0.34", "@types/is-root": "1.0.0", + "@types/is-svg": "3.0.0", "@types/is-url": "1.2.28", - "@types/js-yaml": "3.11.2", + "@types/js-yaml": "3.11.4", "@types/katex": "0.5.0", - "@types/koa": "2.0.46", - "@types/koa-bodyparser": "5.0.1", + "@types/koa": "2.0.48", + "@types/koa-bodyparser": "5.0.2", "@types/koa-compress": "2.0.8", "@types/koa-favicon": "2.0.19", "@types/koa-logger": "3.1.1", "@types/koa-mount": "3.0.1", "@types/koa-multer": "1.0.0", - "@types/koa-router": "7.0.33", + "@types/koa-router": "7.0.35", "@types/koa-send": "4.1.1", "@types/koa-views": "2.0.3", "@types/koa__cors": "2.2.3", "@types/minio": "7.0.1", "@types/mkdirp": "0.5.2", "@types/mocha": "5.2.5", - "@types/mongodb": "3.1.14", + "@types/mongodb": "3.1.18", "@types/ms": "0.7.30", - "@types/node": "10.12.2", + "@types/node": "10.12.18", + "@types/nodemailer": "4.6.5", "@types/oauth": "0.9.1", + "@types/parsimmon": "1.10.0", "@types/portscanner": "2.1.0", "@types/pug": "2.0.4", "@types/qrcode": "1.3.0", "@types/ratelimiter": "2.1.28", - "@types/redis": "2.8.7", + "@types/redis": "2.8.10", "@types/request": "2.48.1", "@types/request-promise-native": "1.0.15", "@types/rimraf": "2.0.2", "@types/seedrandom": "2.4.27", "@types/sharp": "0.21.0", - "@types/showdown": "1.7.5", + "@types/showdown": "1.9.2", "@types/speakeasy": "2.0.3", - "@types/systeminformation": "3.23.0", + "@types/systeminformation": "3.23.1", "@types/tinycolor2": "1.4.1", "@types/tmp": "0.0.33", "@types/uuid": "3.4.4", - "@types/webpack": "4.4.19", + "@types/webpack": "4.4.21", "@types/webpack-stream": "3.2.10", "@types/websocket": "0.0.40", "@types/ws": "6.0.1", - "animejs": "2.2.0", - "apexcharts": "2.2.0", - "autobind-decorator": "2.2.1", + "animejs": "3.0.1", + "apexcharts": "2.5.1", + "autobind-decorator": "2.4.0", "autosize": "4.0.2", "autwh": "0.1.0", "bcryptjs": "2.4.3", @@ -95,54 +97,54 @@ "bootstrap-vue": "2.0.0-rc.11", "cafy": "12.0.0", "chai": "4.2.0", - "chai-http": "4.2.0", - "chalk": "2.4.1", + "chalk": "2.4.2", + "chai-http": "4.2.1", "commander": "2.19.0", "crc-32": "1.2.0", "css-loader": "1.0.1", - "cssnano": "4.1.7", + "cssnano": "4.1.8", "dateformat": "3.0.3", "debug": "4.1.0", "deep-equal": "1.0.1", "deepcopy": "0.6.3", - "diskusage": "0.2.5", + "diskusage": "1.0.0", "double-ended-queue": "2.1.0-0", - "elasticsearch": "15.2.0", - "emojilib": "2.3.0", + "elasticsearch": "15.3.0", + "emojilib": "2.4.0", "escape-regexp": "0.0.1", - "eslint": "5.8.0", - "eslint-plugin-vue": "4.7.1", + "eslint": "5.12.0", + "eslint-plugin-vue": "5.1.0", "eventemitter3": "3.1.0", + "feed": "2.0.2", "file-loader": "2.0.0", - "file-type": "10.4.0", + "file-type": "10.7.0", "fuckadblock": "3.2.1", "gulp": "3.9.1", "gulp-cssnano": "2.1.3", - "gulp-htmlmin": "5.0.1", "gulp-imagemin": "4.1.0", "gulp-mocha": "6.0.0", - "gulp-pug": "4.0.1", "gulp-rename": "1.4.0", "gulp-replace": "1.0.0", "gulp-sourcemaps": "2.6.4", "gulp-stylus": "2.7.0", "gulp-tslint": "8.1.3", - "gulp-typescript": "4.0.2", + "gulp-typescript": "5.0.0", "gulp-uglify": "3.0.1", "gulp-util": "3.0.8", "gulp-yaml": "2.0.2", - "hard-source-webpack-plugin": "0.12.0", + "hard-source-webpack-plugin": "0.13.1", "html-minifier": "3.5.21", "http-signature": "1.2.0", "insert-text-at-cursor": "0.1.1", "is-root": "2.0.0", + "is-svg": "3.0.0", "is-url": "1.2.4", - "js-yaml": "3.12.0", - "jsdom": "13.0.0", + "js-yaml": "3.12.1", + "jsdom": "13.1.0", "json5": "2.1.0", "json5-loader": "1.0.1", "katex": "0.10.0", - "koa": "2.6.1", + "koa": "2.6.2", "koa-bodyparser": "4.2.1", "koa-compress": "3.0.0", "koa-favicon": "2.0.1", @@ -154,25 +156,30 @@ "koa-send": "5.0.0", "koa-slow": "2.1.0", "koa-views": "6.1.4", - "loader-utils": "1.1.0", - "minio": "7.0.1", + "langmap": "0.0.16", + "loader-utils": "1.2.3", + "lookup-dns-cache": "2.1.0", + "minio": "7.0.3", "mkdirp": "0.5.1", "mocha": "5.2.0", "moji": "0.5.1", - "moment": "2.22.2", - "mongodb": "3.1.9", + "moment": "2.23.0", + "mongodb": "3.1.10", "monk": "6.0.6", "ms": "2.1.1", - "nan": "2.11.1", + "nan": "2.12.1", "nested-property": "0.0.7", + "nodemailer": "5.0.0", "nprogress": "0.2.0", "object-assign-deep": "0.4.0", "on-build-webpack": "0.1.0", "os-utils": "0.0.14", "parse5": "5.1.0", + "parsimmon": "1.12.0", "portscanner": "2.2.0", "postcss-loader": "3.0.0", "progress-bar-webpack-plugin": "1.11.0", + "promise-any": "0.2.0", "promise-limit": "2.7.0", "promise-sequential": "1.1.1", "pug": "2.0.3", @@ -186,11 +193,11 @@ "request": "2.88.0", "request-promise-native": "1.0.5", "request-stats": "3.0.0", - "rimraf": "2.6.2", + "rimraf": "2.6.3", "rndstr": "1.0.0", "s-age": "1.1.2", "seedrandom": "2.4.4", - "sharp": "0.21.0", + "sharp": "0.21.1", "showdown": "1.9.0", "showdown-highlightjs-extension": "0.1.2", "speakeasy": "2.0.0", @@ -199,43 +206,45 @@ "stylus": "0.54.5", "stylus-loader": "3.0.2", "summaly": "2.2.0", - "systeminformation": "3.47.0", + "systeminformation": "3.52.2", "syuilo-password-strength": "0.0.1", - "terser-webpack-plugin": "1.1.0", + "terser-webpack-plugin": "1.2.1", "textarea-caret": "3.1.0", "tinycolor2": "1.4.1", "tmp": "0.0.33", - "ts-loader": "5.3.0", + "ts-loader": "5.3.3", "ts-node": "7.0.1", - "tslint": "5.10.0", - "typescript": "3.1.6", - "typescript-eslint-parser": "21.0.0", + "tslint": "5.12.0", + "tslint-sonarts": "1.8.0", + "typescript": "3.2.2", + "typescript-eslint-parser": "21.0.2", "uglify-es": "3.3.9", "url-loader": "1.1.2", "uuid": "3.3.2", - "v-animate-css": "0.0.2", + "v-animate-css": "0.0.3", "vue": "2.5.17", "vue-color": "2.7.0", "vue-content-loading": "1.5.3", - "vue-cropperjs": "2.2.2", - "vue-i18n": "8.3.1", - "vue-js-modal": "1.3.26", + "vue-cropperjs": "3.0.0", + "vue-i18n": "8.7.0", + "vue-js-modal": "1.3.28", "vue-loader": "15.4.2", - "vue-marquee-text-component": "1.1.0", - "vue-router": "3.0.1", + "vue-marquee-text-component": "1.1.1", + "vue-router": "3.0.2", + "vue-sequential-entrance": "1.1.3", "vue-style-loader": "4.1.2", - "vue-svg-inline-loader": "1.2.2", + "vue-svg-inline-loader": "1.2.7", "vue-template-compiler": "2.5.17", - "vuedraggable": "2.16.0", + "vuedraggable": "2.17.0", "vuewordcloud": "18.7.11", "vuex": "3.0.1", "vuex-persistedstate": "2.5.4", "web-push": "3.3.3", - "webfinger.js": "2.6.6", - "webpack": "4.25.1", - "webpack-cli": "3.1.2", + "webfinger.js": "2.7.0", + "webpack": "4.28.4", + "webpack-cli": "3.2.1", "websocket": "1.0.28", - "ws": "6.1.0", + "ws": "6.1.2", "xev": "2.0.1" } } diff --git a/src/chart/active-users.ts b/src/chart/active-users.ts new file mode 100644 index 0000000000..06d9b8aa90 --- /dev/null +++ b/src/chart/active-users.ts @@ -0,0 +1,48 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from '.'; +import { IUser, isLocalUser } from '../models/user'; + +/** + * アクティブユーザーに関するチャート + */ +type ActiveUsersLog = { + local: { + /** + * アクティブユーザー数 + */ + count: number; + }; + + remote: ActiveUsersLog['local']; +}; + +class ActiveUsersChart extends Chart<ActiveUsersLog> { + constructor() { + super('activeUsers'); + } + + @autobind + protected async getTemplate(init: boolean, latest?: ActiveUsersLog): Promise<ActiveUsersLog> { + return { + local: { + count: 0 + }, + remote: { + count: 0 + } + }; + } + + @autobind + public async update(user: IUser) { + const update: Obj = { + count: 1 + }; + + await this.incIfUnique({ + [isLocalUser(user) ? 'local' : 'remote']: update + }, 'users', user._id.toHexString()); + } +} + +export default new ActiveUsersChart(); diff --git a/src/chart/index.ts b/src/chart/index.ts index b59ad7602b..b550e5eb4b 100644 --- a/src/chart/index.ts +++ b/src/chart/index.ts @@ -61,11 +61,12 @@ export default abstract class Chart<T> { constructor(name: string, grouped = false) { this.collection = db.get<Log<T>>(`chart.${name}`); - if (grouped) { - this.collection.createIndex({ span: -1, date: -1, group: -1 }, { unique: true }); - } else { - this.collection.createIndex({ span: -1, date: -1 }, { unique: true }); - } + const keys = { + span: -1, + date: -1 + } as { [key: string]: 1 | -1; }; + if (grouped) keys.group = -1; + this.collection.createIndex(keys, { unique: true }); } @autobind @@ -73,14 +74,14 @@ export default abstract class Chart<T> { const query: Obj = {}; const dive = (x: Obj, path: string) => { - Object.entries(x).forEach(([k, v]) => { + for (const [k, v] of Object.entries(x)) { const p = path ? `${path}.${k}` : k; if (typeof v === 'number') { query[p] = v; } else { dive(v, p); } - }); + } }; dive(x, path); @@ -315,32 +316,20 @@ export default abstract class Chart<T> { const res: ArrayValue<T> = {} as any; /** - * [{ - * xxxxx: 1, yyyyy: 5 - * }, { - * xxxxx: 2, yyyyy: 6 - * }, { - * xxxxx: 3, yyyyy: 7 - * }] - * + * [{ foo: 1, bar: 5 }, { foo: 2, bar: 6 }, { foo: 3, bar: 7 }] * を - * - * { - * xxxxx: [1, 2, 3], - * yyyyy: [5, 6, 7] - * } - * + * { foo: [1, 2, 3], bar: [5, 6, 7] } * にする */ const dive = (x: Obj, path?: string) => { - Object.entries(x).forEach(([k, v]) => { + for (const [k, v] of Object.entries(x)) { const p = path ? `${path}.${k}` : k; if (typeof v == 'object') { dive(v, p); } else { nestedProperty.set(res, p, chart.map(s => nestedProperty.get(s, p))); } - }); + } }; dive(chart[0]); diff --git a/src/client/app/admin/script.ts b/src/client/app/admin/script.ts index 4002734d3d..084f23b367 100644 --- a/src/client/app/admin/script.ts +++ b/src/client/app/admin/script.ts @@ -9,6 +9,7 @@ import './style.styl'; import init from '../init'; import Index from './views/index.vue'; +import NotFound from '../common/views/pages/not-found.vue'; init(launch => { document.title = 'Admin'; @@ -19,6 +20,7 @@ init(launch => { base: '/admin/', routes: [ { path: '/', component: Index }, + { path: '*', component: NotFound } ] }); diff --git a/src/client/app/admin/views/abuse.vue b/src/client/app/admin/views/abuse.vue new file mode 100644 index 0000000000..22a837f1c6 --- /dev/null +++ b/src/client/app/admin/views/abuse.vue @@ -0,0 +1,83 @@ +<template> +<div> + <ui-card> + <div slot="title"><fa :icon="faExclamationCircle"/> {{ $t('title') }}</div> + <section class="fit-top"> + <sequential-entrance animation="entranceFromTop" delay="25"> + <div v-for="report in userReports" :key="report.id" class="haexwsjc"> + <ui-horizon-group inputs> + <ui-input :value="report.user | acct" type="text"> + <span>{{ $t('target') }}</span> + </ui-input> + <ui-input :value="report.reporter | acct" type="text"> + <span>{{ $t('reporter') }}</span> + </ui-input> + </ui-horizon-group> + <ui-textarea :value="report.comment" readonly> + <span>{{ $t('details') }}</span> + </ui-textarea> + <ui-button @click="removeReport(report)">{{ $t('remove-report') }}</ui-button> + </div> + </sequential-entrance> + <ui-button v-if="existMore" @click="fetchUserReports">{{ $t('@.load-more') }}</ui-button> + </section> + </ui-card> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../i18n'; +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + i18n: i18n('admin/views/abuse.vue'), + + data() { + return { + limit: 10, + untilId: undefined, + userReports: [], + existMore: false, + faExclamationCircle + }; + }, + + mounted() { + this.fetchUserReports(); + }, + + methods: { + fetchUserReports() { + this.$root.api('admin/abuse-user-reports', { + untilId: this.untilId, + limit: this.limit + 1 + }).then(reports => { + if (reports.length == this.limit + 1) { + reports.pop(); + this.existMore = true; + } else { + this.existMore = false; + } + this.userReports = this.userReports.concat(reports); + this.untilId = this.userReports[this.userReports.length - 1].id; + }); + }, + + removeReport(report) { + this.$root.api('admin/remove-abuse-user-report', { + reportId: report.id + }).then(() => { + this.userReports = this.userReports.filter(r => r.id != report.id); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.haexwsjc + padding-bottom 16px + border-bottom solid 1px var(--faceDivider) + +</style> diff --git a/src/client/app/admin/views/announcements.vue b/src/client/app/admin/views/announcements.vue index 31a2ab50b7..f50239e092 100644 --- a/src/client/app/admin/views/announcements.vue +++ b/src/client/app/admin/views/announcements.vue @@ -1,5 +1,5 @@ <template> -<div class="cdeuzmsthagexbkpofbmatmugjuvogfb"> +<div> <ui-card> <div slot="title"><fa icon="broadcast-tower"/> {{ $t('announcements') }}</div> <section v-for="(announcement, i) in announcements" class="fit-top"> @@ -9,7 +9,7 @@ <ui-textarea v-model="announcement.text"> <span>{{ $t('text') }}</span> </ui-textarea> - <ui-horizon-group> + <ui-horizon-group class="fit-bottom"> <ui-button @click="save()"><fa :icon="['far', 'save']"/> {{ $t('save') }}</ui-button> <ui-button @click="remove(i)"><fa :icon="['far', 'trash-alt']"/> {{ $t('remove') }}</ui-button> </ui-horizon-group> @@ -48,15 +48,15 @@ export default Vue.extend({ }, remove(i) { - this.$root.alert({ + this.$root.dialog({ type: 'warning', text: this.$t('_remove.are-you-sure').replace('$1', this.announcements.find((_, j) => j == i).title), showCancelButton: true - }).then(res => { - if (!res) return; + }).then(({ canceled }) => { + if (canceled) return; this.announcements = this.announcements.filter((_, j) => j !== i); this.save(true); - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('_remove.removed') }); @@ -68,13 +68,13 @@ export default Vue.extend({ broadcasts: this.announcements }).then(() => { if (!silent) { - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('saved') }); } }).catch(e => { - this.$root.alert({ + this.$root.dialog({ type: 'error', text: e }); @@ -83,10 +83,3 @@ export default Vue.extend({ } }); </script> - -<style lang="stylus" scoped> -.cdeuzmsthagexbkpofbmatmugjuvogfb - @media (min-width 500px) - padding 16px - -</style> diff --git a/src/client/app/admin/views/ap-log.vue b/src/client/app/admin/views/ap-log.vue index 1524340ecb..ee48ef15ea 100644 --- a/src/client/app/admin/views/ap-log.vue +++ b/src/client/app/admin/views/ap-log.vue @@ -56,7 +56,9 @@ export default Vue.extend({ }, onLogs(logs) { - logs.reverse().forEach(log => this.onLog(log)); + for (const log of logs.reverse()) { + this.onLog(log) + } } } }); @@ -67,7 +69,7 @@ export default Vue.extend({ display block padding 12px 16px 16px 16px height 250px - overflow hidden + overflow auto box-shadow 0 2px 4px rgba(0, 0, 0, 0.1) background var(--adminDashboardCardBg) border-radius 8px diff --git a/src/client/app/admin/views/charts.vue b/src/client/app/admin/views/charts.vue index 13e8b3671e..2100cc288b 100644 --- a/src/client/app/admin/views/charts.vue +++ b/src/client/app/admin/views/charts.vue @@ -10,6 +10,7 @@ <optgroup :label="$t('users')"> <option value="users">{{ $t('charts.users') }}</option> <option value="users-total">{{ $t('charts.users-total') }}</option> + <option value="active-users">{{ $t('charts.active-users') }}</option> </optgroup> <optgroup :label="$t('notes')"> <option value="notes">{{ $t('charts.notes') }}</option> @@ -67,6 +68,7 @@ export default Vue.extend({ case 'federation-instances-total': return this.federationInstancesChart(true); case 'users': return this.usersChart(false); case 'users-total': return this.usersChart(true); + case 'active-users': return this.activeUsersChart(); case 'notes': return this.notesChart('combined'); case 'local-notes': return this.notesChart('local'); case 'remote-notes': return this.notesChart('remote'); @@ -107,12 +109,14 @@ export default Vue.extend({ const [perHour, perDay] = await Promise.all([Promise.all([ this.$root.api('charts/federation', { limit: limit, span: 'hour' }), this.$root.api('charts/users', { limit: limit, span: 'hour' }), + this.$root.api('charts/active-users', { limit: limit, span: 'hour' }), this.$root.api('charts/notes', { limit: limit, span: 'hour' }), this.$root.api('charts/drive', { limit: limit, span: 'hour' }), this.$root.api('charts/network', { limit: limit, span: 'hour' }) ]), Promise.all([ this.$root.api('charts/federation', { limit: limit, span: 'day' }), this.$root.api('charts/users', { limit: limit, span: 'day' }), + this.$root.api('charts/active-users', { limit: limit, span: 'day' }), this.$root.api('charts/notes', { limit: limit, span: 'day' }), this.$root.api('charts/drive', { limit: limit, span: 'day' }), this.$root.api('charts/network', { limit: limit, span: 'day' }) @@ -122,16 +126,18 @@ export default Vue.extend({ perHour: { federation: perHour[0], users: perHour[1], - notes: perHour[2], - drive: perHour[3], - network: perHour[4] + activeUsers: perHour[2], + notes: perHour[3], + drive: perHour[4], + network: perHour[5] }, perDay: { federation: perDay[0], users: perDay[1], - notes: perDay[2], - drive: perDay[3], - network: perDay[4] + activeUsers: perDay[2], + notes: perDay[3], + drive: perDay[4], + network: perDay[5] } }; @@ -183,7 +189,7 @@ export default Vue.extend({ }, legend: { labels: { - color: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString() + colors: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString() }, }, xaxis: { @@ -321,6 +327,24 @@ export default Vue.extend({ }; }, + activeUsersChart(): any { + return { + series: [{ + name: 'Combined', + type: 'line', + data: this.format(sum(this.stats.activeUsers.local.count, this.stats.activeUsers.remote.count)) + }, { + name: 'Local', + type: 'area', + data: this.format(this.stats.activeUsers.local.count) + }, { + name: 'Remote', + type: 'area', + data: this.format(this.stats.activeUsers.remote.count) + }] + }; + }, + driveChart(): any { return { bytes: true, diff --git a/src/client/app/admin/views/cpu-memory.vue b/src/client/app/admin/views/cpu-memory.vue index a111dfe32d..a8237fe411 100644 --- a/src/client/app/admin/views/cpu-memory.vue +++ b/src/client/app/admin/views/cpu-memory.vue @@ -132,7 +132,9 @@ export default Vue.extend({ }, onStatsLog(statsLog) { - statsLog.reverse().forEach(stats => this.onStats(stats)); + for (const stats of statsLog.reverse()) { + this.onStats(stats); + } } } }); diff --git a/src/client/app/admin/views/dashboard.vue b/src/client/app/admin/views/dashboard.vue index 3fd024a133..d21bd17202 100644 --- a/src/client/app/admin/views/dashboard.vue +++ b/src/client/app/admin/views/dashboard.vue @@ -127,12 +127,12 @@ export default Vue.extend({ this.$root.api('instances', { sort: '+notes' }).then(instances => { - instances.forEach(i => { + for (const i of instances) { i.bg = randomColor({ seed: i.host, luminosity: 'dark' }); - }); + } this.instances = instances; }); }, @@ -148,7 +148,7 @@ export default Vue.extend({ }, updateStats() { - this.$root.api('stats', {}, false, true).then(stats => { + this.$root.api('stats', {}, true).then(stats => { this.stats = stats; }); } @@ -161,7 +161,7 @@ export default Vue.extend({ padding 16px @media (min-width 500px) - padding 32px + padding 16px > header display flex diff --git a/src/client/app/admin/views/drive.vue b/src/client/app/admin/views/drive.vue new file mode 100644 index 0000000000..f973465ea8 --- /dev/null +++ b/src/client/app/admin/views/drive.vue @@ -0,0 +1,267 @@ +<template> +<div> + <ui-card> + <div slot="title"><fa :icon="faTerminal"/> {{ $t('operation') }}</div> + <section class="fit-top"> + <ui-input v-model="target" type="text"> + <span>{{ $t('fileid-or-url') }}</span> + </ui-input> + <ui-horizon-group> + <ui-button @click="findAndToggleSensitive(true)"><fa :icon="faEyeSlash"/> {{ $t('mark-as-sensitive') }}</ui-button> + <ui-button @click="findAndToggleSensitive(false)"><fa :icon="faEye"/> {{ $t('unmark-as-sensitive') }}</ui-button> + </ui-horizon-group> + <ui-button @click="findAndDel()"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button> + <ui-button @click="show()"><fa :icon="faSearch"/> {{ $t('lookup') }}</ui-button> + <ui-textarea v-if="file" :value="file | json5" readonly tall style="margin-top:16px;"></ui-textarea> + </section> + </ui-card> + + <ui-card> + <div slot="title"><fa :icon="faCloud"/> {{ $t('@.drive') }}</div> + <section class="fit-top"> + <ui-horizon-group inputs> + <ui-select v-model="sort"> + <span slot="label">{{ $t('sort.title') }}</span> + <option value="-createdAt">{{ $t('sort.createdAtAsc') }}</option> + <option value="+createdAt">{{ $t('sort.createdAtDesc') }}</option> + <option value="-size">{{ $t('sort.sizeAsc') }}</option> + <option value="+size">{{ $t('sort.sizeDesc') }}</option> + </ui-select> + <ui-select v-model="origin"> + <span slot="label">{{ $t('origin.title') }}</span> + <option value="combined">{{ $t('origin.combined') }}</option> + <option value="local">{{ $t('origin.local') }}</option> + <option value="remote">{{ $t('origin.remote') }}</option> + </ui-select> + </ui-horizon-group> + <sequential-entrance animation="entranceFromTop" delay="25"> + <div class="kidvdlkg" v-for="file in files"> + <div @click="file._open = !file._open"> + <div> + <div class="thumbnail" :style="thumbnail(file)"></div> + </div> + <div> + <header> + <b>{{ file.name }}</b> + <span class="username">@{{ file.user | acct }}</span> + </header> + <div> + <div> + <span style="margin-right:16px;">{{ file.type }}</span> + <span>{{ file.datasize | bytes }}</span> + </div> + <div><mk-time :time="file.createdAt" mode="detail"/></div> + </div> + </div> + </div> + <div v-show="file._open"> + <ui-input readonly :value="file.url"></ui-input> + <ui-horizon-group> + <ui-button @click="toggleSensitive(file)" v-if="file.isSensitive"><fa :icon="faEye"/> {{ $t('unmark-as-sensitive') }}</ui-button> + <ui-button @click="toggleSensitive(file)" v-else><fa :icon="faEyeSlash"/> {{ $t('mark-as-sensitive') }}</ui-button> + <ui-button @click="del(file)"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button> + </ui-horizon-group> + </div> + </div> + </sequential-entrance> + <ui-button v-if="existMore" @click="fetch">{{ $t('@.load-more') }}</ui-button> + </section> + </ui-card> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../i18n'; +import { faCloud, faTerminal, faSearch } from '@fortawesome/free-solid-svg-icons'; +import { faTrashAlt, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; + +export default Vue.extend({ + i18n: i18n('admin/views/drive.vue'), + + data() { + return { + file: null, + target: null, + sort: '+createdAt', + origin: 'combined', + limit: 10, + offset: 0, + files: [], + existMore: false, + faCloud, faTrashAlt, faEye, faEyeSlash, faTerminal, faSearch + }; + }, + + watch: { + sort() { + this.files = []; + this.offset = 0; + this.fetch(); + }, + + origin() { + this.files = []; + this.offset = 0; + this.fetch(); + } + }, + + mounted() { + this.fetch(); + }, + + methods: { + async fetchFile() { + try { + return await this.$root.api('drive/files/show', this.target.startsWith('http') ? { url: this.target } : { fileId: this.target }); + } catch (e) { + if (e == 'file-not-found') { + this.$root.dialog({ + type: 'error', + text: this.$t('file-not-found') + }); + } else { + this.$root.dialog({ + type: 'error', + text: e.toString() + }); + } + } + }, + + fetch() { + this.$root.api('admin/drive/files', { + origin: this.origin, + sort: this.sort, + offset: this.offset, + limit: this.limit + 1 + }).then(files => { + if (files.length == this.limit + 1) { + files.pop(); + this.existMore = true; + } else { + this.existMore = false; + } + for (const x of files) { + x._open = false; + } + this.files = this.files.concat(files); + this.offset += this.limit; + }); + }, + + thumbnail(file: any): any { + return { + 'background-color': file.properties.avgColor && file.properties.avgColor.length == 3 ? `rgb(${file.properties.avgColor.join(',')})` : 'transparent', + 'background-image': `url(${file.thumbnailUrl})` + }; + }, + + async del(file: any) { + const process = async () => { + await this.$root.api('drive/files/delete', { fileId: file.id }); + this.$root.dialog({ + type: 'success', + text: this.$t('deleted') + }); + }; + + await process().catch(e => { + this.$root.dialog({ + type: 'error', + text: e.toString() + }); + }); + }, + + toggleSensitive(file: any) { + this.$root.api('drive/files/update', { + fileId: file.id, + isSensitive: !file.isSensitive + }); + + file.isSensitive = !file.isSensitive; + }, + + async show() { + const file = await this.fetchFile(); + this.$root.api('admin/drive/show-file', { fileId: file.id }).then(info => { + this.file = info; + }); + }, + + async findAndToggleSensitive(sensitive) { + const process = async () => { + const file = await this.fetchFile(); + await this.$root.api('drive/files/update', { + fileId: file.id, + isSensitive: sensitive + }); + this.$root.dialog({ + type: 'success', + text: sensitive ? this.$t('marked-as-sensitive') : this.$t('unmarked-as-sensitive') + }); + }; + + await process().catch(e => { + this.$root.dialog({ + type: 'error', + text: e.toString() + }); + }); + }, + + async findAndDel() { + const process = async () => { + const file = await this.fetchFile(); + await this.$root.api('drive/files/delete', { fileId: file.id }); + this.$root.dialog({ + type: 'success', + text: this.$t('deleted') + }); + }; + + await process().catch(e => { + this.$root.dialog({ + type: 'error', + text: e.toString() + }); + }); + }, + } +}); +</script> + +<style lang="stylus" scoped> +.kidvdlkg + padding 16px 0 + border-top solid 1px var(--faceDivider) + + > div:first-child + display flex + cursor pointer + + > div:nth-child(1) + > .thumbnail + display block + width 64px + height 64px + background-size cover + background-position center center + + > div:nth-child(2) + flex 1 + padding-left 16px + + @media (max-width 500px) + font-size 14px + + > header + word-break break-word + + > .username + margin-left 8px + opacity 0.7 + +</style> diff --git a/src/client/app/admin/views/emoji.vue b/src/client/app/admin/views/emoji.vue index 6810340a3e..1a623f4249 100644 --- a/src/client/app/admin/views/emoji.vue +++ b/src/client/app/admin/views/emoji.vue @@ -1,5 +1,5 @@ <template> -<div class="tumhkfkmgtvzljezfvmgkeurkfncshbe"> +<div> <ui-card> <div slot="title"><fa icon="plus"/> {{ $t('add-emoji.title') }}</div> <section class="fit-top"> @@ -24,24 +24,28 @@ <ui-card> <div slot="title"><fa :icon="faGrin"/> {{ $t('emojis.title') }}</div> - <section v-for="emoji in emojis"> - <img :src="emoji.url" :alt="emoji.name" style="width: 64px;"/> - <ui-horizon-group inputs> - <ui-input v-model="emoji.name"> - <span>{{ $t('add-emoji.name') }}</span> - </ui-input> - <ui-input v-model="emoji.aliases"> - <span>{{ $t('add-emoji.aliases') }}</span> + <section v-for="emoji in emojis" class="oryfrbft"> + <div> + <img :src="emoji.url" :alt="emoji.name" style="width: 64px;"/> + </div> + <div> + <ui-horizon-group> + <ui-input v-model="emoji.name"> + <span>{{ $t('add-emoji.name') }}</span> + </ui-input> + <ui-input v-model="emoji.aliases"> + <span>{{ $t('add-emoji.aliases') }}</span> + </ui-input> + </ui-horizon-group> + <ui-input v-model="emoji.url"> + <i slot="icon"><fa icon="link"/></i> + <span>{{ $t('add-emoji.url') }}</span> </ui-input> - </ui-horizon-group> - <ui-input v-model="emoji.url"> - <i slot="icon"><fa icon="link"/></i> - <span>{{ $t('add-emoji.url') }}</span> - </ui-input> - <ui-horizon-group> - <ui-button @click="updateEmoji(emoji)"><fa :icon="['far', 'save']"/> {{ $t('emojis.update') }}</ui-button> - <ui-button @click="removeEmoji(emoji)"><fa :icon="['far', 'trash-alt']"/> {{ $t('emojis.remove') }}</ui-button> - </ui-horizon-group> + <ui-horizon-group class="fit-bottom"> + <ui-button @click="updateEmoji(emoji)"><fa :icon="['far', 'save']"/> {{ $t('emojis.update') }}</ui-button> + <ui-button @click="removeEmoji(emoji)"><fa :icon="['far', 'trash-alt']"/> {{ $t('emojis.remove') }}</ui-button> + </ui-horizon-group> + </div> </section> </ui-card> </div> @@ -75,13 +79,13 @@ export default Vue.extend({ url: this.url, aliases: this.aliases.split(' ').filter(x => x.length > 0) }).then(() => { - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('add-emoji.added') }); this.fetchEmojis(); }).catch(e => { - this.$root.alert({ + this.$root.dialog({ type: 'error', text: e }); @@ -91,7 +95,9 @@ export default Vue.extend({ fetchEmojis() { this.$root.api('admin/emoji/list').then(emojis => { emojis.reverse(); - emojis.forEach(e => e.aliases = (e.aliases || []).join(' ')); + for (const e of emojis) { + e.aliases = (e.aliases || []).join(' '); + } this.emojis = emojis; }); }, @@ -103,12 +109,12 @@ export default Vue.extend({ url: emoji.url, aliases: emoji.aliases.split(' ').filter(x => x.length > 0) }).then(() => { - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('updated') }); }).catch(e => { - this.$root.alert({ + this.$root.dialog({ type: 'error', text: e }); @@ -116,23 +122,23 @@ export default Vue.extend({ }, removeEmoji(emoji) { - this.$root.alert({ + this.$root.dialog({ type: 'warning', text: this.$t('remove-emoji.are-you-sure').replace('$1', emoji.name), showCancelButton: true - }).then(res => { - if (!res) return; + }).then(({ canceled }) => { + if (canceled) return; this.$root.api('admin/emoji/remove', { id: emoji.id }).then(() => { - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('remove-emoji.removed') }); this.fetchEmojis(); }).catch(e => { - this.$root.alert({ + this.$root.dialog({ type: 'error', text: e }); @@ -144,8 +150,21 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.tumhkfkmgtvzljezfvmgkeurkfncshbe +.oryfrbft @media (min-width 500px) - padding 16px + display flex + + > div:first-child + @media (max-width 500px) + padding-bottom 16px + + > img + vertical-align bottom + + > div:last-child + flex 1 + + @media (min-width 500px) + padding-left 16px </style> diff --git a/src/client/app/admin/views/hashtags.vue b/src/client/app/admin/views/hashtags.vue index 739118fa6c..dc9f0b015e 100644 --- a/src/client/app/admin/views/hashtags.vue +++ b/src/client/app/admin/views/hashtags.vue @@ -39,10 +39,3 @@ export default Vue.extend({ } }); </script> - -<style lang="stylus" scoped> -.jdnqwkzlnxcfftthoybjxrebyolvoucw - width 100% - min-height 300px - -</style> diff --git a/src/client/app/admin/views/index.vue b/src/client/app/admin/views/index.vue index 3cd8ee7a2a..aa831e8bf6 100644 --- a/src/client/app/admin/views/index.vue +++ b/src/client/app/admin/views/index.vue @@ -15,19 +15,19 @@ </div> <div class="me"> <img class="avatar" :src="$store.state.i.avatarUrl" alt="avatar"/> - <p class="name">{{ $store.state.i | userName }}</p> + <p class="name"><mk-user-name :user="$store.state.i"/></p> </div> <ul> <li @click="nav('dashboard')" :class="{ active: page == 'dashboard' }"><fa icon="home" fixed-width/>{{ $t('dashboard') }}</li> <li @click="nav('instance')" :class="{ active: page == 'instance' }"><fa icon="cog" fixed-width/>{{ $t('instance') }}</li> <li @click="nav('moderators')" :class="{ active: page == 'moderators' }"><fa :icon="faHeadset" fixed-width/>{{ $t('moderators') }}</li> <li @click="nav('users')" :class="{ active: page == 'users' }"><fa icon="users" fixed-width/>{{ $t('users') }}</li> + <li @click="nav('drive')" :class="{ active: page == 'drive' }"><fa icon="cloud" fixed-width/>{{ $t('@.drive') }}</li> <!-- <li @click="nav('federation')" :class="{ active: page == 'federation' }"><fa :icon="faShareAlt" fixed-width/>{{ $t('federation') }}</li> --> <li @click="nav('emoji')" :class="{ active: page == 'emoji' }"><fa :icon="faGrin" fixed-width/>{{ $t('emoji') }}</li> <li @click="nav('announcements')" :class="{ active: page == 'announcements' }"><fa icon="broadcast-tower" fixed-width/>{{ $t('announcements') }}</li> <li @click="nav('hashtags')" :class="{ active: page == 'hashtags' }"><fa icon="hashtag" fixed-width/>{{ $t('hashtags') }}</li> - - <!-- <li @click="nav('drive')" :class="{ active: page == 'drive' }"><fa icon="cloud" fixed-width/>{{ $t('@.drive') }}</li> --> + <li @click="nav('abuse')" :class="{ active: page == 'abuse' }"><fa :icon="faExclamationCircle" fixed-width/>{{ $t('abuse') }}</li> </ul> <div class="back-to-misskey"> <a href="/"><fa :icon="faArrowLeft"/> {{ $t('back-to-misskey') }}</a> @@ -45,8 +45,8 @@ <div v-if="page == 'emoji'"><x-emoji/></div> <div v-if="page == 'announcements'"><x-announcements/></div> <div v-if="page == 'hashtags'"><x-hashtags/></div> - <div v-if="page == 'drive'"></div> - <div v-if="page == 'update'"></div> + <div v-if="page == 'drive'"><x-drive/></div> + <div v-if="page == 'abuse'"><x-abuse/></div> </div> </main> </div> @@ -63,7 +63,9 @@ import XEmoji from "./emoji.vue"; import XAnnouncements from "./announcements.vue"; import XHashtags from "./hashtags.vue"; import XUsers from "./users.vue"; -import { faHeadset, faArrowLeft, faShareAlt } from '@fortawesome/free-solid-svg-icons'; +import XDrive from "./drive.vue"; +import XAbuse from "./abuse.vue"; +import { faHeadset, faArrowLeft, faShareAlt, faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; import { faGrin } from '@fortawesome/free-regular-svg-icons'; // Detect the user agent @@ -79,7 +81,9 @@ export default Vue.extend({ XEmoji, XAnnouncements, XHashtags, - XUsers + XUsers, + XDrive, + XAbuse, }, provide: { isMobile @@ -93,7 +97,8 @@ export default Vue.extend({ faGrin, faArrowLeft, faHeadset, - faShareAlt + faShareAlt, + faExclamationCircle }; }, methods: { @@ -269,6 +274,9 @@ export default Vue.extend({ > .page max-width 1150px + @media (min-width 500px) + padding 16px + &.isMobile > main padding $headerHeight 0 0 0 diff --git a/src/client/app/admin/views/instance.vue b/src/client/app/admin/views/instance.vue index 4c234ec260..bf30913b20 100644 --- a/src/client/app/admin/views/instance.vue +++ b/src/client/app/admin/views/instance.vue @@ -1,22 +1,30 @@ <template> -<div class="axbwjelsbymowqjyywpirzhdlszoncqs"> +<div> <ui-card> <div slot="title"><fa icon="cog"/> {{ $t('instance') }}</div> <section class="fit-top fit-bottom"> <ui-input :value="host" readonly>{{ $t('host') }}</ui-input> <ui-input v-model="name">{{ $t('instance-name') }}</ui-input> <ui-textarea v-model="description">{{ $t('instance-description') }}</ui-textarea> + <ui-input v-model="mascotImageUrl"><i slot="icon"><fa icon="link"/></i>{{ $t('logo-url') }}</ui-input> <ui-input v-model="bannerUrl"><i slot="icon"><fa icon="link"/></i>{{ $t('banner-url') }}</ui-input> + <ui-input v-model="errorImageUrl"><i slot="icon"><fa icon="link"/></i>{{ $t('error-image-url') }}</ui-input> <ui-input v-model="languages"><i slot="icon"><fa icon="language"/></i>{{ $t('languages') }}<span slot="desc">{{ $t('languages-desc') }}</span></ui-input> </section> <section class="fit-bottom"> <header><fa :icon="faHeadset"/> {{ $t('maintainer-config') }}</header> <ui-input v-model="maintainerName">{{ $t('maintainer-name') }}</ui-input> - <ui-input v-model="maintainerEmail" type="email"><i slot="icon"><fa :icon="['far', 'envelope']"/></i>{{ $t('maintainer-email') }}</ui-input> + <ui-input v-model="maintainerEmail" type="email"><i slot="icon"><fa :icon="farEnvelope"/></i>{{ $t('maintainer-email') }}</ui-input> </section> <section class="fit-top fit-bottom"> <ui-input v-model="maxNoteTextLength">{{ $t('max-note-text-length') }}</ui-input> </section> + <section> + <ui-switch v-model="disableRegistration">{{ $t('disable-registration') }}</ui-switch> + <ui-switch v-model="disableLocalTimeline">{{ $t('disable-local-timeline') }}</ui-switch> + <ui-switch v-model="disableGlobalTimeline">{{ $t('disable-global-timeline') }}</ui-switch> + <ui-info>{{ $t('disabling-timelines-info') }}</ui-info> + </section> <section class="fit-bottom"> <header><fa icon="cloud"/> {{ $t('drive-config') }}</header> <ui-switch v-model="cacheRemoteFiles">{{ $t('cache-remote-files') }}<span slot="desc">{{ $t('cache-remote-files-desc') }}</span></ui-switch> @@ -27,8 +35,10 @@ <header><fa :icon="faShieldAlt"/> {{ $t('recaptcha-config') }}</header> <ui-switch v-model="enableRecaptcha">{{ $t('enable-recaptcha') }}</ui-switch> <ui-info>{{ $t('recaptcha-info') }}</ui-info> - <ui-input v-model="recaptchaSiteKey" :disabled="!enableRecaptcha"><i slot="icon"><fa icon="key"/></i>{{ $t('recaptcha-site-key') }}</ui-input> - <ui-input v-model="recaptchaSecretKey" :disabled="!enableRecaptcha"><i slot="icon"><fa icon="key"/></i>{{ $t('recaptcha-secret-key') }}</ui-input> + <ui-horizon-group inputs> + <ui-input v-model="recaptchaSiteKey" :disabled="!enableRecaptcha"><i slot="icon"><fa icon="key"/></i>{{ $t('recaptcha-site-key') }}</ui-input> + <ui-input v-model="recaptchaSecretKey" :disabled="!enableRecaptcha"><i slot="icon"><fa icon="key"/></i>{{ $t('recaptcha-secret-key') }}</ui-input> + </ui-horizon-group> </section> <section> <header><fa :icon="faGhost"/> {{ $t('proxy-account-config') }}</header> @@ -37,10 +47,37 @@ <ui-info warn>{{ $t('proxy-account-warn') }}</ui-info> </section> <section> - <ui-switch v-model="disableRegistration">{{ $t('disable-registration') }}</ui-switch> + <header><fa :icon="farEnvelope"/> {{ $t('email-config') }}</header> + <ui-switch v-model="enableEmail">{{ $t('enable-email') }}<span slot="desc">{{ $t('email-config-info') }}</span></ui-switch> + <ui-input v-model="email" type="email" :disabled="!enableEmail">{{ $t('email') }}</ui-input> + <ui-horizon-group inputs> + <ui-input v-model="smtpHost" :disabled="!enableEmail">{{ $t('smtp-host') }}</ui-input> + <ui-input v-model="smtpPort" type="number" :disabled="!enableEmail">{{ $t('smtp-port') }}</ui-input> + </ui-horizon-group> + <ui-horizon-group inputs> + <ui-input v-model="smtpUser" :disabled="!enableEmail">{{ $t('smtp-user') }}</ui-input> + <ui-input v-model="smtpPass" type="password" :withPasswordToggle="true" :disabled="!enableEmail">{{ $t('smtp-pass') }}</ui-input> + </ui-horizon-group> + <ui-switch v-model="smtpSecure" :disabled="!enableEmail">{{ $t('smtp-secure') }}<span slot="desc">{{ $t('smtp-secure-info') }}</span></ui-switch> </section> <section> - <ui-switch v-model="disableLocalTimeline">{{ $t('disable-local-timeline') }}</ui-switch> + <header><fa :icon="faBolt"/> {{ $t('serviceworker-config') }}</header> + <ui-switch v-model="enableServiceWorker">{{ $t('enable-serviceworker') }}<span slot="desc">{{ $t('serviceworker-info') }}</span></ui-switch> + <ui-info>{{ $t('vapid-info') }}<br><code>npm i web-push -g<br>web-push generate-vapid-keys</code></ui-info> + <ui-horizon-group inputs class="fit-bottom"> + <ui-input v-model="swPublicKey" :disabled="!enableServiceWorker"><i slot="icon"><fa icon="key"/></i>{{ $t('vapid-publickey') }}</ui-input> + <ui-input v-model="swPrivateKey" :disabled="!enableServiceWorker"><i slot="icon"><fa icon="key"/></i>{{ $t('vapid-privatekey') }}</ui-input> + </ui-horizon-group> + </section> + <section> + <header>summaly Proxy</header> + <ui-input v-model="summalyProxy">URL</ui-input> + </section> + <section> + <header><fa :icon="faUserPlus"/> {{ $t('user-recommendation-config') }}</header> + <ui-switch v-model="enableExternalUserRecommendation">{{ $t('enable-external-user-recommendation') }}</ui-switch> + <ui-input v-model="externalUserRecommendationEngine" :disabled="!enableExternalUserRecommendation">{{ $t('external-user-recommendation-engine') }}<span slot="desc">{{ $t('external-user-recommendation-engine-desc') }}</span></ui-input> + <ui-input v-model="externalUserRecommendationTimeout" type="number" :disabled="!enableExternalUserRecommendation">{{ $t('external-user-recommendation-timeout') }}<span slot="suffix">ms</span><span slot="desc">{{ $t('external-user-recommendation-timeout-desc') }}</span></ui-input> </section> <section> <ui-button @click="updateMeta">{{ $t('save') }}</ui-button> @@ -59,9 +96,11 @@ <div slot="title"><fa :icon="['fab', 'twitter']"/> {{ $t('twitter-integration-config') }}</div> <section> <ui-switch v-model="enableTwitterIntegration">{{ $t('enable-twitter-integration') }}</ui-switch> - <ui-info>{{ $t('twitter-integration-info') }}</ui-info> - <ui-input v-model="twitterConsumerKey" :disabled="!enableTwitterIntegration"><i slot="icon"><fa icon="key"/></i>{{ $t('twitter-integration-consumer-key') }}</ui-input> - <ui-input v-model="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><i slot="icon"><fa icon="key"/></i>{{ $t('twitter-integration-consumer-secret') }}</ui-input> + <ui-horizon-group> + <ui-input v-model="twitterConsumerKey" :disabled="!enableTwitterIntegration"><i slot="icon"><fa icon="key"/></i>{{ $t('twitter-integration-consumer-key') }}</ui-input> + <ui-input v-model="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><i slot="icon"><fa icon="key"/></i>{{ $t('twitter-integration-consumer-secret') }}</ui-input> + </ui-horizon-group> + <ui-info>{{ $t('twitter-integration-info', { url: `${url}/api/tw/cb` }) }}</ui-info> <ui-button @click="updateMeta">{{ $t('save') }}</ui-button> </section> </ui-card> @@ -70,9 +109,11 @@ <div slot="title"><fa :icon="['fab', 'github']"/> {{ $t('github-integration-config') }}</div> <section> <ui-switch v-model="enableGithubIntegration">{{ $t('enable-github-integration') }}</ui-switch> - <ui-info>{{ $t('github-integration-info') }}</ui-info> - <ui-input v-model="githubClientId" :disabled="!enableGithubIntegration"><i slot="icon"><fa icon="key"/></i>{{ $t('github-integration-client-id') }}</ui-input> - <ui-input v-model="githubClientSecret" :disabled="!enableGithubIntegration"><i slot="icon"><fa icon="key"/></i>{{ $t('github-integration-client-secret') }}</ui-input> + <ui-horizon-group> + <ui-input v-model="githubClientId" :disabled="!enableGithubIntegration"><i slot="icon"><fa icon="key"/></i>{{ $t('github-integration-client-id') }}</ui-input> + <ui-input v-model="githubClientSecret" :disabled="!enableGithubIntegration"><i slot="icon"><fa icon="key"/></i>{{ $t('github-integration-client-secret') }}</ui-input> + </ui-horizon-group> + <ui-info>{{ $t('github-integration-info', { url: `${url}/api/gh/cb` }) }}</ui-info> <ui-button @click="updateMeta">{{ $t('save') }}</ui-button> </section> </ui-card> @@ -81,9 +122,11 @@ <div slot="title"><fa :icon="['fab', 'discord']"/> {{ $t('discord-integration-config') }}</div> <section> <ui-switch v-model="enableDiscordIntegration">{{ $t('enable-discord-integration') }}</ui-switch> - <ui-info>{{ $t('discord-integration-info') }}</ui-info> - <ui-input v-model="discordClientId" :disabled="!enableDiscordIntegration"><i slot="icon"><fa icon="key"/></i>{{ $t('discord-integration-client-id') }}</ui-input> - <ui-input v-model="discordClientSecret" :disabled="!enableDiscordIntegration"><i slot="icon"><fa icon="key"/></i>{{ $t('discord-integration-client-secret') }}</ui-input> + <ui-horizon-group> + <ui-input v-model="discordClientId" :disabled="!enableDiscordIntegration"><i slot="icon"><fa icon="key"/></i>{{ $t('discord-integration-client-id') }}</ui-input> + <ui-input v-model="discordClientSecret" :disabled="!enableDiscordIntegration"><i slot="icon"><fa icon="key"/></i>{{ $t('discord-integration-client-secret') }}</ui-input> + </ui-horizon-group> + <ui-info>{{ $t('discord-integration-info', { url: `${url}/api/dc/cb` }) }}</ui-info> <ui-button @click="updateMeta">{{ $t('save') }}</ui-button> </section> </ui-card> @@ -93,21 +136,26 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../i18n'; -import { host } from '../../config'; +import { url, host } from '../../config'; import { toUnicode } from 'punycode'; -import { faHeadset, faShieldAlt, faGhost } from '@fortawesome/free-solid-svg-icons'; +import { faHeadset, faShieldAlt, faGhost, faUserPlus, faBolt } from '@fortawesome/free-solid-svg-icons'; +import { faEnvelope as farEnvelope } from '@fortawesome/free-regular-svg-icons'; export default Vue.extend({ i18n: i18n('admin/views/instance.vue'), data() { return { + url, host: toUnicode(host), maintainerName: null, maintainerEmail: null, disableRegistration: false, disableLocalTimeline: false, + disableGlobalTimeline: false, + mascotImageUrl: null, bannerUrl: null, + errorImageUrl: null, name: null, description: null, languages: null, @@ -129,7 +177,21 @@ export default Vue.extend({ discordClientSecret: null, proxyAccount: null, inviteCode: null, - faHeadset, faShieldAlt, faGhost + enableExternalUserRecommendation: false, + externalUserRecommendationEngine: null, + externalUserRecommendationTimeout: null, + summalyProxy: null, + enableEmail: false, + email: null, + smtpSecure: false, + smtpHost: null, + smtpPort: null, + smtpUser: null, + smtpPass: null, + enableServiceWorker: false, + swPublicKey: null, + swPrivateKey: null, + faHeadset, faShieldAlt, faGhost, faUserPlus, farEnvelope, faBolt }; }, @@ -137,7 +199,12 @@ export default Vue.extend({ this.$root.getMeta().then(meta => { this.maintainerName = meta.maintainer.name; this.maintainerEmail = meta.maintainer.email; + this.disableRegistration = meta.disableRegistration; + this.disableLocalTimeline = meta.disableLocalTimeline; + this.disableGlobalTimeline = meta.disableGlobalTimeline; + this.mascotImageUrl = meta.mascotImageUrl; this.bannerUrl = meta.bannerUrl; + this.errorImageUrl = meta.errorImageUrl; this.name = meta.name; this.description = meta.description; this.languages = meta.langs.join(' '); @@ -158,6 +225,20 @@ export default Vue.extend({ this.enableDiscordIntegration = meta.enableDiscordIntegration; this.discordClientId = meta.discordClientId; this.discordClientSecret = meta.discordClientSecret; + this.enableExternalUserRecommendation = meta.enableExternalUserRecommendation; + this.externalUserRecommendationEngine = meta.externalUserRecommendationEngine; + this.externalUserRecommendationTimeout = meta.externalUserRecommendationTimeout; + this.summalyProxy = meta.summalyProxy; + this.enableEmail = meta.enableEmail; + this.email = meta.email; + this.smtpSecure = meta.smtpSecure; + this.smtpHost = meta.smtpHost; + this.smtpPort = meta.smtpPort; + this.smtpUser = meta.smtpUser; + this.smtpPass = meta.smtpPass; + this.enableServiceWorker = meta.enableServiceWorker; + this.swPublicKey = meta.swPublickey; + this.swPrivateKey = meta.swPrivateKey; }); }, @@ -166,7 +247,7 @@ export default Vue.extend({ this.$root.api('admin/invite').then(x => { this.inviteCode = x.code; }).catch(e => { - this.$root.alert({ + this.$root.dialog({ type: 'error', text: e }); @@ -179,7 +260,10 @@ export default Vue.extend({ maintainerEmail: this.maintainerEmail, disableRegistration: this.disableRegistration, disableLocalTimeline: this.disableLocalTimeline, + disableGlobalTimeline: this.disableGlobalTimeline, + mascotImageUrl: this.mascotImageUrl, bannerUrl: this.bannerUrl, + errorImageUrl: this.errorImageUrl, name: this.name, description: this.description, langs: this.languages.split(' '), @@ -199,14 +283,28 @@ export default Vue.extend({ githubClientSecret: this.githubClientSecret, enableDiscordIntegration: this.enableDiscordIntegration, discordClientId: this.discordClientId, - discordClientSecret: this.discordClientSecret + discordClientSecret: this.discordClientSecret, + enableExternalUserRecommendation: this.enableExternalUserRecommendation, + externalUserRecommendationEngine: this.externalUserRecommendationEngine, + externalUserRecommendationTimeout: parseInt(this.externalUserRecommendationTimeout, 10), + summalyProxy: this.summalyProxy, + enableEmail: this.enableEmail, + email: this.email, + smtpSecure: this.smtpSecure, + smtpHost: this.smtpHost, + smtpPort: parseInt(this.smtpPort, 10), + smtpUser: this.smtpUser, + smtpPass: this.smtpPass, + enableServiceWorker: this.enableServiceWorker, + swPublicKey: this.swPublicKey, + swPrivateKey: this.swPrivateKey }).then(() => { - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('saved') }); }).catch(e => { - this.$root.alert({ + this.$root.dialog({ type: 'error', text: e }); @@ -215,10 +313,3 @@ export default Vue.extend({ } }); </script> - -<style lang="stylus" scoped> -.axbwjelsbymowqjyywpirzhdlszoncqs - @media (min-width 500px) - padding 16px - -</style> diff --git a/src/client/app/admin/views/moderators.vue b/src/client/app/admin/views/moderators.vue index ba9417d969..c383b10f34 100644 --- a/src/client/app/admin/views/moderators.vue +++ b/src/client/app/admin/views/moderators.vue @@ -1,12 +1,13 @@ <template> -<div class="jnhmugbb"> +<div> <ui-card> <div slot="title"><fa icon="plus"/> {{ $t('add-moderator.title') }}</div> <section class="fit-top"> <ui-input v-model="username" type="text"> <span slot="prefix">@</span> </ui-input> - <ui-button @click="add" :disabled="adding">{{ $t('add-moderator.add') }}</ui-button> + <ui-button @click="add" :disabled="changing">{{ $t('add-moderator.add') }}</ui-button> + <ui-button @click="remove" :disabled="changing">{{ $t('add-moderator.remove') }}</ui-button> </section> </ui-card> </div> @@ -23,39 +24,54 @@ export default Vue.extend({ data() { return { username: '', - adding: false + changing: false }; }, methods: { async add() { - this.adding = true; + this.changing = true; const process = async () => { const user = await this.$root.api('users/show', parseAcct(this.username)); await this.$root.api('admin/moderators/add', { userId: user.id }); - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('add-moderator.added') }); }; await process().catch(e => { - this.$root.alert({ + this.$root.dialog({ type: 'error', text: e.toString() }); }); - this.adding = false; + this.changing = false; + }, + + async remove() { + this.changing = true; + + const process = async () => { + const user = await this.$root.api('users/show', parseAcct(this.username)); + await this.$root.api('admin/moderators/remove', { userId: user.id }); + this.$root.dialog({ + type: 'success', + text: this.$t('add-moderator.removed') + }); + }; + + await process().catch(e => { + this.$root.dialog({ + type: 'error', + text: e.toString() + }); + }); + + this.changing = false; }, } }); </script> - -<style lang="stylus" scoped> -.jnhmugbb - @media (min-width 500px) - padding 16px - -</style> diff --git a/src/client/app/admin/views/users.vue b/src/client/app/admin/views/users.vue index 693d5cea84..6f0f1629f1 100644 --- a/src/client/app/admin/views/users.vue +++ b/src/client/app/admin/views/users.vue @@ -1,42 +1,77 @@ <template> -<div class="ucnffhbtogqgscfmqcymwmmupoknpfsw"> +<div> <ui-card> - <div slot="title">{{ $t('verify-user') }}</div> + <div slot="title"><fa :icon="faTerminal"/> {{ $t('operation') }}</div> <section class="fit-top"> - <ui-input v-model="verifyUsername" type="text"> - <span slot="prefix">@</span> + <ui-input v-model="target" type="text"> + <span>{{ $t('username-or-userid') }}</span> </ui-input> - <ui-button @click="verifyUser" :disabled="verifying">{{ $t('verify') }}</ui-button> + <ui-button @click="resetPassword"><fa :icon="faKey"/> {{ $t('reset-password') }}</ui-button> + <ui-horizon-group> + <ui-button @click="verifyUser" :disabled="verifying"><fa :icon="faCertificate"/> {{ $t('verify') }}</ui-button> + <ui-button @click="unverifyUser" :disabled="unverifying">{{ $t('unverify') }}</ui-button> + </ui-horizon-group> + <ui-horizon-group> + <ui-button @click="suspendUser" :disabled="suspending"><fa :icon="faSnowflake"/> {{ $t('suspend') }}</ui-button> + <ui-button @click="unsuspendUser" :disabled="unsuspending">{{ $t('unsuspend') }}</ui-button> + </ui-horizon-group> + <ui-button @click="showUser"><fa :icon="faSearch"/> {{ $t('lookup') }}</ui-button> + <ui-textarea v-if="user" :value="user | json5" readonly tall style="margin-top:16px;"></ui-textarea> </section> </ui-card> <ui-card> - <div slot="title">{{ $t('unverify-user') }}</div> + <div slot="title"><fa :icon="faUsers"/> {{ $t('users.title') }}</div> <section class="fit-top"> - <ui-input v-model="unverifyUsername" type="text"> - <span slot="prefix">@</span> - </ui-input> - <ui-button @click="unverifyUser" :disabled="unverifying">{{ $t('unverify') }}</ui-button> - </section> - </ui-card> - - <ui-card> - <div slot="title">{{ $t('suspend-user') }}</div> - <section class="fit-top"> - <ui-input v-model="suspendUsername" type="text"> - <span slot="prefix">@</span> - </ui-input> - <ui-button @click="suspendUser" :disabled="suspending">{{ $t('suspend') }}</ui-button> - </section> - </ui-card> - - <ui-card> - <div slot="title">{{ $t('unsuspend-user') }}</div> - <section class="fit-top"> - <ui-input v-model="unsuspendUsername" type="text"> - <span slot="prefix">@</span> - </ui-input> - <ui-button @click="unsuspendUser" :disabled="unsuspending">{{ $t('unsuspend') }}</ui-button> + <ui-horizon-group inputs> + <ui-select v-model="sort"> + <span slot="label">{{ $t('users.sort.title') }}</span> + <option value="-createdAt">{{ $t('users.sort.createdAtAsc') }}</option> + <option value="+createdAt">{{ $t('users.sort.createdAtDesc') }}</option> + <option value="-updatedAt">{{ $t('users.sort.updatedAtAsc') }}</option> + <option value="+updatedAt">{{ $t('users.sort.updatedAtDesc') }}</option> + </ui-select> + <ui-select v-model="state"> + <span slot="label">{{ $t('users.state.title') }}</span> + <option value="all">{{ $t('users.state.all') }}</option> + <option value="admin">{{ $t('users.state.admin') }}</option> + <option value="moderator">{{ $t('users.state.moderator') }}</option> + <option value="verified">{{ $t('users.state.verified') }}</option> + <option value="suspended">{{ $t('users.state.suspended') }}</option> + </ui-select> + <ui-select v-model="origin"> + <span slot="label">{{ $t('users.origin.title') }}</span> + <option value="combined">{{ $t('users.origin.combined') }}</option> + <option value="local">{{ $t('users.origin.local') }}</option> + <option value="remote">{{ $t('users.origin.remote') }}</option> + </ui-select> + </ui-horizon-group> + <sequential-entrance animation="entranceFromTop" delay="25"> + <div class="kofvwchc" v-for="user in users" :key="user.id"> + <div> + <a :href="user | userPage(null, true)"> + <mk-avatar class="avatar" :user="user" :disable-link="true"/> + </a> + </div> + <div> + <header> + <b><mk-user-name :user="user"/></b> + <span class="username">@{{ user | acct }}</span> + <span class="is-admin" v-if="user.isAdmin">admin</span> + <span class="is-moderator" v-if="user.isModerator">moderator</span> + <span class="is-verified" v-if="user.isVerified" :title="$t('@.verified-user')"><fa icon="star"/></span> + <span class="is-suspended" v-if="user.isSuspended" :title="$t('@.suspended-user')"><fa :icon="faSnowflake"/></span> + </header> + <div> + <span>{{ $t('users.updatedAt') }}: <mk-time :time="user.updatedAt" mode="detail"/></span> + </div> + <div> + <span>{{ $t('users.createdAt') }}: <mk-time :time="user.createdAt" mode="detail"/></span> + </div> + </div> + </div> + </sequential-entrance> + <ui-button v-if="existMore" @click="fetchUsers">{{ $t('@.load-more') }}</ui-button> </section> </ui-card> </div> @@ -46,38 +81,105 @@ import Vue from 'vue'; import i18n from '../../i18n'; import parseAcct from "../../../../misc/acct/parse"; +import { faCertificate, faUsers, faTerminal, faSearch, faKey } from '@fortawesome/free-solid-svg-icons'; +import { faSnowflake } from '@fortawesome/free-regular-svg-icons'; export default Vue.extend({ i18n: i18n('admin/views/users.vue'), data() { return { - verifyUsername: null, + user: null, + target: null, verifying: false, - unverifyUsername: null, unverifying: false, - suspendUsername: null, suspending: false, - unsuspendUsername: null, - unsuspending: false + unsuspending: false, + sort: '+createdAt', + state: 'all', + origin: 'combined', + limit: 10, + offset: 0, + users: [], + existMore: false, + faTerminal, faCertificate, faUsers, faSnowflake, faSearch, faKey }; }, + watch: { + sort() { + this.users = []; + this.offset = 0; + this.fetchUsers(); + }, + + state() { + this.users = []; + this.offset = 0; + this.fetchUsers(); + }, + + origin() { + this.users = []; + this.offset = 0; + this.fetchUsers(); + } + }, + + mounted() { + this.fetchUsers(); + }, + methods: { + async fetchUser() { + try { + return await this.$root.api('users/show', this.target.startsWith('@') ? parseAcct(this.target) : { userId: this.target }); + } catch (e) { + if (e == 'user not found') { + this.$root.dialog({ + type: 'error', + text: this.$t('user-not-found') + }); + } else { + this.$root.dialog({ + type: 'error', + text: e.toString() + }); + } + } + }, + + async showUser() { + const user = await this.fetchUser(); + this.$root.api('admin/show-user', { userId: user.id }).then(info => { + this.user = info; + }); + }, + + async resetPassword() { + const user = await this.fetchUser(); + this.$root.api('admin/reset-password', { userId: user.id }).then(res => { + this.$root.dialog({ + type: 'success', + text: this.$t('password-updated', { password: res.password }) + }); + }); + }, + async verifyUser() { this.verifying = true; const process = async () => { - const user = await this.$root.api('users/show', parseAcct(this.verifyUsername)); + const user = await this.fetchUser(); await this.$root.api('admin/verify-user', { userId: user.id }); - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('verified') }); }; await process().catch(e => { - this.$root.alert({ + this.$root.dialog({ type: 'error', text: e.toString() }); @@ -90,16 +192,16 @@ export default Vue.extend({ this.unverifying = true; const process = async () => { - const user = await this.$root.api('users/show', parseAcct(this.unverifyUsername)); + const user = await this.fetchUser(); await this.$root.api('admin/unverify-user', { userId: user.id }); - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('unverified') }); }; await process().catch(e => { - this.$root.alert({ + this.$root.dialog({ type: 'error', text: e.toString() }); @@ -112,16 +214,16 @@ export default Vue.extend({ this.suspending = true; const process = async () => { - const user = await this.$root.api('users/show', parseAcct(this.suspendUsername)); + const user = await this.fetchUser(); await this.$root.api('admin/suspend-user', { userId: user.id }); - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('suspended') }); }; await process().catch(e => { - this.$root.alert({ + this.$root.dialog({ type: 'error', text: e.toString() }); @@ -134,30 +236,83 @@ export default Vue.extend({ this.unsuspending = true; const process = async () => { - const user = await this.$root.api('users/show', parseAcct(this.unsuspendUsername)); + const user = await this.fetchUser(); await this.$root.api('admin/unsuspend-user', { userId: user.id }); - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('unsuspended') }); }; await process().catch(e => { - this.$root.alert({ + this.$root.dialog({ type: 'error', text: e.toString() }); }); this.unsuspending = false; + }, + + fetchUsers() { + this.$root.api('admin/show-users', { + state: this.state, + origin: this.origin, + sort: this.sort, + offset: this.offset, + limit: this.limit + 1 + }).then(users => { + if (users.length == this.limit + 1) { + users.pop(); + this.existMore = true; + } else { + this.existMore = false; + } + this.users = this.users.concat(users); + this.offset += this.limit; + }); } } }); </script> <style lang="stylus" scoped> -.ucnffhbtogqgscfmqcymwmmupoknpfsw - @media (min-width 500px) - padding 16px +.kofvwchc + display flex + padding 16px 0 + border-top solid 1px var(--faceDivider) + + > div:first-child + > a + > .avatar + width 64px + height 64px + + > div:last-child + flex 1 + padding-left 16px + + @media (max-width 500px) + font-size 14px + + > header + > .username + margin-left 8px + opacity 0.7 + + > .is-admin + > .is-moderator + flex-shrink 0 + align-self center + margin 0 0 0 .5em + padding 1px 6px + font-size 80% + border-radius 3px + background var(--noteHeaderAdminBg) + color var(--noteHeaderAdminFg) + > .is-verified + > .is-suspended + margin 0 0 0 .5em + color #4dabf7 </style> diff --git a/src/client/app/animation.styl b/src/client/app/animation.styl index 8f121b313b..a629165207 100644 --- a/src/client/app/animation.styl +++ b/src/client/app/animation.styl @@ -10,3 +10,19 @@ opacity: 0; transform: scaleY(0); } + +.entranceFromTop { + animation-duration: 0.5s; + animation-name: entranceFromTop; +} + +@keyframes entranceFromTop { + from { + opacity: 0; + transform: translateY(-64px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/client/app/auth/script.ts b/src/client/app/auth/script.ts index 3d916e8d79..91bb24b108 100644 --- a/src/client/app/auth/script.ts +++ b/src/client/app/auth/script.ts @@ -9,6 +9,7 @@ import './style.styl'; import init from '../init'; import Index from './views/index.vue'; +import NotFound from '../common/views/pages/not-found.vue'; /** * init @@ -20,6 +21,7 @@ init(launch => { base: '/auth/', routes: [ { path: '/:token', component: Index }, + { path: '*', component: NotFound } ] }); diff --git a/src/client/app/boot.js b/src/client/app/boot.js index 76ea41c649..4e9d3192c7 100644 --- a/src/client/app/boot.js +++ b/src/client/app/boot.js @@ -17,9 +17,9 @@ //#region Apply theme const theme = localStorage.getItem('theme'); if (theme) { - Object.entries(JSON.parse(theme)).forEach(([k, v]) => { + for (const [k, v] of Object.entries(JSON.parse(theme))) { document.documentElement.style.setProperty(`--${k}`, v.toString()); - }); + } } //#endregion @@ -41,6 +41,7 @@ if (`${url.pathname}/`.startsWith('/dev/')) app = 'dev'; if (`${url.pathname}/`.startsWith('/auth/')) app = 'auth'; if (`${url.pathname}/`.startsWith('/admin/')) app = 'admin'; + if (`${url.pathname}/`.startsWith('/test/')) app = 'test'; //#endregion // Script version @@ -159,7 +160,7 @@ navigator.serviceWorker.controller.postMessage('clear'); navigator.serviceWorker.getRegistrations().then(registrations => { - registrations.forEach(registration => registration.unregister()); + for (const registration of registrations) registration.unregister(); }); } catch (e) { console.error(e); diff --git a/src/client/app/common/define-widget.ts b/src/client/app/common/define-widget.ts index 56314a4104..5eb9718446 100644 --- a/src/client/app/common/define-widget.ts +++ b/src/client/app/common/define-widget.ts @@ -1,6 +1,6 @@ import Vue from 'vue'; -export default function<T extends object>(data: { +export default function <T extends object>(data: { name: string; props?: () => T; }) { @@ -53,11 +53,10 @@ export default function<T extends object>(data: { mergeProps() { if (data.props) { const defaultProps = data.props(); - Object.keys(defaultProps).forEach(prop => { - if (!this.props.hasOwnProperty(prop)) { - Vue.set(this.props, prop, defaultProps[prop]); - } - }); + for (const prop of Object.keys(defaultProps)) { + if (this.props.hasOwnProperty(prop)) continue; + Vue.set(this.props, prop, defaultProps[prop]); + } } }, diff --git a/src/client/app/common/hotkey.ts b/src/client/app/common/hotkey.ts index f7366e35cb..b2afd57ae3 100644 --- a/src/client/app/common/hotkey.ts +++ b/src/client/app/common/hotkey.ts @@ -28,15 +28,15 @@ const getKeyMap = keymap => Object.entries(keymap).map(([patterns, callback]): a shift: false } as pattern; - part.trim().split('+').forEach(key => { - key = key.trim().toLowerCase(); + const keys = part.trim().split('+').map(x => x.trim().toLowerCase()); + for (const key of keys) { switch (key) { case 'ctrl': pattern.ctrl = true; break; case 'alt': pattern.alt = true; break; case 'shift': pattern.shift = true; break; default: pattern.which = keyCode(key).map(k => k.toLowerCase()); } - }); + } return pattern; }); @@ -77,11 +77,7 @@ export default { const matched = match(e, action.patterns); if (matched) { - if (el._hotkey_global) { - if (match(e, targetReservedKeys)) { - return; - } - } + if (el._hotkey_global && match(e, targetReservedKeys)) return; e.preventDefault(); e.stopPropagation(); diff --git a/src/client/app/common/scripts/check-for-update.ts b/src/client/app/common/scripts/check-for-update.ts index 7fe9d8d50c..20da83a0c2 100644 --- a/src/client/app/common/scripts/check-for-update.ts +++ b/src/client/app/common/scripts/check-for-update.ts @@ -14,19 +14,20 @@ export default async function($root: any, force = false, silent = false) { navigator.serviceWorker.controller.postMessage('clear'); } - navigator.serviceWorker.getRegistrations().then(registrations => { - registrations.forEach(registration => registration.unregister()); - }); + const registrations = await navigator.serviceWorker.getRegistrations(); + for (const registration of registrations) { + registration.unregister(); + } } catch (e) { console.error(e); } - if (!silent) { - $root.alert({ + /*if (!silent) { + $root.dialog({ title: $root.$t('@.update-available-title'), text: $root.$t('@.update-available', { newer, current }) }); - } + }*/ return newer; } else { diff --git a/src/client/app/common/scripts/compose-notification.ts b/src/client/app/common/scripts/compose-notification.ts index 65087cc98e..f65672ee30 100644 --- a/src/client/app/common/scripts/compose-notification.ts +++ b/src/client/app/common/scripts/compose-notification.ts @@ -22,7 +22,7 @@ export default function(type, data): Notification { case 'unreadMessagingMessage': return { - title: '%i18n:common.notification.message-from%'.split("{}")[0] + `${getUserName(data.user)}` + '%i18n:common.notification.message-from%'.split("{}")[1] , + title: '%i18n:common.notification.message-from%'.split('{}')[0] + `${getUserName(data.user)}` + '%i18n:common.notification.message-from%'.split('{}')[1] , body: data.text, // TODO: getMessagingMessageSummary(data), icon: data.user.avatarUrl }; @@ -30,7 +30,7 @@ export default function(type, data): Notification { case 'reversiInvited': return { title: '%i18n:common.notification.reversi-invited%', - body: '%i18n:common.notification.reversi-invited-by%'.split("{}")[0] + `${getUserName(data.parent)}` + '%i18n:common.notification.reversi-invited-by%'.split("{}")[1], + body: '%i18n:common.notification.reversi-invited-by%'.split('{}')[0] + `${getUserName(data.parent)}` + '%i18n:common.notification.reversi-invited-by%'.split('{}')[1], icon: data.parent.avatarUrl }; @@ -38,21 +38,21 @@ export default function(type, data): Notification { switch (data.type) { case 'mention': return { - title: '%i18n:common.notification.notified-by%'.split("{}")[0] + `${getUserName(data.user)}:` + '%i18n:common.notification.notified-by%'.split("{}")[1], + title: '%i18n:common.notification.notified-by%'.split('{}')[0] + `${getUserName(data.user)}:` + '%i18n:common.notification.notified-by%'.split('{}')[1], body: getNoteSummary(data), icon: data.user.avatarUrl }; case 'reply': return { - title: '%i18n:common.notification.reply-from%'.split("{}")[0] + `${getUserName(data.user)}` + '%i18n:common.notification.reply-from%'.split("{}")[1], + title: '%i18n:common.notification.reply-from%'.split('{}')[0] + `${getUserName(data.user)}` + '%i18n:common.notification.reply-from%'.split('{}')[1], body: getNoteSummary(data), icon: data.user.avatarUrl }; case 'quote': return { - title: '%i18n:common.notification.quoted-by%'.split("{}")[0] + `${getUserName(data.user)}` + '%i18n:common.notification.quoted-by%'.split("{}")[1], + title: '%i18n:common.notification.quoted-by%'.split('{}')[0] + `${getUserName(data.user)}` + '%i18n:common.notification.quoted-by%'.split('{}')[1], body: getNoteSummary(data), icon: data.user.avatarUrl }; diff --git a/src/client/app/common/scripts/format-uptime.ts b/src/client/app/common/scripts/format-uptime.ts new file mode 100644 index 0000000000..6550e4cc39 --- /dev/null +++ b/src/client/app/common/scripts/format-uptime.ts @@ -0,0 +1,25 @@ + +/** + * Format like the uptime command + */ +export default function(sec) { + if (!sec) return sec; + + const day = Math.floor(sec / 86400); + const tod = sec % 86400; + + // Days part in string: 2 days, 1 day, null + const d = day >= 2 ? `${day} days` : day >= 1 ? `${day} day` : null; + + // Time part in string: 1 sec, 1 min, 1:01 + const t + = tod < 60 ? `${Math.floor(tod)} sec` + : tod < 3600 ? `${Math.floor(tod / 60)} min` + : `${Math.floor(tod / 60 / 60)}:${Math.floor((tod / 60) % 60).toString().padStart(2, '0')}`; + + let str = ''; + if (d) str += `${d}, `; + str += t; + + return str; +} diff --git a/src/client/app/common/scripts/fuck-ad-block.ts b/src/client/app/common/scripts/fuck-ad-block.ts index f5cc1b71f2..ba7e5a9f87 100644 --- a/src/client/app/common/scripts/fuck-ad-block.ts +++ b/src/client/app/common/scripts/fuck-ad-block.ts @@ -4,7 +4,7 @@ export default ($root: any) => { require('fuckadblock'); function adBlockDetected() { - $root.alert({ + $root.dialog({ title: $root.$t('@.adblock.detected'), text: $root.$t('@.adblock.warning') }); diff --git a/src/client/app/common/scripts/get-face.ts b/src/client/app/common/scripts/get-face.ts index 79cf7a1be4..b523948bd3 100644 --- a/src/client/app/common/scripts/get-face.ts +++ b/src/client/app/common/scripts/get-face.ts @@ -2,7 +2,7 @@ const faces = [ '(=^・・^=)', 'v(\'ω\')v', '🐡( \'-\' 🐡 )フグパンチ!!!!', - '🖕(´・_・`)🖕', + '✌️(´・_・`)✌️', '(。>﹏<。)', '(Δ・x・Δ)' ]; diff --git a/src/client/app/common/scripts/get-md5.ts b/src/client/app/common/scripts/get-md5.ts index 51a45b30d5..b78a598118 100644 --- a/src/client/app/common/scripts/get-md5.ts +++ b/src/client/app/common/scripts/get-md5.ts @@ -3,8 +3,8 @@ export default (data: ArrayBuffer) => { //const buf = new Buffer(data); - //const hash = crypto.createHash("md5"); + //const hash = crypto.createHash('md5'); //hash.update(buf); - //return hash.digest("hex"); + //return hash.digest('hex'); return ''; }; diff --git a/src/client/app/common/scripts/get-median.ts b/src/client/app/common/scripts/get-median.ts deleted file mode 100644 index 91a415d5b2..0000000000 --- a/src/client/app/common/scripts/get-median.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * 中央値を求めます - * @param samples サンプル - */ -export default function(samples) { - if (!samples.length) return 0; - const numbers = samples.slice(0).sort((a, b) => a - b); - const middle = Math.floor(numbers.length / 2); - const isEven = numbers.length % 2 === 0; - return isEven ? (numbers[middle] + numbers[middle - 1]) / 2 : numbers[middle]; -} diff --git a/src/client/app/common/scripts/note-mixin.ts b/src/client/app/common/scripts/note-mixin.ts index e0df788b34..047e2ae55e 100644 --- a/src/client/app/common/scripts/note-mixin.ts +++ b/src/client/app/common/scripts/note-mixin.ts @@ -65,6 +65,10 @@ export default (opts: Opts = {}) => ({ return this.isRenote ? this.note.renote : this.note; }, + isMyNote(): boolean { + return this.$store.getters.isSignedIn && (this.$store.state.i.id === this.appearNote.userId); + }, + reactionsCount(): number { return this.appearNote.reactionCounts ? sum(Object.values(this.appearNote.reactionCounts)) @@ -72,15 +76,16 @@ export default (opts: Opts = {}) => ({ }, title(): string { - return new Date(this.appearNote.createdAt).toLocaleString(); + return ''; }, urls(): string[] { if (this.appearNote.text) { const ast = parse(this.appearNote.text); + // TODO: 再帰的にURL要素がないか調べる return unique(ast - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => t.url)); + .filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent)) + .map(t => t.node.props.url)); } else { return null; } @@ -124,9 +129,7 @@ export default (opts: Opts = {}) => ({ source: this.$refs.reactButton, note: this.appearNote, showFocus: viaKeyboard, - animation: !viaKeyboard, - compact: opts.mobile, - big: opts.mobile + animation: !viaKeyboard }).$once('closed', this.focus); }, @@ -137,11 +140,19 @@ export default (opts: Opts = {}) => ({ }); }, + undoReact(note) { + const oldReaction = note.myReaction; + if (!oldReaction) return; + this.$root.api('notes/reactions/delete', { + noteId: note.id + }); + }, + favorite() { this.$root.api('notes/favorites/create', { noteId: this.appearNote.id }).then(() => { - this.$root.alert({ + this.$root.dialog({ type: 'success', splash: true }); @@ -158,8 +169,7 @@ export default (opts: Opts = {}) => ({ this.$root.new(MkNoteMenu, { source: this.$refs.menuButton, note: this.appearNote, - animation: !viaKeyboard, - compact: opts.mobile, + animation: !viaKeyboard }).$once('closed', this.focus); }, diff --git a/src/client/app/common/scripts/note-subscriber.ts b/src/client/app/common/scripts/note-subscriber.ts index bc434c4360..9545b5406b 100644 --- a/src/client/app/common/scripts/note-subscriber.ts +++ b/src/client/app/common/scripts/note-subscriber.ts @@ -95,6 +95,7 @@ export default prop => ({ Vue.set(this.$_ns_target.reactionCounts, reaction, 0); } + // Increment the count this.$_ns_target.reactionCounts[reaction]++; if (body.userId == this.$store.state.i.id) { @@ -103,6 +104,26 @@ export default prop => ({ break; } + case 'unreacted': { + const reaction = body.reaction; + + if (this.$_ns_target.reactionCounts == null) { + return; + } + + if (this.$_ns_target.reactionCounts[reaction] == null) { + return; + } + + // Decrement the count + if (this.$_ns_target.reactionCounts[reaction] > 0) this.$_ns_target.reactionCounts[reaction]--; + + if (body.userId == this.$store.state.i.id) { + Vue.set(this.$_ns_target, 'myReaction', null); + } + break; + } + case 'pollVoted': { if (body.userId == this.$store.state.i.id) return; const choice = body.choice; diff --git a/src/client/app/common/scripts/should-mute-note.ts b/src/client/app/common/scripts/should-mute-note.ts index a849135763..8a6430b1df 100644 --- a/src/client/app/common/scripts/should-mute-note.ts +++ b/src/client/app/common/scripts/should-mute-note.ts @@ -2,27 +2,17 @@ export default function(me, settings, note) { const isMyNote = note.userId == me.id; const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null; - if (settings.showMyRenotes === false) { - if (isMyNote && isPureRenote) { - return true; - } - } + const includesMutedWords = (text: string) => + text + ? settings.mutedWords.some(q => q.length > 0 && !q.some(word => !text.includes(word))) + : false; - if (settings.showRenotedMyNotes === false) { - if (isPureRenote && (note.renote.userId == me.id)) { - return true; - } - } - - if (settings.showLocalRenotes === false) { - if (isPureRenote && (note.renote.user.host == null)) { - return true; - } - } - - if (!isMyNote && note.text && settings.mutedWords.some(q => !q.some(word => !note.text.includes(word)))) { - return true; - } - - return false; + return ( + (!isMyNote && note.reply && includesMutedWords(note.reply.text)) || + (!isMyNote && note.renote && includesMutedWords(note.renote.text)) || + (settings.showMyRenotes === false && isMyNote && isPureRenote) || + (settings.showRenotedMyNotes === false && isPureRenote && note.renote.userId == me.id) || + (settings.showLocalRenotes === false && isPureRenote && note.renote.user.host == null) || + (!isMyNote && includesMutedWords(note.text)) + ); } diff --git a/src/client/app/common/scripts/stream.ts b/src/client/app/common/scripts/stream.ts index 345b112b15..23f839ae85 100644 --- a/src/client/app/common/scripts/stream.ts +++ b/src/client/app/common/scripts/stream.ts @@ -75,12 +75,10 @@ export default class Stream extends EventEmitter { // チャンネル再接続 if (isReconnect) { - this.sharedConnectionPools.forEach(p => { + for (const p of this.sharedConnectionPools) p.connect(); - }); - this.nonSharedConnections.forEach(c => { + for (const c of this.nonSharedConnections) c.connect(); - }); } } @@ -113,9 +111,9 @@ export default class Stream extends EventEmitter { connections = [this.nonSharedConnections.find(c => c.id === id)]; } - connections.filter(c => c != null).forEach(c => { + for (const c of connections.filter(c => c != null)) { c.emit(body.type, body.body); - }); + } } else { this.emit(type, body); } diff --git a/src/client/app/common/views/components/acct.vue b/src/client/app/common/views/components/acct.vue index 69259ab60f..2730e4139c 100644 --- a/src/client/app/common/views/components/acct.vue +++ b/src/client/app/common/views/components/acct.vue @@ -2,6 +2,7 @@ <span class="mk-acct"> <span class="name">@{{ user.username }}</span> <span class="host" :class="{ fade: $store.state.settings.contrastedAcct }" v-if="user.host || detail || $store.state.settings.showFullAcct">@{{ user.host || host }}</span> + <fa v-if="user.isLocked == true" class="locked" icon="lock" fixed-width/> </span> </template> @@ -23,4 +24,8 @@ export default Vue.extend({ .mk-acct > .host.fade opacity 0.5 + + > .locked + opacity 0.8 + margin-left 0.5em </style> diff --git a/src/client/app/common/views/components/analog-clock.vue b/src/client/app/common/views/components/analog-clock.vue index 43ae2ca933..4ba578a1a4 100644 --- a/src/client/app/common/views/components/analog-clock.vue +++ b/src/client/app/common/views/components/analog-clock.vue @@ -32,7 +32,7 @@ <script lang="ts"> import Vue from 'vue'; -import { themeColor } from '../../../config'; +import * as tinycolor from 'tinycolor2'; export default Vue.extend({ props: { @@ -75,7 +75,7 @@ export default Vue.extend({ return this.dark ? '#fff' : '#777'; }, hHandColor(): string { - return themeColor; + return tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--primary')).toHexString(); }, ms(): number { diff --git a/src/client/app/common/views/components/api-settings.vue b/src/client/app/common/views/components/api-settings.vue index 062218b3f4..e96eb28d93 100644 --- a/src/client/app/common/views/components/api-settings.vue +++ b/src/client/app/common/views/components/api-settings.vue @@ -50,10 +50,13 @@ export default Vue.extend({ methods: { regenerateToken() { - this.$input({ + this.$root.dialog({ title: this.$t('enter-password'), - type: 'password' - }).then(password => { + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; this.$root.api('i/regenerate_token', { password: password }); diff --git a/src/client/app/common/views/components/autocomplete.vue b/src/client/app/common/views/components/autocomplete.vue index 01461c7280..e33e4ae8c5 100644 --- a/src/client/app/common/views/components/autocomplete.vue +++ b/src/client/app/common/views/components/autocomplete.vue @@ -3,7 +3,9 @@ <ol class="users" ref="suggests" v-if="users.length > 0"> <li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1"> <img class="avatar" :src="user.avatarUrl" alt=""/> - <span class="name">{{ user | userName }}</span> + <span class="name"> + <mk-user-name :user="user"/> + </span> <span class="username">@{{ user | acct }}</span> </li> </ol> @@ -42,8 +44,9 @@ const lib = Object.entries(emojilib.lib).filter((x: any) => { }); const char2file = (char: string) => { - let codes = [...char].map(x => x.codePointAt(0).toString(16)); + let codes = Array.from(char).map(x => x.codePointAt(0).toString(16)); if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f'); + codes = codes.filter(x => x && x.length); return codes.join('-'); }; @@ -54,18 +57,18 @@ const emjdb: EmojiDef[] = lib.map((x: any) => ({ url: `https://twemoji.maxcdn.com/2/svg/${char2file(x[1].char)}.svg` })); -lib.forEach((x: any) => { +for (const x of lib as any) { if (x[1].keywords) { - x[1].keywords.forEach(k => { + for (const k of x[1].keywords) { emjdb.push({ emoji: x[1].char, name: k, aliasOf: x[0], url: `https://twemoji.maxcdn.com/2/svg/${char2file(x[1].char)}.svg` }); - }); + } } -}); +} emjdb.sort((a, b) => a.name.length - b.name.length); @@ -117,7 +120,7 @@ export default Vue.extend({ const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || []; const emojiDefinitions: EmojiDef[] = []; - customEmojis.forEach(x => { + for (const x of customEmojis) { emojiDefinitions.push({ name: x.name, emoji: `:${x.name}:`, @@ -126,7 +129,7 @@ export default Vue.extend({ }); if (x.aliases) { - x.aliases.forEach(alias => { + for (const alias of x.aliases) { emojiDefinitions.push({ name: alias, aliasOf: x.name, @@ -134,9 +137,9 @@ export default Vue.extend({ url: x.url, isCustomEmoji: true }); - }); + } } - }); + } emojiDefinitions.sort((a, b) => a.name.length - b.name.length); @@ -145,9 +148,9 @@ export default Vue.extend({ this.textarea.addEventListener('keydown', this.onKeydown); - Array.from(document.querySelectorAll('body *')).forEach(el => { + for (const el of Array.from(document.querySelectorAll('body *'))) { el.addEventListener('mousedown', this.onMousedown); - }); + } this.$nextTick(() => { this.exec(); @@ -163,18 +166,18 @@ export default Vue.extend({ beforeDestroy() { this.textarea.removeEventListener('keydown', this.onKeydown); - Array.from(document.querySelectorAll('body *')).forEach(el => { + for (const el of Array.from(document.querySelectorAll('body *'))) { el.removeEventListener('mousedown', this.onMousedown); - }); + } }, methods: { exec() { this.select = -1; if (this.$refs.suggests) { - Array.from(this.items).forEach(el => { + for (const el of Array.from(this.items)) { el.removeAttribute('data-selected'); - }); + } } if (this.type == 'user') { @@ -187,7 +190,8 @@ export default Vue.extend({ } else { this.$root.api('users/search', { query: this.q, - limit: 30 + limit: 10, + detail: false }).then(users => { this.users = users; this.fetching = false; @@ -312,9 +316,9 @@ export default Vue.extend({ }, applySelect() { - Array.from(this.items).forEach(el => { + for (const el of Array.from(this.items)) { el.removeAttribute('data-selected'); - }); + } this.items[this.select].setAttribute('data-selected', 'true'); (this.items[this.select] as any).focus(); diff --git a/src/client/app/common/views/components/cw-button.vue b/src/client/app/common/views/components/cw-button.vue index bda39f2d48..098aa021d1 100644 --- a/src/client/app/common/views/components/cw-button.vue +++ b/src/client/app/common/views/components/cw-button.vue @@ -1,21 +1,43 @@ <template> -<button class="nrvgflfuaxwgkxoynpnumyookecqrrvh" @click="toggle">{{ value ? this.$t('hide') : this.$t('show') }}</button> +<button class="nrvgflfuaxwgkxoynpnumyookecqrrvh" @click="toggle"> + <b>{{ value ? this.$t('hide') : this.$t('show') }}</b> + <span v-if="!value">{{ this.label }}</span> +</button> </template> <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; +import { length } from 'stringz'; +import { concat } from '../../../../../prelude/array'; export default Vue.extend({ i18n: i18n('common/views/components/cw-button.vue'), + props: { value: { type: Boolean, required: true + }, + note: { + type: Object, + required: true + } + }, + + computed: { + label(): string { + return concat([ + this.note.text ? [this.$t('chars', { count: length(this.note.text) })] : [], + this.note.files && this.note.files.length !== 0 ? [this.$t('files', { count: this.note.files.length }) ] : [], + this.note.poll != null ? [this.$t('poll')] : [] + ] as string[][]).join(' / '); } }, methods: { + length, + toggle() { this.$emit('input', !this.value); } @@ -37,4 +59,12 @@ export default Vue.extend({ &:hover background var(--cwButtonHoverBg) + > span + margin-left 4px + + &:before + content '(' + &:after + content ')' + </style> diff --git a/src/client/app/common/views/components/alert.vue b/src/client/app/common/views/components/dialog.vue index 27d876c87a..9eb9151a02 100644 --- a/src/client/app/common/views/components/alert.vue +++ b/src/client/app/common/views/components/dialog.vue @@ -2,12 +2,17 @@ <div class="felqjxyj" :class="{ splash }"> <div class="bg" ref="bg" @click="onBgClick"></div> <div class="main" ref="main"> - <div class="icon" :class="type"><fa :icon="icon"/></div> + <div class="icon" v-if="!input && !select && !user" :class="type"><fa :icon="icon"/></div> <header v-if="title" v-html="title"></header> <div class="body" v-if="text" v-html="text"></div> - <ui-horizon-group no-grow class="buttons" v-if="!splash"> - <ui-button @click="ok" primary autofocus>OK</ui-button> - <ui-button @click="cancel" v-if="showCancelButton">Cancel</ui-button> + <ui-input v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></ui-input> + <ui-input v-if="user" v-model="userInputValue" autofocus @keydown="onInputKeydown"><span slot="prefix">@</span></ui-input> + <ui-select v-if="select" v-model="selectedValue"> + <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> + </ui-select> + <ui-horizon-group no-grow class="buttons fit-bottom" v-if="!splash"> + <ui-button @click="ok" primary :autofocus="!input && !select && !user">OK</ui-button> + <ui-button @click="cancel" v-if="showCancelButton || input || select || user">Cancel</ui-button> </ui-horizon-group> </div> </div> @@ -15,8 +20,9 @@ <script lang="ts"> import Vue from 'vue'; -import * as anime from 'animejs'; +import anime from 'animejs'; import { faTimesCircle, faQuestionCircle } from '@fortawesome/free-regular-svg-icons'; +import parseAcct from "../../../../../misc/acct/parse"; export default Vue.extend({ props: { @@ -33,6 +39,15 @@ export default Vue.extend({ type: String, required: false }, + input: { + required: false + }, + select: { + required: false + }, + user: { + required: false + }, showCancelButton: { type: Boolean, default: false @@ -43,6 +58,14 @@ export default Vue.extend({ } }, + data() { + return { + inputValue: this.input && this.input.default ? this.input.default : null, + userInputValue: null, + selectedValue: null + }; + }, + computed: { icon(): any { switch (this.type) { @@ -70,7 +93,7 @@ export default Vue.extend({ opacity: 1, scale: [1.2, 1], duration: 300, - easing: [0, 0.5, 0.5, 1] + easing: 'cubicBezier(0, 0.5, 0.5, 1)' }); if (this.splash) { @@ -82,9 +105,21 @@ export default Vue.extend({ }, methods: { - ok() { - this.$emit('ok'); - this.close(); + async ok() { + if (this.user) { + const user = await this.$root.api('users/show', parseAcct(this.userInputValue)); + if (user) { + this.$emit('ok', user); + this.close(); + } + } else { + const result = + this.input ? this.inputValue : + this.select ? this.selectedValue : + true; + this.$emit('ok', result); + this.close(); + } }, cancel() { @@ -107,13 +142,21 @@ export default Vue.extend({ opacity: 0, scale: 0.8, duration: 300, - easing: [0, 0.5, 0.5, 1], + easing: 'cubicBezier(0, 0.5, 0.5, 1)', complete: () => this.destroyDom() }); }, onBgClick() { this.cancel(); + }, + + onInputKeydown(e) { + if (e.which == 13) { // Enter + e.preventDefault(); + e.stopPropagation(); + this.ok(); + } } } }); @@ -180,8 +223,11 @@ export default Vue.extend({ display block margin 0 auto + & + header + margin-top 16px + > header - margin 16px 0 8px 0 + margin 0 0 8px 0 font-weight bold font-size 20px diff --git a/src/client/app/common/views/components/discord-setting.vue b/src/client/app/common/views/components/discord-setting.vue deleted file mode 100644 index 113df9b0ae..0000000000 --- a/src/client/app/common/views/components/discord-setting.vue +++ /dev/null @@ -1,64 +0,0 @@ -<template> -<div class="mk-discord-setting"> - <p>{{ $t('description') }}</p> - <p class="account" v-if="$store.state.i.discord" :title="`Discord ID: ${$store.state.i.discord.id}`">{{ $t('connected-to') }}: <a :href="`https://discordapp.com/users/${$store.state.i.discord.id}`" target="_blank">@{{ $store.state.i.discord.username }}#{{ $store.state.i.discord.discriminator }}</a></p> - <p> - <a :href="`${apiUrl}/connect/discord`" target="_blank" @click.prevent="connect">{{ $store.state.i.discord ? this.$t('reconnect') : this.$t('connect') }}</a> - <span v-if="$store.state.i.discord"> or </span> - <a :href="`${apiUrl}/disconnect/discord`" target="_blank" v-if="$store.state.i.discord" @click.prevent="disconnect">{{ $t('disconnect') }}</a> - </p> - <p class="id" v-if="$store.state.i.discord">Discord ID: {{ $store.state.i.discord.id }}</p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { apiUrl } from '../../../config'; - -export default Vue.extend({ - i18n: i18n('common/views/components/discord-setting.vue'), - data() { - return { - form: null, - apiUrl - }; - }, - mounted() { - this.$watch('$store.state.i', () => { - if (this.$store.state.i.discord && this.form) - this.form.close(); - }, { - deep: true - }); - }, - methods: { - connect() { - this.form = window.open(apiUrl + '/connect/discord', - 'discord_connect_window', - 'height=570, width=520'); - }, - - disconnect() { - window.open(apiUrl + '/disconnect/discord', - 'discord_disconnect_window', - 'height=570, width=520'); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-discord-setting - .account - border solid 1px #e1e8ed - border-radius 4px - padding 16px - - a - font-weight bold - color inherit - - .id - color #8899a6 -</style> diff --git a/src/client/app/common/views/components/drive-settings.vue b/src/client/app/common/views/components/drive-settings.vue index a173810f68..0bd0ed2fb4 100644 --- a/src/client/app/common/views/components/drive-settings.vue +++ b/src/client/app/common/views/components/drive-settings.vue @@ -93,8 +93,7 @@ export default Vue.extend({ }, plotOptions: { bar: { - columnWidth: '90%', - endingShape: 'rounded' + columnWidth: '90%' } }, grid: { diff --git a/src/client/app/common/views/components/emoji-picker.vue b/src/client/app/common/views/components/emoji-picker.vue index 8181047167..f9164ad524 100644 --- a/src/client/app/common/views/components/emoji-picker.vue +++ b/src/client/app/common/views/components/emoji-picker.vue @@ -114,11 +114,11 @@ export default Vue.extend({ }, onScroll(e) { - const section = this.categories.forEach(x => { + for (const x of this.categories) { const top = e.target.scrollTop; const el = this.$refs[x.ref][0]; x.isActive = el.offsetTop <= top && el.offsetTop + el.offsetHeight > top; - }); + } }, chosen(emoji) { diff --git a/src/client/app/common/views/components/emoji.vue b/src/client/app/common/views/components/emoji.vue index a8fef35b8a..29b09947e4 100644 --- a/src/client/app/common/views/components/emoji.vue +++ b/src/client/app/common/views/components/emoji.vue @@ -1,5 +1,5 @@ <template> -<img v-if="customEmoji" class="fvgwvorwhxigeolkkrcderjzcawqrscl custom" :src="url" :alt="alt" :title="alt"/> +<img v-if="customEmoji" class="fvgwvorwhxigeolkkrcderjzcawqrscl custom" :class="{ normal: normal }" :src="url" :alt="alt" :title="alt"/> <img v-else-if="char && !useOsDefaultEmojis" class="fvgwvorwhxigeolkkrcderjzcawqrscl" :src="url" :alt="alt" :title="alt"/> <span v-else-if="char && useOsDefaultEmojis">{{ char }}</span> <span v-else>:{{ name }}:</span> @@ -20,6 +20,11 @@ export default Vue.extend({ type: String, required: false }, + normal: { + type: Boolean, + required: false, + default: false + }, customEmojis: { required: false, default: () => [] @@ -61,8 +66,9 @@ export default Vue.extend({ } if (this.char) { - let codes = [...this.char].map(x => x.codePointAt(0).toString(16)); + let codes = Array.from(this.char).map(x => x.codePointAt(0).toString(16)); if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f'); + codes = codes.filter(x => x && x.length); this.url = `https://twemoji.maxcdn.com/2/svg/${codes.join('-')}.svg`; } @@ -83,4 +89,11 @@ export default Vue.extend({ &:hover transform scale(1.2) + &.normal + height 1.25em + vertical-align -0.25em + + &:hover + transform none + </style> diff --git a/src/client/app/common/views/components/follow-button.vue b/src/client/app/common/views/components/follow-button.vue index d88a11aca8..6d120f52b4 100644 --- a/src/client/app/common/views/components/follow-button.vue +++ b/src/client/app/common/views/components/follow-button.vue @@ -48,7 +48,7 @@ export default Vue.extend({ iconAndText(): any[] { return ( (this.hasPendingFollowRequestFromYou && this.user.isLocked) ? ['hourglass-half', this.$t('request-pending')] : - (this.hasPendingFollowRequestFromYou && !this.user.isLocked) ? ['hourglass-start', this.$t('follow-processing')] : + (this.hasPendingFollowRequestFromYou && !this.user.isLocked) ? ['spinner', this.$t('follow-processing')] : (this.isFollowing) ? ['minus', this.$t('following')] : (!this.isFollowing && this.user.isLocked) ? ['plus', this.$t('follow-request')] : (!this.isFollowing && !this.user.isLocked) ? ['plus', this.$t('follow')] : diff --git a/src/client/app/common/views/components/formula-core.vue b/src/client/app/common/views/components/formula-core.vue index 930f16b471..254e0df308 100644 --- a/src/client/app/common/views/components/formula-core.vue +++ b/src/client/app/common/views/components/formula-core.vue @@ -15,7 +15,9 @@ export default Vue.extend({ }, computed: { compiledFormula(): any { - return katex.renderToString(this.formula); + return katex.renderToString(this.formula, { + throwOnError: false + } as any); } } }); diff --git a/src/client/app/common/views/components/games/reversi/reversi.game.vue b/src/client/app/common/views/components/games/reversi/reversi.game.vue index 14c0c0891c..7b4e5cf164 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.game.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.game.vue @@ -1,16 +1,21 @@ <template> <div class="xqnhankfuuilcwvhgsopeqncafzsquya"> <button class="go-index" v-if="selfNav" @click="goIndex"><fa icon="arrow-left"/></button> - <header><b><router-link :to="blackUser | userPage">{{ blackUser | userName }}</router-link></b>({{ $t('@.reversi.black') }}) vs <b><router-link :to="whiteUser | userPage">{{ whiteUser | userName }}</router-link></b>({{ $t('@.reversi.white') }})</header> + <header><b><router-link :to="blackUser | userPage"><mk-user-name :user="blackUser"/></router-link></b>({{ $t('@.reversi.black') }}) vs <b><router-link :to="whiteUser | userPage"><mk-user-name :user="whiteUser"/></router-link></b>({{ $t('@.reversi.white') }})</header> <div style="overflow: hidden; line-height: 28px;"> - <p class="turn" v-if="!iAmPlayer && !game.isEnded">{{ $t('@.reversi.turn-of', { name: $options.filters.userName(turnUser) }) }}<mk-ellipsis/></p> - <p class="turn" v-if="logPos != logs.length">{{ $t('@.reversi.past-turn-of', { name: $options.filters.userName(turnUser) }) }}</p> + <p class="turn" v-if="!iAmPlayer && !game.isEnded"> + <mfm :text="$t('@.reversi.turn-of', { name: $options.filters.userName(turnUser) })" :should-break="false" :plain-text="true" :custom-emojis="turnUser.emojis"/> + <mk-ellipsis/> + </p> + <p class="turn" v-if="logPos != logs.length"> + <mfm :text="$t('@.reversi.past-turn-of', { name: $options.filters.userName(turnUser) })" :should-break="false" :plain-text="true" :custom-emojis="turnUser.emojis"/> + </p> <p class="turn1" v-if="iAmPlayer && !game.isEnded && !isMyTurn">{{ $t('@.reversi.opponent-turn') }}<mk-ellipsis/></p> <p class="turn2" v-if="iAmPlayer && !game.isEnded && isMyTurn" v-animate-css="{ classes: 'tada', iteration: 'infinite' }">{{ $t('@.reversi.my-turn') }}</p> <p class="result" v-if="game.isEnded && logPos == logs.length"> <template v-if="game.winner"> - <span>{{ $t('@.reversi.won', { name: $options.filters.userName(game.winner) }) }}</span> + <mfm :text="$t('@.reversi.won', { name: $options.filters.userName(game.winner) })" :should-break="false" :plain-text="true" :custom-emojis="game.winner.emojis"/> <span v-if="game.surrendered != null"> ({{ $t('surrendered') }})</span> </template> <template v-else>{{ $t('@.reversi.drawn') }}</template> @@ -30,8 +35,14 @@ :class="{ empty: stone == null, none: o.map[i] == 'null', isEnded: game.isEnded, myTurn: !game.isEnded && isMyTurn, can: turnUser ? o.canPut(turnUser.id == blackUser.id, i) : null, prev: o.prevPos == i }" @click="set(i)" :title="`${String.fromCharCode(65 + o.transformPosToXy(i)[0])}${o.transformPosToXy(i)[1] + 1}`"> - <img v-if="stone === true" :src="blackUser.avatarUrl" alt="black" :class="{ contrast: $store.state.settings.games.reversi.useContrastStones }"> - <img v-if="stone === false" :src="whiteUser.avatarUrl" alt="white" :class="{ contrast: $store.state.settings.games.reversi.useContrastStones }"> + <template v-if="$store.state.settings.games.reversi.useAvatarStones"> + <img v-if="stone === true" :src="blackUser.avatarUrl" alt="black"> + <img v-if="stone === false" :src="whiteUser.avatarUrl" alt="white"> + </template> + <template v-else> + <fa v-if="stone === true" :icon="fasCircle"/> + <fa v-if="stone === false" :icon="farCircle"/> + </template> </div> </div> <div class="labels-y" v-if="this.$store.state.settings.games.reversi.showBoardLabels"> @@ -50,15 +61,13 @@ </div> <div class="player" v-if="game.isEnded"> - <div> - <button @click="logPos = 0" :disabled="logPos == 0"><fa icon="angle-double-left"/></button> - <button @click="logPos--" :disabled="logPos == 0"><fa icon="angle-left"/></button> - </div> <span>{{ logPos }} / {{ logs.length }}</span> - <div> - <button @click="logPos++" :disabled="logPos == logs.length"><fa icon="angle-right"/></button> - <button @click="logPos = logs.length" :disabled="logPos == logs.length"><fa icon="angle-double-right"/></button> - </div> + <ui-horizon-group> + <ui-button @click="logPos = 0" :disabled="logPos == 0"><fa :icon="faAngleDoubleLeft"/></ui-button> + <ui-button @click="logPos--" :disabled="logPos == 0"><fa :icon="faAngleLeft"/></ui-button> + <ui-button @click="logPos++" :disabled="logPos == logs.length"><fa :icon="faAngleRight"/></ui-button> + <ui-button @click="logPos = logs.length" :disabled="logPos == logs.length"><fa :icon="faAngleDoubleRight"/></ui-button> + </ui-horizon-group> </div> <div class="info"> @@ -75,6 +84,9 @@ import i18n from '../../../../../i18n'; import * as CRC32 from 'crc-32'; import Reversi, { Color } from '../../../../../../../games/reversi/core'; import { url } from '../../../../../config'; +import { faAngleDoubleLeft, faAngleLeft, faAngleRight, faAngleDoubleRight } from '@fortawesome/free-solid-svg-icons'; +import { faCircle as fasCircle } from '@fortawesome/free-solid-svg-icons'; +import { faCircle as farCircle } from '@fortawesome/free-regular-svg-icons'; export default Vue.extend({ i18n: i18n('common/views/components/games/reversi/reversi.game.vue'), @@ -99,7 +111,8 @@ export default Vue.extend({ o: null as Reversi, logs: [], logPos: 0, - pollingClock: null + pollingClock: null, + faAngleDoubleLeft, faAngleLeft, faAngleRight, faAngleDoubleRight, fasCircle, farCircle }; }, @@ -177,9 +190,9 @@ export default Vue.extend({ loopedBoard: this.game.settings.loopedBoard }); - this.game.logs.forEach(log => { + for (const log of this.game.logs) { this.o.put(log.color, log.pos); - }); + } this.logs = this.game.logs; this.logPos = this.logs.length; @@ -279,9 +292,9 @@ export default Vue.extend({ loopedBoard: this.game.settings.loopedBoard }); - this.game.logs.forEach(log => { + for (const log of this.game.logs) { this.o.put(log.color, log.pos, true); - }); + } this.logs = this.game.logs; this.logPos = this.logs.length; @@ -412,17 +425,15 @@ export default Vue.extend({ &.none border-color transparent !important - > img + > svg display block width 100% height 100% - &.contrast - &[alt="black"] - filter brightness(.5) - - &[alt="white"] - filter brightness(2) + > img + display block + width 100% + height 100% > .graph display grid @@ -449,7 +460,9 @@ export default Vue.extend({ padding-bottom 16px > .player - padding-bottom 32px + padding 0 16px 32px 16px + margin 0 auto + max-width 500px > span display inline-block diff --git a/src/client/app/common/views/components/games/reversi/reversi.index.vue b/src/client/app/common/views/components/games/reversi/reversi.index.vue index b82a60a360..94e1d9a7e3 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.index.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.index.vue @@ -19,27 +19,27 @@ <h2>{{ $t('invitations') }}</h2> <div class="invitation" v-for="i in invitations" tabindex="-1" @click="accept(i)"> <mk-avatar class="avatar" :user="i.parent"/> - <span class="name"><b>{{ i.parent | userName }}</b></span> + <span class="name"><b><mk-user-name :user="i.parent"/></b></span> <span class="username">@{{ i.parent.username }}</span> <mk-time :time="i.createdAt"/> </div> </section> <section v-if="myGames.length > 0"> <h2>{{ $t('my-games') }}</h2> - <a class="game" v-for="g in myGames" tabindex="-1" @click.prevent="go(g)" :href="`/reversi/${g.id}`"> + <a class="game" v-for="g in myGames" tabindex="-1" @click.prevent="go(g)" :href="`/games/reversi/${g.id}`"> <mk-avatar class="avatar" :user="g.user1"/> <mk-avatar class="avatar" :user="g.user2"/> - <span><b>{{ g.user1 | userName }}</b> vs <b>{{ g.user2 | userName }}</b></span> + <span><b><mk-user-name :user="g.user1"/></b> vs <b><mk-user-name :user="g.user2"/></b></span> <span class="state">{{ g.isEnded ? $t('game-state.ended') : $t('game-state.playing') }}</span> <mk-time :time="g.createdAt" /> </a> </section> <section v-if="games.length > 0"> <h2>{{ $t('all-games') }}</h2> - <a class="game" v-for="g in games" tabindex="-1" @click.prevent="go(g)" :href="`/reversi/${g.id}`"> + <a class="game" v-for="g in games" tabindex="-1" @click.prevent="go(g)" :href="`/games/reversi/${g.id}`"> <mk-avatar class="avatar" :user="g.user1"/> <mk-avatar class="avatar" :user="g.user2"/> - <span><b>{{ g.user1 | userName }}</b> vs <b>{{ g.user2 | userName }}</b></span> + <span><b><mk-user-name :user="g.user1"/></b> vs <b><mk-user-name :user="g.user2"/></b></span> <span class="state">{{ g.isEnded ? $t('game-state.ended') : $t('game-state.playing') }}</span> <mk-time :time="g.createdAt" /> </a> @@ -99,23 +99,22 @@ export default Vue.extend({ this.$emit('go', game); }, - match() { - this.$input({ - title: this.$t('enter-username') - }).then(username => { - this.$root.api('users/show', { - username - }).then(user => { - this.$root.api('games/reversi/match', { - userId: user.id - }).then(res => { - if (res == null) { - this.$emit('matching', user); - } else { - this.$emit('go', res); - } - }); - }); + async match() { + const { result: user } = await this.$root.dialog({ + title: this.$t('enter-username'), + user: { + local: true + } + }); + if (user == null) return; + this.$root.api('games/reversi/match', { + userId: user.id + }).then(res => { + if (res == null) { + this.$emit('matching', user); + } else { + this.$emit('go', res); + } }); }, diff --git a/src/client/app/common/views/components/games/reversi/reversi.room.vue b/src/client/app/common/views/components/games/reversi/reversi.room.vue index 92cdc6c083..d5d148790c 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.room.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.room.vue @@ -1,6 +1,6 @@ <template> <div class="urbixznjwwuukfsckrwzwsqzsxornqij"> - <header><b>{{ game.user1 | userName }}</b> vs <b>{{ game.user2 | userName }}</b></header> + <header><b><mk-user-name :user="game.user1"/></b> vs <b><mk-user-name :user="game.user2"/></b></header> <div> <p>{{ $t('settings-of-the-game') }}</p> @@ -22,8 +22,8 @@ <div v-for="(x, i) in game.settings.map.join('')" :data-none="x == ' '" @click="onPixelClick(i, x)"> - <template v-if="x == 'b'"><template v-if="$store.state.device.darkmode"><fa :icon="['far', 'circle']"/></template><template v-else><fa icon="circle"/></template></template> - <template v-if="x == 'w'"><template v-if="$store.state.device.darkmode"><fa :icon="['far', 'circle']"/></template><template v-else><fa icon="circle"/></template></template> + <fa v-if="x == 'b'" :icon="fasCircle"/> + <fa v-if="x == 'w'" :icon="farCircle"/> </div> </div> </div> @@ -36,8 +36,8 @@ <div> <form-radio v-model="game.settings.bw" value="random" @change="updateSettings">{{ $t('random') }}</form-radio> - <form-radio v-model="game.settings.bw" :value="1" @change="updateSettings">{{ this.$t('black-is').split('{}')[0] }}<b>{{ game.user1 | userName }}</b>{{ this.$t('black-is').split('{}')[1] }}</form-radio> - <form-radio v-model="game.settings.bw" :value="2" @change="updateSettings">{{ this.$t('black-is').split('{}')[0] }}<b>{{ game.user2 | userName }}</b>{{ this.$t('black-is').split('{}')[1] }}</form-radio> + <form-radio v-model="game.settings.bw" :value="1" @change="updateSettings">{{ this.$t('black-is').split('{}')[0] }}<b><mk-user-name :user="game.user1"/></b>{{ this.$t('black-is').split('{}')[1] }}</form-radio> + <form-radio v-model="game.settings.bw" :value="2" @change="updateSettings">{{ this.$t('black-is').split('{}')[0] }}<b><mk-user-name :user="game.user2"/></b>{{ this.$t('black-is').split('{}')[1] }}</form-radio> </div> </div> @@ -60,7 +60,7 @@ <div> <template v-for="item in form"> - <ui-switch v-if="item.type == 'switch'" v-model="item.value" :key="item.id" :text="item.label" @change="onChangeForm(item)">{{ item.desc || '' }}</ui-switch> + <ui-switch v-if="item.type == 'switch'" v-model="item.value" :key="item.id" @change="onChangeForm(item)">{{ item.label || item.desc || '' }}</ui-switch> <div class="card" v-if="item.type == 'radio'" :key="item.id"> <header> @@ -117,6 +117,8 @@ import Vue from 'vue'; import i18n from '../../../../../i18n'; import * as maps from '../../../../../../../games/reversi/maps'; +import { faCircle as fasCircle } from '@fortawesome/free-solid-svg-icons'; +import { faCircle as farCircle } from '@fortawesome/free-regular-svg-icons'; export default Vue.extend({ i18n: i18n('common/views/components/games/reversi/reversi.room.vue'), @@ -129,7 +131,8 @@ export default Vue.extend({ mapName: maps.eighteight.name, maps: maps, form: null, - messages: [] + messages: [], + fasCircle, farCircle }; }, diff --git a/src/client/app/common/views/components/games/reversi/reversi.vue b/src/client/app/common/views/components/games/reversi/reversi.vue index 8c555a6c4f..b6803cd7f7 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.vue @@ -4,7 +4,7 @@ <x-gameroom :game="game" :self-nav="selfNav" @go-index="goIndex"/> </div> <div class="matching" v-else-if="matching"> - <h1>{{ this.$t('matching.waiting-for').split('{}')[0] }}<b>{{ matching | userName }}</b>{{ this.$t('matching.waiting-for').split('{}')[1] }}<mk-ellipsis/></h1> + <h1>{{ this.$t('matching.waiting-for').split('{}')[0] }}<b><mk-user-name :user="matching"/></b>{{ this.$t('matching.waiting-for').split('{}')[1] }}<mk-ellipsis/></h1> <div class="cancel"> <form-button round @click="cancel">{{ $t('matching.cancel') }}</form-button> </div> diff --git a/src/client/app/common/views/components/github-setting.vue b/src/client/app/common/views/components/github-setting.vue deleted file mode 100644 index 93d7f406f8..0000000000 --- a/src/client/app/common/views/components/github-setting.vue +++ /dev/null @@ -1,64 +0,0 @@ -<template> -<div class="mk-github-setting"> - <p>{{ $t('description') }}</p> - <p class="account" v-if="$store.state.i.github" :title="`GitHub ID: ${$store.state.i.github.id}`">{{ $t('connected-to') }}: <a :href="`https://github.com/${$store.state.i.github.login}`" target="_blank">@{{ $store.state.i.github.login }}</a></p> - <p> - <a :href="`${apiUrl}/connect/github`" target="_blank" @click.prevent="connect">{{ $store.state.i.github ? this.$t('reconnect') : this.$t('connect') }}</a> - <span v-if="$store.state.i.github"> or </span> - <a :href="`${apiUrl}/disconnect/github`" target="_blank" v-if="$store.state.i.github" @click.prevent="disconnect">{{ $t('disconnect') }}</a> - </p> - <p class="id" v-if="$store.state.i.github">GitHub ID: {{ $store.state.i.github.id }}</p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { apiUrl } from '../../../config'; - -export default Vue.extend({ - i18n: i18n('common/views/components/github-setting.vue'), - data() { - return { - form: null, - apiUrl - }; - }, - mounted() { - this.$watch('$store.state.i', () => { - if (this.$store.state.i.github && this.form) - this.form.close(); - }, { - deep: true - }); - }, - methods: { - connect() { - this.form = window.open(apiUrl + '/connect/github', - 'github_connect_window', - 'height=570, width=520'); - }, - - disconnect() { - window.open(apiUrl + '/disconnect/github', - 'github_disconnect_window', - 'height=570, width=520'); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-github-setting - .account - border solid 1px #e1e8ed - border-radius 4px - padding 16px - - a - font-weight bold - color inherit - - .id - color #8899a6 -</style> diff --git a/src/client/app/common/views/components/google.vue b/src/client/app/common/views/components/google.vue index 1d852cf25a..dab2e6824a 100644 --- a/src/client/app/common/views/components/google.vue +++ b/src/client/app/common/views/components/google.vue @@ -22,7 +22,10 @@ export default Vue.extend({ }, methods: { search() { - window.open(`https://www.google.com/?#q=${this.query}`, '_blank'); + const engine = this.$store.state.settings.webSearchEngine || + 'https://www.google.com/?#q={{query}}'; + const url = engine.replace('{{query}}', this.query) + window.open(url, '_blank'); } } }); diff --git a/src/client/app/common/views/components/image-viewer.vue b/src/client/app/common/views/components/image-viewer.vue index b86a110337..e668a2f46b 100644 --- a/src/client/app/common/views/components/image-viewer.vue +++ b/src/client/app/common/views/components/image-viewer.vue @@ -7,7 +7,7 @@ <script lang="ts"> import Vue from 'vue'; -import * as anime from 'animejs'; +import anime from 'animejs'; export default Vue.extend({ props: ['image'], @@ -65,5 +65,6 @@ export default Vue.extend({ max-height 100% margin auto cursor zoom-out + image-orientation from-image </style> diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts index ace9eaf44f..e6f93bb840 100644 --- a/src/client/app/common/views/components/index.ts +++ b/src/client/app/common/views/components/index.ts @@ -1,5 +1,6 @@ import Vue from 'vue'; +import userName from './user-name.vue'; import followButton from './follow-button.vue'; import error from './error.vue'; import noteSkeleton from './note-skeleton.vue'; @@ -10,13 +11,14 @@ import trends from './trends.vue'; import analogClock from './analog-clock.vue'; import menu from './menu.vue'; import noteHeader from './note-header.vue'; +import renote from './renote.vue'; import signin from './signin.vue'; import signup from './signup.vue'; import forkit from './forkit.vue'; import acct from './acct.vue'; import avatar from './avatar.vue'; import nav from './nav.vue'; -import misskeyFlavoredMarkdown from './misskey-flavored-markdown'; +import misskeyFlavoredMarkdown from './misskey-flavored-markdown.vue'; import poll from './poll.vue'; import pollEditor from './poll-editor.vue'; import reactionIcon from './reaction-icon.vue'; @@ -43,6 +45,8 @@ import uiInfo from './ui/info.vue'; import formButton from './ui/form/button.vue'; import formRadio from './ui/form/radio.vue'; +Vue.component('mfm', misskeyFlavoredMarkdown); +Vue.component('mk-user-name', userName); Vue.component('mk-follow-button', followButton); Vue.component('mk-error', error); Vue.component('mk-note-skeleton', noteSkeleton); @@ -53,13 +57,13 @@ Vue.component('mk-trends', trends); Vue.component('mk-analog-clock', analogClock); Vue.component('mk-menu', menu); Vue.component('mk-note-header', noteHeader); +Vue.component('mk-renote', renote); Vue.component('mk-signin', signin); Vue.component('mk-signup', signup); Vue.component('mk-forkit', forkit); Vue.component('mk-acct', acct); Vue.component('mk-avatar', avatar); Vue.component('mk-nav', nav); -Vue.component('misskey-flavored-markdown', misskeyFlavoredMarkdown); Vue.component('mk-poll', poll); Vue.component('mk-poll-editor', pollEditor); Vue.component('mk-reaction-icon', reactionIcon); diff --git a/src/client/app/common/views/components/integration-settings.vue b/src/client/app/common/views/components/integration-settings.vue new file mode 100644 index 0000000000..a9b17779b3 --- /dev/null +++ b/src/client/app/common/views/components/integration-settings.vue @@ -0,0 +1,114 @@ +<template> +<ui-card v-if="enableTwitterIntegration || enableDiscordIntegration || enableGithubIntegration"> + <div slot="title"><fa icon="share-alt"/> {{ $t('title') }}</div> + + <section v-if="enableTwitterIntegration"> + <header><fa :icon="['fab', 'twitter']"/> Twitter</header> + <p v-if="$store.state.i.twitter">{{ $t('connected-to') }}: <a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p> + <ui-button v-if="$store.state.i.twitter" @click="disconnectTwitter">{{ $t('disconnect') }}</ui-button> + <ui-button v-else @click="connectTwitter">{{ $t('connect') }}</ui-button> + </section> + + <section v-if="enableDiscordIntegration"> + <header><fa :icon="['fab', 'discord']"/> Discord</header> + <p v-if="$store.state.i.discord">{{ $t('connected-to') }}: <a :href="`https://discordapp.com/users/${$store.state.i.discord.id}`" target="_blank">@{{ $store.state.i.discord.username }}#{{ $store.state.i.discord.discriminator }}</a></p> + <ui-button v-if="$store.state.i.discord" @click="disconnectDiscord">{{ $t('disconnect') }}</ui-button> + <ui-button v-else @click="connectDiscord">{{ $t('connect') }}</ui-button> + </section> + + <section v-if="enableGithubIntegration"> + <header><fa :icon="['fab', 'github']"/> GitHub</header> + <p v-if="$store.state.i.github">{{ $t('connected-to') }}: <a :href="`https://github.com/${$store.state.i.github.login}`" target="_blank">@{{ $store.state.i.github.login }}</a></p> + <ui-button v-if="$store.state.i.github" @click="disconnectGithub">{{ $t('disconnect') }}</ui-button> + <ui-button v-else @click="connectGithub">{{ $t('connect') }}</ui-button> + </section> +</ui-card> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import { apiUrl } from '../../../config'; + +export default Vue.extend({ + i18n: i18n('common/views/components/integration-settings.vue'), + + data() { + return { + apiUrl, + twitterForm: null, + discordForm: null, + githubForm: null, + enableTwitterIntegration: false, + enableDiscordIntegration: false, + enableGithubIntegration: false, + }; + }, + + created() { + this.$root.getMeta().then(meta => { + this.enableTwitterIntegration = meta.enableTwitterIntegration; + this.enableDiscordIntegration = meta.enableDiscordIntegration; + this.enableGithubIntegration = meta.enableGithubIntegration; + }); + }, + + mounted() { + document.cookie = `i=${this.$store.state.i.token}`; + this.$watch('$store.state.i', () => { + if (this.$store.state.i.twitter) { + if (this.twitterForm) this.twitterForm.close(); + } + if (this.$store.state.i.discord) { + if (this.discordForm) this.discordForm.close(); + } + if (this.$store.state.i.github) { + if (this.githubForm) this.githubForm.close(); + } + }, { + deep: true + }); + }, + + methods: { + connectTwitter() { + this.twitterForm = window.open(apiUrl + '/connect/twitter', + 'twitter_connect_window', + 'height=570, width=520'); + }, + + disconnectTwitter() { + window.open(apiUrl + '/disconnect/twitter', + 'twitter_disconnect_window', + 'height=570, width=520'); + }, + + connectDiscord() { + this.discordForm = window.open(apiUrl + '/connect/discord', + 'discord_connect_window', + 'height=570, width=520'); + }, + + disconnectDiscord() { + window.open(apiUrl + '/disconnect/discord', + 'discord_disconnect_window', + 'height=570, width=520'); + }, + + connectGithub() { + this.githubForm = window.open(apiUrl + '/connect/github', + 'github_connect_window', + 'height=570, width=520'); + }, + + disconnectGithub() { + window.open(apiUrl + '/disconnect/github', + 'github_disconnect_window', + 'height=570, width=520'); + }, + } +}); +</script> + +<style lang="stylus" scoped> +</style> diff --git a/src/client/app/common/views/components/language-settings.vue b/src/client/app/common/views/components/language-settings.vue new file mode 100644 index 0000000000..aa3f290511 --- /dev/null +++ b/src/client/app/common/views/components/language-settings.vue @@ -0,0 +1,54 @@ +<template> +<ui-card> + <div slot="title"><fa icon="language"/> {{ $t('title') }}</div> + + <section class="fit-top"> + <ui-select v-model="lang" :placeholder="$t('pick-language')"> + <optgroup :label="$t('recommended')"> + <option value="">{{ $t('auto') }}</option> + </optgroup> + + <optgroup :label="$t('specify-language')"> + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + </optgroup> + </ui-select> + <ui-info>Current: <i>{{ currentLanguage }}</i></ui-info> + <ui-info warn>{{ $t('info') }}</ui-info> + </section> +</ui-card> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import { langs } from '../../../config'; + +export default Vue.extend({ + i18n: i18n('common/views/components/language-settings.vue'), + + data() { + return { + langs, + currentLanguage: 'Unknown', + }; + }, + + computed: { + lang: { + get() { return this.$store.state.device.lang; }, + set(value) { this.$store.commit('device/set', { key: 'lang', value }); } + }, + }, + + created() { + try { + const locale = JSON.parse(localStorage.getItem('locale') || "{}"); + const localeKey = localStorage.getItem('localeKey'); + this.currentLanguage = `${locale.meta.lang} (${localeKey})`; + } catch { } + }, + + methods: { + } +}); +</script> diff --git a/src/client/app/common/views/components/mention.vue b/src/client/app/common/views/components/mention.vue new file mode 100644 index 0000000000..11dddbd52a --- /dev/null +++ b/src/client/app/common/views/components/mention.vue @@ -0,0 +1,70 @@ +<template> +<router-link class="ldlomzub" :to="`/${ canonical }`" v-user-preview="canonical"> + <span class="me" v-if="isMe">{{ $t('@.you') }}</span> + <span class="main"> + <span class="username">@{{ username }}</span> + <span class="host" :class="{ fade: $store.state.settings.contrastedAcct }" v-if="(host != localHost) || $store.state.settings.showFullAcct">@{{ toUnicode(host) }}</span> + </span> +</router-link> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import { toUnicode } from 'punycode'; +import { host as localHost } from '../../../config'; + +export default Vue.extend({ + i18n: i18n(), + props: { + username: { + type: String, + required: true + }, + host: { + type: String, + required: true + } + }, + data() { + return { + localHost + }; + }, + computed: { + canonical(): string { + return `@${this.username}@${toUnicode(this.host)}`; + }, + isMe(): boolean { + return this.$store.getters.isSignedIn && this.canonical.toLowerCase() === `@${this.$store.state.i.username}@${toUnicode(localHost)}`.toLowerCase(); + } + }, + methods: { + toUnicode + } +}); +</script> + +<style lang="stylus" scoped> +.ldlomzub + color var(--mfmMention) + + > .me + pointer-events none + user-select none + padding 0 4px + background var(--mfmMention) + border solid var(--lineWidth) var(--mfmMention) + border-radius 4px 0 0 4px + color var(--mfmMentionForeground) + + & + .main + padding 0 4px + border solid var(--lineWidth) var(--mfmMention) + border-radius 0 4px 4px 0 + + > .main + > .host.fade + opacity 0.5 + +</style> diff --git a/src/client/app/common/views/components/menu.vue b/src/client/app/common/views/components/menu.vue index e085bf4bb9..b43bec1ca3 100644 --- a/src/client/app/common/views/components/menu.vue +++ b/src/client/app/common/views/components/menu.vue @@ -1,5 +1,5 @@ <template> -<div class="onchrpzrvnoruiaenfcqvccjfuupzzwv"> +<div class="onchrpzrvnoruiaenfcqvccjfuupzzwv" :class="{ isMobile: $root.isMobile }"> <div class="backdrop" ref="backdrop" @click="close"></div> <div class="popover" :class="{ hukidasi }" ref="popover"> <template v-for="item, i in items"> @@ -14,7 +14,7 @@ <script lang="ts"> import Vue from 'vue'; -import * as anime from 'animejs'; +import anime from 'animejs'; export default Vue.extend({ props: { @@ -24,16 +24,11 @@ export default Vue.extend({ items: { type: Array, required: true - }, - compact: { - type: Boolean, - required: false, - default: false } }, data() { return { - hukidasi: !this.compact + hukidasi: !this.$root.isMobile }; }, mounted() { @@ -47,7 +42,7 @@ export default Vue.extend({ let left; let top; - if (this.compact) { + if (this.$root.isMobile) { const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); left = (x - (width / 2)); @@ -121,10 +116,14 @@ export default Vue.extend({ <style lang="stylus" scoped> .onchrpzrvnoruiaenfcqvccjfuupzzwv $bg-color = var(--popupBg) - $border-color = rgba(27, 31, 35, 0.15) position initial + &.isMobile + > .popover + > button + font-size 15px + > .backdrop position fixed top 0 @@ -140,7 +139,6 @@ export default Vue.extend({ z-index 10001 padding 8px 0 background $bg-color - border 1px solid $border-color border-radius 4px box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) transform scale(0.5) @@ -165,14 +163,6 @@ export default Vue.extend({ border-top solid $balloon-size transparent border-left solid $balloon-size transparent border-right solid $balloon-size transparent - border-bottom solid $balloon-size $border-color - - &:after - top -($balloon-size * 2) + 1.5px - left s('calc(50% - %s)', $balloon-size) - border-top solid $balloon-size transparent - border-left solid $balloon-size transparent - border-right solid $balloon-size transparent border-bottom solid $balloon-size $bg-color > button @@ -180,6 +170,7 @@ export default Vue.extend({ padding 8px 16px width 100% color var(--popupFg) + white-space nowrap &:hover color var(--primaryForeground) @@ -195,7 +186,7 @@ export default Vue.extend({ > div margin 8px 0 - height 1px + height var(--lineWidth) background var(--faceDivider) </style> diff --git a/src/client/app/common/views/components/messaging-room.form.vue b/src/client/app/common/views/components/messaging-room.form.vue index 97e2e16e4b..6c8b09c244 100644 --- a/src/client/app/common/views/components/messaging-room.form.vue +++ b/src/client/app/common/views/components/messaging-room.form.vue @@ -9,7 +9,7 @@ @keypress="onKeypress" @paste="onPaste" :placeholder="$t('input-message-here')" - v-autocomplete="'text'" + v-autocomplete="{ model: 'text' }" ></textarea> <div class="file" @click="file = null" v-if="file">{{ file.name }}</div> <mk-uploader ref="uploader" @uploaded="onUploaded"/> @@ -85,7 +85,7 @@ export default Vue.extend({ } } else { if (items[0].kind == 'file') { - alert('%i18n:only-one-file-attached%'); + alert(this.$t('only-one-file-attached')); } } }, @@ -107,7 +107,7 @@ export default Vue.extend({ return; } else if (e.dataTransfer.files.length > 1) { e.preventDefault(); - alert('%i18n:only-one-file-attached%'); + alert(this.$t('only-one-file-attached')); return; } diff --git a/src/client/app/common/views/components/messaging-room.message.vue b/src/client/app/common/views/components/messaging-room.message.vue index 966bd54170..ba2af03030 100644 --- a/src/client/app/common/views/components/messaging-room.message.vue +++ b/src/client/app/common/views/components/messaging-room.message.vue @@ -3,19 +3,20 @@ <mk-avatar class="avatar" :user="message.user" target="_blank"/> <div class="content"> <div class="balloon" :data-no-text="message.text == null"> - <!-- <button class="delete-button" v-if="isMe" :title="$t('@.delete')"> - <img src="/assets/desktop/messaging/delete.png" alt="Delete"/> - </button> --> + <button class="delete-button" v-if="isMe" :title="$t('@.delete')" @click="del"> + <img src="/assets/desktop/remove.png" alt="Delete"/> + </button> <div class="content" v-if="!message.isDeleted"> - <misskey-flavored-markdown class="text" v-if="message.text" ref="text" :text="message.text" :i="$store.state.i"/> + <mfm class="text" v-if="message.text" ref="text" :text="message.text" :i="$store.state.i"/> <div class="file" v-if="message.file"> <a :href="message.file.url" target="_blank" :title="message.file.name"> - <img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/> + <img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name" + :style="{ backgroundColor: message.file.properties.avgColor && message.file.properties.avgColor.length == 3 ? `rgb(${message.file.properties.avgColor.join(',')})` : 'transparent' }"/> <p v-else>{{ message.file.name }}</p> </a> </div> </div> - <div class="content" v-if="message.isDeleted"> + <div class="content" v-else> <p class="is-deleted">{{ $t('deleted') }}</p> </div> </div> @@ -51,12 +52,19 @@ export default Vue.extend({ if (this.message.text) { const ast = parse(this.message.text); return unique(ast - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => t.url)); + .filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent)) + .map(t => t.node.props.url)); } else { return null; } } + }, + methods: { + del() { + this.$root.api('messaging/messages/delete', { + messageId: this.message.id + }); + } } }); </script> @@ -150,7 +158,6 @@ export default Vue.extend({ > a display block max-width 100% - max-height 512px border-radius 16px overflow hidden text-decoration none @@ -165,7 +172,8 @@ export default Vue.extend({ display block margin 0 width 100% - height 100% + max-height 512px + object-fit contain > p padding 30px diff --git a/src/client/app/common/views/components/messaging-room.vue b/src/client/app/common/views/components/messaging-room.vue index b6132ceeb0..6f13d50c1e 100644 --- a/src/client/app/common/views/components/messaging-room.vue +++ b/src/client/app/common/views/components/messaging-room.vue @@ -79,6 +79,7 @@ export default Vue.extend({ this.connection.on('message', this.onMessage); this.connection.on('read', this.onRead); + this.connection.on('deleted', this.onDeleted); if (this.isNaked) { window.addEventListener('scroll', this.onScroll, { passive: true }); @@ -196,12 +197,19 @@ export default Vue.extend({ onRead(ids) { if (!Array.isArray(ids)) ids = [ids]; - ids.forEach(id => { + for (const id of ids) { if (this.messages.some(x => x.id == id)) { const exist = this.messages.map(x => x.id).indexOf(id); this.messages[exist].isRead = true; } - }); + } + }, + + onDeleted(id) { + const msg = this.messages.find(m => m.id === id); + if (msg) { + this.messages = this.messages.filter(m => m.id !== msg.id); + } }, isBottom() { @@ -248,13 +256,13 @@ export default Vue.extend({ onVisibilitychange() { if (document.hidden) return; - this.messages.forEach(message => { + for (const message of this.messages) { if (message.userId !== this.$store.state.i.id && !message.isRead) { this.connection.send('read', { id: message.id }); } - }); + } } } }); diff --git a/src/client/app/common/views/components/messaging.vue b/src/client/app/common/views/components/messaging.vue index 5b3fc790d4..9683ca0ca3 100644 --- a/src/client/app/common/views/components/messaging.vue +++ b/src/client/app/common/views/components/messaging.vue @@ -14,7 +14,7 @@ tabindex="-1" > <mk-avatar class="avatar" :user="user"/> - <span class="name">{{ user | userName }}</span> + <span class="name"><mk-user-name :user="user"/></span> <span class="username">@{{ user | acct }}</span> </li> </ol> @@ -33,7 +33,7 @@ <div> <mk-avatar class="avatar" :user="isMe(message) ? message.recipient : message.user"/> <header> - <span class="name">{{ isMe(message) ? message.recipient : message.user | userName }}</span> + <span class="name"><mk-user-name :user="isMe(message) ? message.recipient : message.user"/></span> <span class="username">@{{ isMe(message) ? message.recipient : message.user | acct }}</span> <mk-time :time="message.createdAt"/> </header> @@ -103,10 +103,10 @@ export default Vue.extend({ this.messages.unshift(message); }, onRead(ids) { - ids.forEach(id => { + for (const id of ids) { const found = this.messages.find(m => m.id == id); if (found) found.isRead = true; - }); + } }, search() { if (this.q == '') { @@ -115,9 +115,11 @@ export default Vue.extend({ } this.$root.api('users/search', { query: this.q, - max: 5 + localOnly: true, + limit: 10, + detail: false }).then(users => { - this.result = users; + this.result = users.filter(user => user.id != this.$store.state.i.id); }); }, navigate(user) { diff --git a/src/client/app/common/views/components/misskey-flavored-markdown.ts b/src/client/app/common/views/components/mfm.ts index 1eb738813e..ad3d8204cc 100644 --- a/src/client/app/common/views/components/misskey-flavored-markdown.ts +++ b/src/client/app/common/views/components/mfm.ts @@ -1,11 +1,20 @@ import Vue, { VNode } from 'vue'; import { length } from 'stringz'; +import { MfmForest } from '../../../../../mfm/parser'; import parse from '../../../../../mfm/parse'; -import getAcct from '../../../../../misc/acct/render'; import MkUrl from './url.vue'; -import { concat } from '../../../../../prelude/array'; +import MkMention from './mention.vue'; +import { concat, sum } from '../../../../../prelude/array'; import MkFormula from './formula.vue'; import MkGoogle from './google.vue'; +import syntaxHighlight from '../../../../../mfm/syntax-highlight'; +import { host } from '../../../config'; +import { preorderF, countNodesF } from '../../../../../prelude/tree'; + +function sumTextsLength(ts: MfmForest): number { + const textNodes = preorderF(ts).filter(n => n.type === 'text'); + return sum(textNodes.map(x => length(x.props.text))); +} export default Vue.component('misskey-flavored-markdown', { props: { @@ -13,14 +22,18 @@ export default Vue.component('misskey-flavored-markdown', { type: String, required: true }, - ast: { - type: [], - required: false - }, shouldBreak: { type: Boolean, default: true }, + plainText: { + type: Boolean, + default: false + }, + author: { + type: Object, + default: null + }, i: { type: Object, default: null @@ -31,23 +44,17 @@ export default Vue.component('misskey-flavored-markdown', { }, render(createElement) { - let ast: any[]; + if (this.text == null || this.text == '') return; - if (this.ast == null) { - // Parse text to ast - ast = parse(this.text); - } else { - ast = this.ast as any[]; - } + const ast = parse(this.text, this.plainText); let bigCount = 0; let motionCount = 0; - // Parse ast to DOM - const els = concat(ast.map((token): VNode[] => { - switch (token.type) { + const genEl = (ast: MfmForest) => concat(ast.map((token): VNode[] => { + switch (token.node.type) { case 'text': { - const text = token.content.replace(/(\r\n|\n|\r)/g, '\n'); + const text = token.node.props.text.replace(/(\r\n|\n|\r)/g, '\n'); if (this.shouldBreak) { const x = text.split('\n') @@ -60,12 +67,24 @@ export default Vue.component('misskey-flavored-markdown', { } case 'bold': { - return [createElement('b', token.bold)]; + return [createElement('b', genEl(token.children))]; + } + + case 'strike': { + return [createElement('del', genEl(token.children))]; + } + + case 'italic': { + return (createElement as any)('i', { + attrs: { + style: 'font-style: oblique;' + }, + }, genEl(token.children)); } case 'big': { bigCount++; - const isLong = length(token.big) > 10; + const isLong = sumTextsLength(token.children) > 10 || countNodesF(token.children) > 5; const isMany = bigCount > 3; return (createElement as any)('strong', { attrs: { @@ -75,12 +94,24 @@ export default Vue.component('misskey-flavored-markdown', { name: 'animate-css', value: { classes: 'tada', iteration: 'infinite' } }] - }, token.big); + }, genEl(token.children)); + } + + case 'small': { + return [createElement('small', genEl(token.children))]; + } + + case 'center': { + return [createElement('div', { + attrs: { + style: 'text-align:center;' + } + }, genEl(token.children))]; } case 'motion': { motionCount++; - const isLong = length(token.motion) > 10; + const isLong = sumTextsLength(token.children) > 10 || countNodesF(token.children) > 5; const isMany = motionCount > 3; return (createElement as any)('span', { attrs: { @@ -90,15 +121,18 @@ export default Vue.component('misskey-flavored-markdown', { name: 'animate-css', value: { classes: 'rubberBand', iteration: 'infinite' } }] - }, token.motion); + }, genEl(token.children)); } case 'url': { return [createElement(MkUrl, { + key: Math.random(), props: { - url: token.content, - target: '_blank', - style: 'color:var(--mfmLink);' + url: token.node.props.url, + target: '_blank' + }, + attrs: { + style: 'color:var(--mfmUrl);' } })]; } @@ -107,75 +141,67 @@ export default Vue.component('misskey-flavored-markdown', { return [createElement('a', { attrs: { class: 'link', - href: token.url, + href: token.node.props.url, target: '_blank', - title: token.url, + title: token.node.props.url, style: 'color:var(--mfmLink);' } - }, token.title)]; + }, genEl(token.children))]; } case 'mention': { - return (createElement as any)('router-link', { - attrs: { - to: `/${token.canonical}`, - dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token), - style: 'color:var(--mfmMention);' - }, - directives: [{ - name: 'user-preview', - value: token.canonical - }] - }, token.canonical); + return [createElement(MkMention, { + key: Math.random(), + props: { + host: (token.node.props.host == null && this.author && this.author.host != null ? this.author.host : token.node.props.host) || host, + username: token.node.props.username + } + })]; } case 'hashtag': { return [createElement('router-link', { + key: Math.random(), attrs: { - to: `/tags/${encodeURIComponent(token.hashtag)}`, + to: `/tags/${encodeURIComponent(token.node.props.hashtag)}`, style: 'color:var(--mfmHashtag);' } - }, token.content)]; + }, `#${token.node.props.hashtag}`)]; } - case 'code': { + case 'blockCode': { return [createElement('pre', { class: 'code' }, [ createElement('code', { domProps: { - innerHTML: token.html + innerHTML: syntaxHighlight(token.node.props.code) } }) ])]; } - case 'inline-code': { + case 'inlineCode': { return [createElement('code', { domProps: { - innerHTML: token.html + innerHTML: syntaxHighlight(token.node.props.code) } })]; } case 'quote': { - const text2 = token.quote.replace(/(\r\n|\n|\r)/g, '\n'); - if (this.shouldBreak) { - const x = text2.split('\n') - .map(t => [createElement('span', t), createElement('br')]); - x[x.length - 1].pop(); return [createElement('div', { attrs: { class: 'quote' } - }, x)]; + }, genEl(token.children))]; } else { return [createElement('span', { attrs: { class: 'quote' } - }, text2.replace(/\n/g, ' '))]; + }, genEl(token.children))]; } } @@ -184,18 +210,20 @@ export default Vue.component('misskey-flavored-markdown', { attrs: { class: 'title' } - }, token.title)]; + }, genEl(token.children))]; } case 'emoji': { const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || []; return [createElement('mk-emoji', { + key: Math.random(), attrs: { - emoji: token.emoji, - name: token.name + emoji: token.node.props.emoji, + name: token.node.props.name }, props: { - customEmojis: this.customEmojis || customEmojis + customEmojis: this.customEmojis || customEmojis, + normal: this.plainText } })]; } @@ -203,8 +231,9 @@ export default Vue.component('misskey-flavored-markdown', { case 'math': { //const MkFormula = () => import('./formula.vue').then(m => m.default); return [createElement(MkFormula, { + key: Math.random(), props: { - formula: token.formula + formula: token.node.props.formula } })]; } @@ -212,22 +241,22 @@ export default Vue.component('misskey-flavored-markdown', { case 'search': { //const MkGoogle = () => import('./google.vue').then(m => m.default); return [createElement(MkGoogle, { + key: Math.random(), props: { - q: token.query + q: token.node.props.query } })]; } default: { - console.log('unknown ast type:', token.type); + console.log('unknown ast type:', token.node.type); return []; } } })); - // el.tag === 'br' のとき i !== 0 が保証されるため、短絡評価により els[i - 1] は配列外参照しない - const _els = els.filter((el, i) => !(el.tag === 'br' && ['div', 'pre'].includes(els[i - 1].tag))); - return createElement('span', _els); + // Parse ast to DOM + return createElement('span', genEl(ast)); } }); diff --git a/src/client/app/common/views/components/misskey-flavored-markdown.vue b/src/client/app/common/views/components/misskey-flavored-markdown.vue new file mode 100644 index 0000000000..6fc2aa795c --- /dev/null +++ b/src/client/app/common/views/components/misskey-flavored-markdown.vue @@ -0,0 +1,57 @@ +<template> +<mfm-core v-bind="$attrs" class="havbbuyv"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MfmCore from './mfm'; + +export default Vue.extend({ + components: { + MfmCore + } +}); +</script> + +<style lang="stylus" scoped> +.havbbuyv + >>> .title + display block + margin-bottom 4px + padding 4px + font-size 90% + text-align center + background var(--mfmTitleBg) + border-radius 4px + + >>> .code + margin 8px 0 + + >>> .quote + margin 8px + padding 6px 0 6px 12px + color var(--mfmQuote) + border-left solid 3px var(--mfmQuoteLine) + + >>> code + padding 4px 8px + margin 0 0.5em + font-size 80% + color #525252 + background rgba(0, 0, 0, 0.05) + border-radius 2px + + >>> pre > code + padding 16px + margin 0 + + >>> [data-is-me]:after + content "you" + padding 0 4px + margin-left 4px + font-size 80% + color var(--primaryForeground) + background var(--primary) + border-radius 4px + +</style> diff --git a/src/client/app/common/views/components/mute-and-block.vue b/src/client/app/common/views/components/mute-and-block.vue index fdeaa97eb4..97e992ace1 100644 --- a/src/client/app/common/views/components/mute-and-block.vue +++ b/src/client/app/common/views/components/mute-and-block.vue @@ -7,7 +7,7 @@ <ui-info v-if="!muteFetching && mute.length == 0">{{ $t('no-muted-users') }}</ui-info> <div class="users" v-if="mute.length != 0"> <div v-for="user in mute" :key="user.id"> - <p><b>{{ user | userName }}</b> @{{ user | acct }}</p> + <p><b><mk-user-name :user="user"/></b> @{{ user | acct }}</p> </div> </div> </section> @@ -17,7 +17,7 @@ <ui-info v-if="!blockFetching && block.length == 0">{{ $t('no-blocked-users') }}</ui-info> <div class="users" v-if="block.length != 0"> <div v-for="user in block" :key="user.id"> - <p><b>{{ user | userName }}</b> @{{ user | acct }}</p> + <p><b><mk-user-name :user="user"/></b> @{{ user | acct }}</p> </div> </div> </section> @@ -72,7 +72,7 @@ export default Vue.extend({ methods: { save() { - this._mutedWords = this.mutedWords.split('\n').map(line => line.split(' ')); + this._mutedWords = this.mutedWords.split('\n').map(line => line.split(' ').filter(x => x != '')); } } }); diff --git a/src/client/app/common/views/components/note-header.vue b/src/client/app/common/views/components/note-header.vue index 1e457d2d72..26c9c7b7d8 100644 --- a/src/client/app/common/views/components/note-header.vue +++ b/src/client/app/common/views/components/note-header.vue @@ -1,7 +1,9 @@ <template> <header class="bvonvjxbwzaiskogyhbwgyxvcgserpmu"> <mk-avatar class="avatar" :user="note.user" v-if="$store.state.device.postStyle == 'smart'"/> - <router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id">{{ note.user | userName }}</router-link> + <router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id"> + <mk-user-name :user="note.user"/> + </router-link> <span class="is-admin" v-if="note.user.isAdmin">admin</span> <span class="is-bot" v-if="note.user.isBot">bot</span> <span class="is-cat" v-if="note.user.isCat">cat</span> @@ -17,7 +19,6 @@ <fa v-if="note.visibility == 'home'" icon="home"/> <fa v-if="note.visibility == 'followers'" icon="unlock"/> <fa v-if="note.visibility == 'specified'" icon="envelope"/> - <fa v-if="note.visibility == 'private'" icon="lock"/> </span> <span class="localOnly" v-if="note.localOnly == true"><fa icon="heart"/></span> </div> diff --git a/src/client/app/common/views/components/note-menu.vue b/src/client/app/common/views/components/note-menu.vue index 7d15b4ed7f..f7223b962c 100644 --- a/src/client/app/common/views/components/note-menu.vue +++ b/src/client/app/common/views/components/note-menu.vue @@ -1,6 +1,6 @@ <template> <div style="position:initial"> - <mk-menu :source="source" :compact="compact" :items="items" @closed="closed"/> + <mk-menu :source="source" :items="items" @closed="closed"/> </div> </template> @@ -13,16 +13,27 @@ import { concat, intersperse } from '../../../../../prelude/array'; export default Vue.extend({ i18n: i18n('common/views/components/note-menu.vue'), - props: ['note', 'source', 'compact'], + props: ['note', 'source'], computed: { items(): any[] { return concat(intersperse([null], [ [ [{ + icon: 'at', + text: this.$t('mention'), + action: this.mention + }] + ], + [ + [{ icon: 'info-circle', text: this.$t('detail'), action: this.detail }], [{ + icon: 'align-left', + text: this.$t('copy-content'), + action: this.copyContent + }], [{ icon: 'link', text: this.$t('copy-link'), action: this.copyLink @@ -66,10 +77,18 @@ export default Vue.extend({ }, methods: { + mention() { + this.$post({ mention: this.note.user }); + }, + detail() { this.$router.push(`/notes/${this.note.id}`); }, + copyContent() { + copyToClipboard(this.note.text); + }, + copyLink() { copyToClipboard(`${url}/notes/${this.note.id}`); }, @@ -78,7 +97,7 @@ export default Vue.extend({ this.$root.api('i/pin', { noteId: this.note.id }).then(() => { - this.$root.alert({ + this.$root.dialog({ type: 'success', splash: true }); @@ -95,12 +114,12 @@ export default Vue.extend({ }, del() { - this.$root.alert({ + this.$root.dialog({ type: 'warning', text: this.$t('delete-confirm'), showCancelButton: true - }).then(res => { - if (!res) return; + }).then(({ canceled }) => { + if (canceled) return; this.$root.api('notes/delete', { noteId: this.note.id @@ -114,7 +133,7 @@ export default Vue.extend({ this.$root.api('notes/favorites/create', { noteId: this.note.id }).then(() => { - this.$root.alert({ + this.$root.dialog({ type: 'success', splash: true }); @@ -126,7 +145,7 @@ export default Vue.extend({ this.$root.api('notes/favorites/delete', { noteId: this.note.id }).then(() => { - this.$root.alert({ + this.$root.dialog({ type: 'success', splash: true }); diff --git a/src/client/app/common/views/components/notification-settings.vue b/src/client/app/common/views/components/notification-settings.vue new file mode 100644 index 0000000000..68e3e7b3ad --- /dev/null +++ b/src/client/app/common/views/components/notification-settings.vue @@ -0,0 +1,44 @@ +<template> +<ui-card> + <div slot="title"><fa :icon="['far', 'bell']"/> {{ $t('title') }}</div> + <section> + <ui-switch v-model="$store.state.i.settings.autoWatch" @change="onChangeAutoWatch"> + {{ $t('auto-watch') }}<span slot="desc">{{ $t('auto-watch-desc') }}</span> + </ui-switch> + <section> + <ui-button @click="readAllNotifications">{{ $t('mark-as-read-all-notifications') }}</ui-button> + <ui-button @click="readAllUnreadNotes">{{ $t('mark-as-read-all-unread-notes') }}</ui-button> + <ui-button @click="readAllMessagingMessages">{{ $t('mark-as-read-all-talk-messages') }}</ui-button> + </section> + </section> +</ui-card> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; + +export default Vue.extend({ + i18n: i18n('common/views/components/notification-settings.vue'), + + methods: { + onChangeAutoWatch(v) { + this.$root.api('i/update', { + autoWatch: v + }); + }, + + readAllUnreadNotes() { + this.$root.api('i/read_all_unread_notes'); + }, + + readAllMessagingMessages() { + this.$root.api('i/read_all_messaging_messages'); + }, + + readAllNotifications() { + this.$root.api('notifications/mark_all_as_read'); + } + } +}); +</script> diff --git a/src/client/app/common/views/components/particle.vue b/src/client/app/common/views/components/particle.vue new file mode 100644 index 0000000000..33c118f000 --- /dev/null +++ b/src/client/app/common/views/components/particle.vue @@ -0,0 +1,53 @@ +<template> +<div class="vswabwbm" :style="{ top: `${y - 50}px`, left: `${x - 50}px` }" :class="{ active }"></div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + x: { + type: Number, + required: true + }, + y: { + type: Number, + required: true + } + }, + data() { + return { + active: false + } + }, + mounted() { + setTimeout(() => { + this.active = true; + }, 1); + + setTimeout(() => { + this.destroyDom(); + }, 1000); + } +}); +</script> + +<style lang="stylus" scoped> +.vswabwbm + pointer-events none + position fixed + z-index 1000000 + width 100px + height 100px + background url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABqUAAABkCAYAAAAPKjqIAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA25pVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMDE0IDc5LjE1Njc5NywgMjAxNC8wOC8yMC0wOTo1MzowMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDo1ZmMyNTFlNy02ZmI3LTg3NDMtYWFkNy1kZWQ2ZWY1NzIzYWUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NDIyMEQ0QjBFNTE2MTFFNkFGREZCRkYzMDQ2QkI0RDciIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NDIyMEQ0QUZFNTE2MTFFNkFGREZCRkYzMDQ2QkI0RDciIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTQgKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6MUU1NkMyNUZFNTAzMTFFNkI1RjJFOTE0NTRGREQ2MDgiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6MUU1NkMyNjBFNTAzMTFFNkI1RjJFOTE0NTRGREQ2MDgiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz5nGnsGAABRHklEQVR42uydB3xUVfbH731TMiUNkkACCQRQOrYkoq4gYEVFZUXsfQUbCuta1nV33f/adQVFRVwrdnFlEV2wgQYVIYmutABSQnqAEFJmMsnMvPt/Z5LJDggkwMy8Mr/v5zO8kmHeuee8W94975zLhRAMAAAAAAAAAAAAAAAAAAAAgEgiQQUAAAAAAAAAAAAAAAAAAAAg0sApBQAAAAAAAAAAAAAAAAAAACIOnFIAAAAAAAAAAAAAAAAAAAAg4sApBQAAAAAAAAAAAAAAAAAAACIOnFIAAAAAAAAAAAAAAAAAAAAg4pj1KjjnHNYDAAAAAAAAAAAAAAAAAEBM8sela9NO2tnSeOGlOR61ZRFCdOl7vKtf1BpwSgEAAAAAAAAAAAAcOkXP1mYJbjuT+fg3uTMcW6ARdfnwnWVnZfOEycX9j/rX1SOTF0Mj6vL61vwJybVsakU6y78ta/QT0Ii6vFix/Bqv5L/VLKSNmd85bpxwSa4PWlHXHk0m9kac8K/sk58wRguOkFhvrxpM1o9MptbKzO+cg9S2R1d9TWaYDgAAAAAAAAAAAJGi4LnaVMlrf0kwNohzsaiyd/EDmFRU0R4zm4bZHWwNYzRxJFjBLNcpedOdP0Az6jB7wdahSQnyZ3XKfnrjrhvfXLnnXDim1OO5dfOT4j22f/ucjPVsZONf35pffF3/0YugGfXwC/H3FKs7S9k9sTaHfahsYQ8VIQchYybWwk0jS0/claecWg6tqIewyqNazV4zY7zPD2lxCcopXTgJ4ZQCAAAAAAAAAGAoCme6znP3trxI+/Yq74t5dzofhlbUQ2q1/8OeKF/Ufjgko3rIJmX7KjSjlkH4RW0OqfZDxsg2cEqpRG9L5WmMpXccm7auGaNs4JRSiYqaIdb0dHfHsSeOd4NW1EU2yVXKhpxSrFVybYJG1MUim15oMbGRgUipVakF0Ii6uOp3PpySlEZOwrV/GDd8p17khlMKAAAAAAAAAI6Ql1e0XttrpzyG9tNKXXfn3Z6yC1pRh4XvF9laZMsik7/tuLWH5e+rZjcuOXFaQhG0oxKc9drr0M8GQikqmkOIbXudkFgFtKIeWSVD5jcNrvuzj7EMOnb2dX0ArajHo+OG73y+LP+PvUtNdzOrf2XGGifsoTIZovziGnefK2Vf84pp/c7eCI2oy829R81TNvOgCW1w+7BL6pXNPXqTG04pAAAAAAAAADgC3ly5Z3xCLXul1SwFjhvSEwYom9HQjDp0K+1lZpl7n5P8pj7KBk4plRBm8a6yOT147DfJ86EV9ajqveGD9Moh/SSZj2FcFFX2Lp4DragHvcRQ8FztMRvyTHlNdTvW3XLKOaXQirq0ryOFtaQ0wqReV1XAHgAYC97Vxac0JzjnsB4AAAAAAIhJyAniEY7naL/F7v+i76Z1t2N9luiQ/2RVPG1H353RFDz3n489r7WapatDv9d9XW1y6HdAZCic6R7ATc2tOXeklIWe/+rD1nybl51K+34Tq7DvaDwe0WuRZ9Xsxhza7i8qrWimaywzseGyXyzNmxG/DtqKTl9BW6xPpA1erFh+DW3bowwA7AFgD83yUsniQbSdkj0ekWkagLIA0PbCS3M0v15UV31NcEoBAAAAAIAOKAWZ4OwvbQNF9n+/O9n6BrSirj1S69grtL+rG7uR7EFOkS2npjSEfs/P2PWwVeQpnO26oSXV8jLtx+3y/i53mvPVfe1EdG9iVaMnW7Ogscjbw2EXAXu4m9hVudPj3wl9eO+9Y/AVSjvmZMzz732dViAC9pjpOs+RIAKLz7sb+YTcGc5PoRX1mL1g69DBCXLA+behURo2bWL/9dCKuny4flUgqeikoSeaoA31mVOZHxg33dJr9LXQhvrACQKAMeiqrwnp+wAAAAAAQIDn19TlmJrYayFrn782e8HWgmzf7k2lRw25V0hSFpflsj6bix9HVE7kIefT7hBHBzk9Fr5f9P6aobZBjn3ibzwO/8nKBk6pCOPuaflbcJ0i2lc2AadUz/LVb5viho+haKkEN/t+zRDXDGgr8nAfP5+1N1hcSJOUTYdTqv1N0lehpShiZj33uw9U4aSKpB1Ng+uqgvvQiPo02TwXQQvaAc4obQFnFACxNmwEAAAAAABAIa7JOXzfc/b0zLztjow7/Nw0NXDCZGLbBw6jCJCbobHIsm5EY/cMOWWvc712Zsez9VvX7eg7YptZsH7B8za3aQU0Fnm8Ettm8rPewf3g+XYn7fXtHxAlBGdvKpuLQvaBilT02PBOr+rBGbRf3asY6Zf2w9dXrUyrn2BujEb6HUpXuWh+Ia2lxsbefhReJNkHevGj5Js9J2SflvxjtFKtXtd/9CJofv8svuyn/jubbJlJV7tX6SE9ldF5La90Im2vL+izANpQF4r89r5nu8ntlX6+5pMh+dCI+v34Llf8ZbDHkYP0fQAAAABQlTlLNvWRe/YOrMUi1VS8ecs5A7G4s0pQqp/49My1oeeaqsuHywMyf7X2x53HWDEYiwKhaxVZffKb515gCzg9KKqNSdKT1kZ739Y476u3He98GNqKPLR+0Y4B0gO037PE9RekhFOfgudqU2mL9aJ+zS8PLR3OnNbHA/2ro+m1AVPP+RBaUQdygKSWb/7a7LSdQMf+2urTh8ydsAyaUa9u1GxI/Yn2vbK3xCNLZ49/7/it0Iw6kAMkNcH3UfvhGnbL7hMQka8erxxb9lfOxV9pXwj+txt/zvobtKIe8/K2vefzmibTvjnTNQaOEJXbq6kF33HGT6T9lDOYHW3Vr0H6PgAAAABoHpqk+alX95WM8fTAiV59b39u3fzBtw+7pB7aiT603sSbK/ec1+A0PUbHiS7/fXTumdUt1R02ahtqVkNb0YGcUKtmNz5XMMbHbhvRrSh4vn1/HDQUXXJnOLYwREMdkMA6TrJ4zm9puT7Olbhqs2XXXyddMfbzSF4Tzqj9s2h+oVnsEB9aRWtgjQ7mTTj3l4eWjjj6gXFroZ3ok1L+y1Sz035CRy9qS36SmhRoRh08laYbg/sWyZLdI20PRYX8A5pRBxPzXxpyOML8LxNF+MFJqBIWq2+Iz9u27JnExRXKBk4pjeCvcaZAC+rCGaP1GU8UTKyacEkeHFJHAJxSAAAAAFCN7aPto/Z2dvD0pKYzT1F2FkM70YMiDYSw9jX5pET2PfMIxu5vG3RLnqKZrrE/+H3P+UyWh4Lfd7r4fdBa9DhxWkIRtHBgyBHS2s39JufsRHOyWMO97r9eeMo50JkK+J3N1/s9POC0a3E2nJjFrIsXzS+Mw1uk0adXVb8Mq7RhUOg5Ka11sLKBUwrEPJKFl+/VdnkZXrZREUem+ztWbw86ptb4LvaXsvegF9X6cmZ6X9kEInOYhWHMrzJpA/bcv3NLMvO2movT7qlZxC6BTtSk2zhpWv2bjjf7JDcUQxtHBtL3AQAAiAnojeGM8qF9ORN9lJ7PofSASYE/CF6v9ChuwXhpVeb67Zg4iywFM5uGcTMfyf1soKL7gYotBn13unlo6Hd+85VvvWKTjYqNNgkT2yR8YmXejPh10F746kLP6kHHSj4p/VDufUqzaEnKHnvcylYv1RvZLFfXpG/8OZbrzHPr5ieZk9MvtLWIum4Fzi+wBoI6fFGw8DF3c+q9wWNvi1wat8c+AO159Cl8t/BlipIKPSdVD+6JaCZ12vrBVeYKi+Tq0VE39rQgUkolaA2KHimti4Pp+zx7mq8+5oXRb0Mz6kCR+vGbN79U3+jIS01u+qRkrPNu9BnqQin8EjIbM1OdTe+NeWvkTmhE/TpC22ittwYAMA5d9TXBKQUAAGGGIg641zaSm1h/4WdDeFsUSILS3ia3t197lE2jYKJa+U6x8p2twuJZiQmb8FL0bG2WkONGcVk6W3CWq/SM9LawqZP/5lcMtJELVigk+TMutSzHeiFHRiCVU9WQ8YpOr1bqwGjl1K9SDpQPtvi3Z7bZpm8583er9Jj+e6KNBY8zNwTyR9QqdSefFrKvyChejMn/Q4fWwlGGiIOVtqckXE6+gJOR8WylZdvQntYsZviw8q3eNXKf761NPJOOPUn+gj75CWNwb0affxfkf8KbTeeFnvMetS1zUq+rKqCdKNeLd5adlWW1dkS6Ugq/464dcTI0ow5YU0pbBByFGxsG70k+thrjfgAAAAAYETilAIghaNJ34NLKk2Vb0gmyV2R6/VJPi8nf02QxBSJB/F5/vddvqrGY5BpKVSB56n/cNK7XCkychQ+amJU4v1rI7HR2+PnhC7nEvpKFeBNRIYcHvdHlsCVO5j5+udK/nRGm/uZLYRbvuj0NH+BNsa4TcAr6bX9kIpD6IdQRFXAuBaKgmFjNBa/ym+UGyWciZy2Tzf7kFadZPwlN6RfimOr4DcbZB9zkeTRWnYazt302SjLbT5Z9zSum9Tt7+cG+u2p2Yw5FRVVlFn8WqbeA2yIRh5xN0VOxkmrutfKvn/A1me4KPedPYNff3HvUPLQA0WXfSClhYrtMlXEZeOtdHbbMXTKpLjH1nIAtavvdh8l3AAAAAAAAYgM4pQAwOJQCIi2l5RrZK5/LmfiNciruEH+iRTD+nWSR/rOzNm4eQuQPnUAEyI7BVzAvu4WFf6HiQmZhcyp6bHgHzsPOoeg05rdN5X42g+0nEidM1AoTm8lMnrmYYDsw5IxiPvtDgokrWTAyjfP1QhLvMFn8uzOH6x+Xrk1LTx24I/ScSfjnjlzqn80kfhGX+RXKKCeY7s/PGX+bmZsfiCXn1PNl+fc0W/jjwWO7V9x7W9boJ37ViLRHRkXSGbUvQedULEROwSmlHSiNYs+K9JctTmmMzy27LU7Xb7GmFAAAAAAAAABEFzilADAoxVMXjfWZEh7gTIwLa6PB+FKzv/GhIXMnLIOWD05g0rV6yDXMyx5UDrMifLkyZmEPVqUXz8Mb3/u3Ra+qIbcIH39MuYsdUeqB3Nws7qvMKJ4Dm+xti/TKIfdyP7+/3RZ+pa/+WObiibzpzh8O5beeX1e/1Oe3jw0ed292n3v1yOSOdFAFs1wnSYLfo4xhLmABxxd3C5N4pLpX8eNGtwk5w385zd2873lbbXXy7cMuqe9oo8qHnK1mWtBgGtNoOsSibovvl+Ts6O5cFXquW+L2PkgZBwAAAAAAAAAgFoFTCgCDQc4oYUt+Uvb6ciJ5HcliLuKePXfDObV/AmmwvNKLLPyRUZ1RKFvkm2MlLVaXbeEzzQuJmol2R7ReNvuvgU3aI3KEeC9YL5Q+eoHM5D8fbhpKipbK7NZvipCkrCRPy8JQh1QogbSZTPq7MpaZGBRFufhlRo7QId2kDK3bse/52vXdejw6bvjOQKSa33ZUzgynJtrwopmusczk2WzUSDZKo2iOs//OJss1Xl/zK1Oyx29E6wwAAAAAAAAAIBaBUwqoQvlxl1hom/nf+V5oIzxQmr5EZ+szVpPv8mhet9VvfrfBZb0Taf3a+F8UCPsbC6Ykiz5+YWJ/jYVokM4oetY1TfjYTBVt0WETbmYzcu5wzo5VWxTOarqCydI/26Ojyhhnt+TOcH4aVRlmus5jgs1hgchF7maSfFPu9Ph3jKrz2VVf/9DCTSODx3HCv3JaxpiT2h21e7TmlCOnJa0Vtq8Dd81LRffbXM2/8Tjt321NYk8jVam67PrPM7lse+pdbs5LrAM2zUo/88EaaEU9di9+IIFJBbf56rOPd2dn/CX7xAfh8FTbJp+dfV9rNznX3tQwM2ncyu+gkUObeAj3c3zJqgcH2dh3D0ueYW/3GD1rASyh/rPSbxL/9Afa7372Z49BI+qzI3/6xG1DMweNTP0D7KEBPqx8q3eWtfrq6q/GzsKYVxus3PXUfQU1fecEs00A9Z9Fvj4upQpZJ7QDZWk5nPYKTikQUUqHTEr1ZeaeLjjLVSyRq9xF/VnbOi7O9q+4GC1mz9hW5W+FXLBCc3nhV32KP8Q6LIcARUfJ5oS3lXqaoVI9q5J8jVfGetQUpaGSvPZ3FTucoZH270vZ0nx5LK5rRJ1iZsWQuYKJazTVJzE+r7x38dRYecCgh6qdrM8jsszGDNzQq0+31RltqfoszVPUTBen1NOXglFTwsQeyLvT+bCR9P5SyeJBVtk5sFVybfJbnff7uDzILKSN/rqaO07+/NwTZItnjVbbhTb72EYEI7jIIcW81oeCf2/p3vJq7uW5v9NVe/T9kkDkslSR+rPeXxSo/uLBnp6KPh0PgLaklm/SJ95yul7kp8lQufeuYzML83YYJSpvxwdT31c2wShQ1mPyXKue7DF4Y8Pg9UcnbTZKv1i/dORvTL6W/ODxsvqX4/RS7/OfrIrPaCnOPvqBcWvD+btHOo9wpM/zjcuO+4l72TG03+o/P7n7+Ica9/c9emnypz/dK7RiL3rhUDq2T/PouzOaInmdQ7FPOOZWyAHiSdr8Ee1bd+SmH+jFBnK4m+K+OKbZP36zFl5+IHs0Xmiqi9T9oZbTNjD22vHntXb3zmGpv6Qf0B7UXo9OuS3wopMWnO1kD6O+FPvNjlmnOpgvf7svPetgk+7kcN+enZzW8M2pP6jdbtH9YeSXYckp1fenptcO1hZRm7UmJ/XYEUW7fj5QPwNbhG+uR5KEr7NyUl3a6UvdBueVNqA2i7ahL9DBKQXCDg3oW3rmXMcFu0K5a8Yclt2UcYbg7J24mqLXEU11cNbdmX+X8Pqf0kR9s5j+MOyZ0f+IRTtQKizhty9RLUXcgRvB9dzUfI5RU2IdaFLFaU5aEB7nIHcrXWVt+35KONajImehy1c/MdKTDFpg30idbvVpX9w4aPBZWpCt4BnXn7ifBZwdXGKPy2bPU8xvmxo4lvl7ek3t93xZ/j1WF3+0Y6BnaxoaTBVHKfJcvoYCrd97bXU4MY8cU/99Y80KUxPruIdsfv/Oo+84rqde7PFFwcLH3M2p9wZsYfd/elHe6PP1XKfpzcSmHQl7rf2Wfd0NZr3IX/hu4ct+S8v1tJ9a23jpgKnnfKj3drYzpxRNTpzs/O44n6WuTEtRbSTXoB1irVW0Dmrl1o3FqdJxRnBM7euU2nPP0bZ9n2XoodxRUvV/gYN0+3taiN6hdjelZOMmS5Iz3etqvjsc4/lwzx8c7nN99aozP4zfs3OisLDV9TOOPnF/z5YU3WblNYEXVFpFzz+pHcFDE+5mZq+k/Uaff9D4947fGs7fD4dtDtseXzzYkyV993xgLLb90cv2N6lI33Ga/70k6Ex028ZMUrOeLL7sp/6+HUlb6hr5+9cU9LtMS3Y4Elvs1SZ59gw9mI6pHrXGxV1M+/HV3j+qWUfmnV882lfu/Nqc6RpzzSdD8sP9+0dil2jNP5Jzd0cfT8C52+xIW5fX4+/D1bLHa3mlE03Mf2k464beIIfUlhy+grGkocpIYH3lsoty1RrTUP1Ii/eUh7vf0BvkSJRk9gjtd+bgjSSHG0FkNILO9kCfzsyjT+sx/dtDaW8lBkAnkDNqy1mP39XSI2czE+ylw3VIBW5M+r/Kb9Bv0W8G0/2BvVlz6zczteKQCthNkYVkijU7UMop4bN9pzmHVFsrP5RkC6zlEwNQp38EDqlaimRiFnajbJFz3f6GhNzfO5y5v3f2afs4nHSO/kbfCXxX+T+H8aBzBslIshpN/zTJSGvn0Oe5dfOTQh1SgQFIYnWiVmQNREdJ4kpG6S5ldi9vtW0gJ1XAUSXESorY0aMNQh1SBEVKBSZ+ZrlO0oNDiiAZSVaS2Wdt2Wu9MVeSaaue2qOgQyowUdFsOo+i2PRcx8mxEXoc3xw3P7j/y0NLh1NkG320WH9IpqBDiqhLTD3HEA1vuv294K45qeQvoX+iid0RrtUeciRShFvpi+88qhWxe1X1yyCHVKDdUrZDf6k/KvxDIHHInyOFIgjIqdGUnLbAb44bva8DhCauHCVVa1ibI3Eiq25+P5ASU2UoQoocUgF7ZEvjwqH3SNnzkNutKcmXky1cvovO2Z9DiupJ0CEVKL+yT85FNe3R7LMmROaxIHy2OdzfIud4+olfTKLPgd5yt8orrg86pAi7/+u/aOHR25Hp/k5rdgjH79Fb6wd1SCl1JOiQIprSLar2JdmnJf+Y2K/xzqSr3asi0V9o4Xc6Q7atu7Kjfrh3Dgs4e1Xi+oI+CyLtkIp2X37IjUNO6rFtDqnASGBo8tjlqvXr5KiNdYcUIcn1VwX341xNp6glBxxSbTTU2zqyejk2Jww7ZHtCheBgUPq4gDOKCXKQ9AnjT/eh36TfpmtA0yEd363fzORMnq41uUimWHJMBSbehKA0U1kaFjOLZNTrJHtXIYdIZuWQdw7VIUWRS8qNe35VVnF6zu8d1+ZOc75Ka9rsb/KeztHf6Dv0Xfo/9H8Dv3FoA+szSFaS2Uj6Lzm16dsWuyOfPjyp52pri2WvyQZKIaclmQPrSUn8EmXXz9pSywZJ4b64s4xiG3JKS5KnQk/ReSQrySxqhrzsj2crAw8UzLOptSH7dlorruhp9xu01XIdorQSwsT2SpPo3zCgWc/3Ek0mesuzj5M8ticDn+ziKXSeHFKebmmrA6kWlU8S3/otRV5oSfbqntv2hB7HNXJDPLDTRKLcLSOLPvu+ud66ZeBe40TZ5rlbzYmrUCiC2+sV/wnIya0bKYVfuH77SCalwjGhRXagCff9pbiSRdqvHNOS6T+qp32mlH0UIcXTWhYzV+u9R6L7SHOo1yBHFNniQJGCniSWvO+5FvPJPdS0B00o9hy863j6hGtyMVK20WtGnUO1x4Sl/Y+ZtGDY7CPVVST1FanfL9hzPr2EVxpyqlTtMSLZIhyTvZG0SSR/e3v/swuD+xQp1W4jQ3EkTqZoO6j2LBtVSBFSbUf16ymlIgPqjvvre/+NbFHSal8g/5i3yMhl1bLDtoP/5i6QJXY/fSrLjnvjUP870veB/UKTQUP/+SW9KfOHKF3yqfU3nfFHo+dI7Qwtpew7YN2LgVR+9BZ874rBy5XdXJ2IXFjRe8Moo76tUTTL9RhFvByKPoTEpuVNd4Zl0EhRHVxmsw/lfqC0cTnTnfcZQf8UHUXOqNBzx67NYMVH7/K1xnnNccK/Mq6l+dpgKjktEZrKr2NwJ7GTw3VvRJPXyr9+wtdkuit4bLGKi4Z+enwlOVP1eF+tmt2Y4/E0bazPrvBQ30/OqNC14ihikRzEWpX/9a35E5KrTK/Svlwn/vLb80+dE66HDy2NhSktXtzuuBtCz7V4+10VcPxqqZ94tjbLaiu5+pdezXUml/21cPWHh/OcFA277Jrz9rtN9pZL9jo5tHRYaC53LdikMmNbVTjG9pF4Xg23ncgpKNVV7Z1SOd1+qRZS+GlR/9GyTei6U5Tmz+s5f7Saa4Lo0TbhtAdFFFpsn+QHbUKRblpYxyhW6wdB0YPN8YkzaN/e1DAT9lDfJpTCb9vQzEGdrXUE20Rn3EVzoyP6fjJgzfbzt8T6fCXQRz3Rgr8Ea0qBw6Z84MXxrdl5i44kTd9h2ZSxr60lBRMyN/2rKRb1vvrW/Csl5n9LD7LKzHTVMS+Mftuotiic6XqBCXaLvhpFNid3hvNWA9riPMUWn3RRCW5mEdMo2ikissx23cC8fHaX15/i7HzFJp/q3Qb7c0oNXZX3+rkX2K6nVH63D7ukXsvyF810fxGMstO7s5BsYbE6+kneppUnLBh9lN7vL6rfVAaK9uSttl8tak1pNWNhjbYjfQCJ5Jh4f04ph7lishHWbIrkw2CkbbNl7pJJprjKjvR+tqSWb9In3nI6bKGujWgikdL20T6lXFR7/SK92CCSdiEnCJMKbqP9VulkTPBqpC0jJy5FgOh9glfv9QP9BexiFPtA99G3E3Su/Tqipo3glAKHhVoOqQ67xqhjihZZzeq+h8KC43QickvZ7uShRsxpWzjLPZHJ4iNdCi/x3+ZOdywwii3aJ6o3sL3Trx2IMsHF+LwZ8esiKtPMpmFc8MWsa2kda4XVMzjv9pRderfFnMr8N1wSD0SxJLm61fQobMnWS2ReIPKxckhR+9pwtcLEZgbWndJz3ZjlOqm6d3Gh3idz6M3D9Iohuczs2bwfp5Tf7W9INrpTSusOkI70fe1QqsVqd2auEe0SyWeiSNiGHFNJcu3Fbs5L4vuuf8RI0R+RtgeeK7VnA9hF+7aBPVA/YAvYReu2gf7VsRH0HlvPIOEsE5xSoIPy4y6xtPbI+Vwth1SHbckxtaPorP0tWGtU1t+5vFD2+nL0JLNkMRcNfWZUrpHsQOtkOEyJJaxrThAtUuv2N2QbZbLwECLWCoXVMz5azp92Zxk5pjq//w0SwVY00zW2KcG/lPYdjdLwSDv/wm8v+/a9ItwkcaXW0o91FXKyZewYOEyvafv2hdL4VfXYtC6zYsjc0PR9Ro3+1OPDB61dFucsvpT26+p7PQuHFJ5djGAP2EebdoBNtG0X2EQbdQP20J4tYBft2Ab6V88+0D2eQQ61XBJuARCkpUfOP9V2SAVuXkUGkiVW9E5p+/TmkCJIZpLdSLZwmBOfYPp1SBEp7WXQPTRR3SWHFOfro+mQIuhadE26dhcatFsCZdH7YInx55wNJhbfZHpcTw6pwEDHaxvxq5SLgp+qV1v0rhx8ulEcUgSVhcpUmbX+RmZhN5IzirZVmcV34OFDG7+fO8OxZcSUnEfoYzSHVDQXB1Z9IWLUD01eF3aATfSkE9hEWzqIdXtotfyoJ+rqAPpXT0fQvTb1pWW7wCkFAmw98zF6A1ZLC4pf2y6ToaHIHBOXn9Sr/CQ7lcEItqA3wZlgU/Tfs7EpgbLoHJPP1JV1GGq5qfkcNdLj0TXp2iRDmMqi3bpBKS3bUt+VubwND+nvZvJsVv71h57igjv1aAuKkhJMlERywNrZJzLNliiRZW6m9eAoOoq2Rl1IGA8fsWcL2EY/uoFttKcH2ASgbqCO6LHcsdx24aWG2LYPdK9NPWnVLnBKAVY6ZFKq4OwVzVVSRSaSzci6Tyn/ZarSOGTouCHNoDIYpFu4S/nHZICCmNrLolsoski5t87o9IucXZtzR0qZWnIGrs07d+ZTWXQdLSWL+wPlMLG5eoySCNjJQg5n7u6wCQs42XQHRRRFIlLtUBxOkXBOUZmobHj4wENhrOgGttGuTmAbgDqCOgJQF1BX9F9mtFWRf4bTwrVQT/RdJyS9V6hwfGIdb1buU8pGi2+NO9tlMyT0xjvzyXfpviBKGQJl0TG05owhoqQ6Gkg2JVAmnWLymjpN28UZn5c7w/mp2rKSDCRLOMqkyboxy3USa1s7q7a5teEZvd5TgQic3zucwupJY23RbbntZdMVnDG3Vgan4R4/RaJsAGj5gQzPIAD1A3VFrzrAZDtkAwAAoL/2Wmv9BSKlYpztgy7sx7SVtm9frm2X0XD0+8Z1sZ6jpEIatQwqi57LIMm2y5kxoqSCmNrLpDsoHaTSTU7q5Gu1srVZMw7ddlkOmsaPyqTHVJdcsGvadtgHRlhLJpDqUSnLXmXTCZSWszKzeLmWBqXhHNRS2YyQelRrDwCYqAKoH6g3QN+UDrrAAS0AoM/2OZb6E7zUANtA/+BQgVMqxvH1PfmvkFEdOJNvQFk00kn79DU5beQyOSwJFyjSH/Thm0vsZTXWkToQgfWlFJk6sYijrWz6YdH8QjMTbHJAes7mGaZuBMuilC1QRv1IPjic6yyF6yEhXL/TVjYxGA+CeCCMJV3ANgCALjQUNigBAP32mejrAZ5DgJZ0oSWbwCkVw5QPvJje2p+kA1EntctqGL6+amUaZ2KcUcpDZaEy6VH2omdrs1hbejKjkdteNn3dS0LqpE3ibtns0VxazzaZuPvIyqYt0iuGUL1IUT5ledOdPxilYrSXhdYiS2kvo07aWeOnt0MKPxCLD8Wx/rAOu6DMsMfBydr48W7YBGWFPQDuP9QNgDpiNHnglIphWvvmnce0uZbUvjjbZTUM3RNbzjLa/aTXMgk5bpRhOz4dlk3pHEcf7O+cs8+0FCUVJBAtpch2JGXTHJyd3rbhyyI9IIr2mo8dZWovo9ah1I+yxbNGq4PQcP0elVGPaS4BAACASFA28MLu0AIAAAAtPPPpXQ6gPeCUiuUGirPxkFUdOBOGi8zRa5m4LJ1t1Dqut7IVzGwaxtoicw7SFsgfared6lS2lPYy6mOAIPMxgXJJ8meRGpx2NkCNlHMqWKZgGbWO3ZI4XIvO2HBDZaSy4mEQD4MgRp5FNH5fot4A9ZFN0AEA+m+PjdyfIJIQAHC4wCkV24yFrKp1i8cYcDiiyzIJJoYadoCos7Jxzo7ttEzmls81q+8uyNaVMmpo8D6obct+VvvBIPyRPW1lCpZR83VDHNxZq4WHsnD9bjjLCkCk7/tYkQ8AoBoeqAAAAIDex5axPNZFquoDA6dUjFI6ZFKqsumjI5H7tMtsCCTOhhjtntJvmfhQ49Z0fZVNcN7v4MXh67UcLRKQTZHxiMqoEdpTqNGaZP7qzA0btTD4Ceegqb1Mfioj0sUBAAAAQJMjeQGnFAAAAACMCZxSMYpr9LUjILM6fH3VyjQhRIbR7ikqE5VNTzIXPFebqkjuMG5NF462MuqkQ/LzgQd9MGdso9bL0JmMnZVRK9jNCX3bdysnXJLrM1rNaC9T5T5lBQAAAADQDL03feyFFgAAAABgROCUilHitq7rAZnVodlnTTDqfaW3snGvPcnodV1XZeTsoPePYKJa60XoVMZOyqgdW/CgnDVh1c8RRjuFOcS8Zp+yarhqMHesjE9iqawAAADAwYdjHEoAAAAAgCGBUypGERJLhMzqcNTwOsNG5uitbLLZn2z0uq6zMiYc/MGcNWi9AF2QURdOqeC6PpzzPUatG8Gy6WENI5mz5lgZn8RSWQEAAAAAAAAAgFgETqlYRfBEyKwOvjJXmlFvK72VzeSTEo1e1Y1URpmzRsgYJTi3uhL9bE+WOW7h+0U2Q1cSpaxaEuePS9emPb+mLsfwetc4sxdsHUq2gCbU5cPKt3o/t25+EjShLovmF5phBwAAAAAAAEC4MEMFMQoXDUzoUGYjVLos505/pUEbFKVsepLXb5YbJK+xffNURqOURRLajzIiGYUBdP3DWH62nwd8IqMYG75t9oKtp0+b2H+9MbtDoZlosJdXtF7rcrLXfcpNVDJkePXza+rOv21EtyIMWqIHOaIye/T9kpvdx2QxJ3v+J9cDtx3vfBiaiS7kALGbR8xv9DWf4VRa/hcrll93c+9R86CZ6PP61vwJjUk7FnZX6sO7lfN+rK2zn377sEvqoZno882OWafWxTveUXZ7eHd7P7WtGHmpEdd91APkqDUPLp67J0Uca5Ybt9fU97gB9UI98p+sii/P3PxnX2NrliNeen3SFWM/h1bUtceO73dfT/uWyzz/vPDSHA+0oq49tnzgPTN9QO3P4987fis0oi704qF1gdQLtgAAkVIxC5dZA2RWh81ruxl2vQy9lU3ymfYYva7rrIwHjTISQvspPLsgo+YjqWhS3s9NU0Na33TeP+MOo9UNIUQgtaVW0sWR3skhFap3xs2vBuqxYPaYGZiqXNbeKQP+Tg6pDivYmx6iqCmMHKOLMyntT5K9+YzgcZw//nWKmoJmogtNvFuTmhZ2tJtxqSeQbaAZdWh3SGVRlbB0t/y2ecS6W6AVdSDdt0jSDfY6U46lPvm3A2TP09CKepBDinsa77ZYWi7ztjQvmbNkU5+wzkPoYG0vLcm4ZXP5Z009mp+mT91SeTbuUJXt8ba3SHjZv6o2pGxefNlP/aER9SAH4e5H034mW8zL2/YeNKIuVB+WXVH0C31QN1R69ocKYhNL2cqNkFkdnDnpO4x6X+mtbMLSbPg3GvVURsFE9UEfthg/WvMPhJ3I2FkZtUCvesevUpZxc+tAA1aPnm1GEZpwFGam9f7VBIpPjjum7b5hhl2L8Nd1RN2y7u9eT+7VvS9GjtHFI0k9fzUOLElNh2aiy/ah25xdsQ2IPOQgZG0OqQ76pu50QjPqkOCVjgs9burWkAytqIc5UR4Xepy2u2IwtKLq89CJIeM6vNijIl9ftTJNsUHH82n1lpRjoRX1KPlmzwlBe/i9puOhEXXp3b3xZItkyaaPq8V6HjQSfeCUitWBgixthszqMPrujCbOeZXh7imlTFQ2Pcmcd3vKLmVTa+CqXtteRl0gJFZx0L8LMUjzZehExs7KqAUoTZ9Zalkdei6uwf6GkSoGvaXmSvRnVeT+lxVe+uM9s7d9Nkr9+/9LpY/b22lpNjUvi0BbHak+IKy/R+nbVImMaTV/FXooS76G9a7yQq3pW69ydFlewfeyg89r2cHK0tZhBB9dKB0Zb9n1Y+g5u4d9CM1EH0rT593t/SjkVMvPTcMWxEo7oTXZHD1rX9urD2+K/zrW2m8tyWUWplc7unGvVNqjYvD3aDVUHdW+HtxL2OF4z8j1QOvyj3lr5E6zxf9BQAbGfuk3qOpb3J/q0e1a1/dBeyT0a3wOGlGX3Q1xnzuc9fOTEnfnpzqbELmmyjOfEFCCzju4w2XLWY9tVzZ9dCJu6YDP7zPMm8prbv36K87EOEMNPRlfOuKFMafrTe6ime4vlHbwDIO2bV/mzHCcqRd5C2e5JzJZfHTQMpk9fXLuSCnT5L30bG2W8NlKD/olif82d7pjgdZtQenKsuIyA46pigzvX8O1pk44xhzh6LMLZrlOWjFxzfLWOG/H2pq167v1eHTccFXXxXtz5Z7xu+32Vyl1HzkG5V92Xk5OwsKZrvNyZzg/DVt7HYGxX7jGUlTWwsk/pphamwMTGh6bdRXfvfPsaK3VQdEIpUcNuZdZ5MmMy7VMlu8+0nW9tDDWPhL7kE5s2RUP7epdf0ZyLf+5df3Q26KxPsSLFcuvsTSlXdWasKPK1Op6ZEr2+I16t0O47FP9xYM9y5xZFwX2K459JZLrCpGDmFL2UYQUOaSu6z96kZbbIrVsQzYxe7tlpZ57Z2GkZKF1KFqOXXsTRUhVbxr+xYWnnBP2NQe1ahMtPq8v/H5JTkv3mlN6dq/76bQe0yM60atFu2jNJmQPb0lcSvWxu1ZGasyA+tF1gqmwwrlujp7nLtW2EUVMkYMqluqFHupJpNGSbWJ13t1ozyDhLrOZgViG3sC+VkeyGgi+mhnMKdVWJh12EEz8omwM6ZRqL5ueBO70HhLcRk62VzUpfptsR1xGtSmc1XQF28bnMeY10XHvMN5FNOg5kkFZuAZNv/T8cWJr3N5joEHZu09SNovU1P3VI5MXK5sMmnjsXTVkPDP1PKngudodspDDmvbxSO0QycGs4KzW1NrcYQebp/XE1sSetMbZE9GwQfvk/sPtnw4ouo62eosIDge0dorfb77XXmdiLRLLcfYvp0mN+yJ93Zt7j1LaITYPw/VfU2bv8amfSScE6kh2Rb9I2qN9cveeSD0IG+FBfdd/nsltktgqFsdY1ZczX8w4Y8atkZCn3RmM9Vm6wNZlT99T7fU/lG4xPdB/7O8j0n+0OwWLoO3OIec2byy9Py05tXzy4GtnR9AeoItQNPqz2xvS80++NSJ6C6czKlag8X+kXvqJlENKD315uJ9VAADhA+n7Yhgu2GLIqg5mf8PHRrufdFsmzr8wbiXXV9lyZzi2KJuDRkFxH79cs+ruXLay9jJqG5k/pvxr6iiXzK8wWtXos+P47H3PtUquTVqRL6V60BvlXHxULrNXKvy21ZLPtIci8Yw+LqEy/nRRft2+5x1cTlVTrifm7nh8hTO+nj5Pzqm7UW8Pwkd6/W6NyXulUfTZGtLULM8Vr1XcfdM/d64+//WdM9vX2Yk5/JL9hOB+ckuFqi/WnPp67cVkkz8uXZvGYpQGu39cSIU7R01ZyA5HYgstTtwdjkzkkArd6n9Iz3UtT19r7Vmyh/2+pnrX0+Sggh3Ul2lmWc2cXj7vipdKFg+C/tWXe/SKF3L+2O/rJtoyoAnG/vD0I3q3h5HSiFPfoVd7GDUNLznSj1QGOKViGOvOIkqTVaoDUUvbZTUMm8b1WqFsWgxUpJb2MukOYW5ermz8Bqzi/vay6avDZvygUZFCiLGFM90DtCY3yUSyHUnZNIRjb6Wz7HBO/B7u4Cccg7nny/Lveb48f/uPF6397bFrM5jdJ0rjhH9lvJ9de6SpwcIFOWZ2MT455FSGkMRlzG87SouD47AOspUykh0oZV/o6SZvi2oRbJTKUvjtfwgey8L8kp4eRsJxXbc3bn7osc/mWqKWPa56Y9tYqzfxMa+wDUtttd3xuKvfhbH4QFiXYP7fejZS3D/Vkp0cUs1Cmr+ROR7/vKz367H6oC45GxbGMVFC+82S/KKa9vi8PrGKPnOWbNJLivaIQBFSoVu12qtLPl9WeMHyV29Uq63QSpu1vTXl8/SjbB9LNvZ0tNLx/qo/3/bZKBoHHumYVktt1pHIktLabWGNxf9URkGaKi/MUaTW4awdqrs1Mrso7+TEtM09Zf4EbfXY5hpxTUK/sP1Lr/YwIn3X93P9pnl0KTShHcIR2Yn0fTFM5n/ne7eclUOL7P1B46J+QLIarfL+OHX5R1aT73IjlKfVb/4oGutLRIK821N2Fc10LzPaulLK4GtZrlI2vcktuPiACXbNQb5iUr51l7K9VWOS38VCoosOWDY92MDEZnI/C75Z7FfOODLKh5yt7H8axvvzkNI8hONB5/Wt+RN2WfjjbaMfL/t5eBVL9LjOmpKpDWdUp3bZ11mogh0i/QAaLCOtIUUp+yhCihxS0/qdrTsHu6HGLKecU7Tw+yW5tHZK3O6e30845UrVUiSViMTuod7ZPsLTP5r3fCTq4OEwfsTVNyo2eeFY8w9N2Sc+qFobRvrf2N40CZkNidU6QjZY+H7R0JHdFyWln/5gjVpyJDP51Ob2/berU/LYYb58qKV6crh1pD1l3xNqyt6Uve0Kp8uSw5r73aIcvhLL/Ui7I+piNWUwx9l/V9q4+xo5t5lepFx+pPel2nXkSMdgH4+6IXBPqvXGnJfJC81y43a17wuN1ZH71bLHu5XzCi1MunBSr6sqYI02KLVlvkrXpufWcK3hqXZ7Fa7nxfYU61Ff/5migSRJ+CK5fmssg0ipGMdSVkgTdC4Ni+hql9Fw2Nmef6Is2kBw+TWj3V96LVNVZvFnyqb24IVjU7QULRWQRZGpk6/VtpdN8+Td6XxYSOxkJokrGWeBqBAu9orcCdsAtbNBale+0/XO5NcTprIlfqTW9J9zR0pZpszmhFYLZvLM5SbP6uC6Rlp4UAi3Q4rKRmUMrDuR2HOqZGLDWrhprdoOqWkT+6/fZq7viAySuG9KtHWtheuRY4rWAVF7zY677SWf2libPSzcs+7mXdKcWB3Dky3UdEgRffvWva48TS4WjJU4uHy3XupGJOShl7PSz1TPIUWM6FPzCGtI+Cyj3j77Xue2hVppP4xyXxwqZzdn/t3l9L5ck1A81Sg60bNNTK2uR/rGp1ybXZiyQu+6MML6OI2taVdyKekOI5dfT3YKh0PKSOkt1SalyLHYCHowQltF47twOKSMMNaNiBxaX5AOFSXybDnrsSeZdqOlnhrw+X13G1X36277ulKpgxk6rz9Vw54f00vPZaDJUIcpsYT6f4PcWrVuf0P26LszmvQofOFM1wtMsFs6ue++zJnhOFML8hbNdH/RaaQdZ3NyZzhv1Z8tyOEmNisFcAtrc988HUbfBQlESjn4XmvfxTW7R2s1CqdgZtMwifEeLl9DQbAuF810jc2Z4YzYS5RdGRNGasxEZavMLF5ecmrTty3c1OEstHvFvbdljX5CbXtQKqxSa2vzo+OG74yGnrU4tqV1agZl7z6J1l/TSrpLLdsAzx/6swlsoy2bwA7asg3soQ1bwA7a7DdgJ23aBPUF/QbsoI5dulpOREoBFldS8DemzbWlSttlMyx+Id2NMqgPTfhSyjLDdHRKWfTqkAp0kibPo6yTdb7ICVT0rGua2rKSDF1I/ehvL5PuyJ3h2EIOQErhJ/lsf9BzvaAUCN3q076wtlh89KF1pLScFi5vRvw6ckBFsy4HI9MO9okkJbm1J4c6pNqEYrdpwR63nDOwNBwOqWg8CETi98mp279fZY2XexZyYSqmdTn03B4YKdqA1uSgtVIoyhA2MY4csa4L2EFb+oE9tKEb2EEf+ohlOxllzTWj2iZaOonmtYC+gVMKsMxN/2pq7TfsOq3JRTKRbEbWfffvZ39AkUY67tiqqAyGMIbJM5eiQQww3HC3lUW/UPoyZRjzdmffEz7+WMEs10lqyUnXJhk6tYhSFiqTXu0hc/HngL5lPq3gudpUvZaDZD/20z6/OeVfI8wnLxgx6ubeo+bprm7McC5T856PZF2KZARYrDwYRvIBsNEu/yn02OrzPAYniPrX/WDDG9PMdbw8y70rP0uWShd+vyQHNlH/+iWrHhxU8P0rUxf98uIli+YXmmETfd0HsA3soaX+Nxq/j3qA+mI0HcAO6ukGuo+NsW64kPRsyHB9AGND5k5YpmhVQ2/B8z+0yWRsMv873yv5Gq/Uq/wkO5XBCLagtGTCJB7RezmoDHpOsdZRDs7+r3MnoXBwmX2ixvpSdE26NsnQSVvmbiuLjuvGdOcPHdFSrfZ/6La9Csiu3DNKWahMui2HYHaj9YXBMtE6D3HCv3Lvas6eN/qDyZGORaMxnpX80q9SDad3a4mH/tW7Fjk74rzxD3VUFcmTKCyOvxnBJnq+LjkGq319i/1MmpO2M+598+DiubCJuteiRcqrvpz5QtVXs7ZWL5jzVfUXD/Y0Uh+ix98nm5Djduuyp+8hJy76dPX7KHrRRK9Rt4i0hU1gB23oCLqPjbFuWJ8xcTuAIAM+v5cmG9/QgChvtMsSE5DzrdVvfldvcpPMRnMcVvcqflxpqdfruHdbHyiDAaC0cV10EqYwIVaumt0YtbfDA9dSrsm6sAYZlYHKond7CMZuZpStk4lraO0fvclPMpPsbWUIlEW3UESRHm1wMNsEo6RoEVm+e+fZtI6UUxbzUt3iAi2sJxWth4RDfVCI5mSVg8nvhx57bNZVR7ogdiw9HEbi92WZm8kRFXquuXdJOuyh7vWsCTv3Wj+yW6Pver1HS0XLJpG6xoge39zRwqWbWxjP9iS2jmVNPd8xWv+hN5ufmJL/tLdp9+xqr/+hyj3ONUZyFIb2z4ejQzVenn6pZPEgsbN0F9u+dilt6TgWxlFavvYFy1+98bLlz7TSZ/SKF3L0Wg+Mck1y1v5m5T8+po9e7RHJ+1aN+kd2GPvD04/QmreoH9q4HtmEXjo5lP8DpxTYi/U3nfE7pq5j6o12GWKKBpf1Tj2l8SNZSWaj2YEmRAUXN7JO1jPSKH6SncpgFHtUpm/4RxedhCmSV/qscJZ7YqRlomvQtVgXHFIke6AMBiCwtpTEnqJ9IdgbekrjR7KSzAGTKGUwgpNQZmJH/pNVuo9SoTJQWULP3T7sknpyRN3Sa/S1tA5YrI0HtBrpn7oi/v5Ws+0+ckaZLdanKotTJxhV91r/zSAXXprjiZPlV0PPmRrEG0ayhx6vY/Yk7mQGJVL3c6TbNLufZ4cei8SWfrCNevYgtsm+vZ73y5xZFxm53pQOviipfNAFeWWDLhimfFKVj4U+yrlE5W9WtbP4eKvX33CwY7RT0b0WTeo6WOOcjod8qfmvsIe61/lXY9m9dZydSx8926OzZ49o/L9wQI6oXabmlTWSuCff+dkrGOuqfx1yEPbyeVe81a3h20P5f3BKgb2gCe0Bn993nbL7lAqXf4qubaRJ9a4y5q2RO9vT+LXoQNwWkpVkNqItKK2XMDHdDTRIZj2nJNsfNOEmmDy5i2t9pTBZfFQ40/VCJCbr6Tfpt+karCsOKUrbp8hOZTCKPcozNjyobAqVT5bktb+rhze/SUaSlWQm2dvLoP92akb8Ooc58TS9l4PKQGXB6Esf40NyFk7LGHPS9Zlj7nl03HDDT7wf7gNcNB/SW9cPva3V5LuDnFMma+PkyYOvnW1EW+jpt+t7N842yc0/Bo9NTL7FaM824dJdtOqJybr9i70HzWIJ2i/12i3C4nCuDj3ut6epyMh9aN+NC+uzNi0qkASjZ5T7lM+jQogRyrkG5W+tasvXJ8VZGHqclpxajnZKvbZLksRefYZV8LVGsIcefzvW0OMyOOQoRN1Qv360cjE8MOayrz7mUNLAcqUzhOHAftl65mOXCs7I6+yM8KVcXLAb+39x3/uxrvPVt+ZfKTH/W1qWUWamq455YfTbRrdF0Uz3R0r7OFEPsirt4YKcGY7fGtUWhbNdNzAvO5Q3YMqYhT1YlV4870gngsipkVE95Brl+g+yNsdGF5+22Y2505yvGs4WtH5XMHUhZ3NyZzhv1ba8rheYYLcou7VKRRlphCip0HuzV/mQUcHUd/prY11jKzOLl8fiiygAgMMjHM+t0XiGDKQuydo5bKTrh/L0Mx+sgV3Uf47fkT99or+175nNJlGybtfop2Op79k+6EJat9HXd+NCzawDTOtIxTUk3VluizuRSXH/zDvlxrkMqMrzP/zj8R6ZtqN2lHs29yk77U9GrCNH0oeokZqMInLIIXVxQtbjlEkAdlDPJjTJ/m5T6Zu0b5Ltf8s/+dYitBrqQikua611FxrFHpHwzUSzjlCb1b118M2yVPLDx6NueKWr5YFTChyU0kEXZHj7nvKosntthC7xhmX793/ss/HjKmi7jXV35t8lvP6nNFnvLKY/DHtmdEys90UTCpmVQxYpbeQZGm8LvyzvVTzBSBE5+6NolusxIbN7D/G/lQkTmytxz7ycO1LKDul6z9ZmycJ2DfezqexQnFEskCLu8ZzpzvsMawtan0kweuvYpOj3gbw7nQ9rUc6CZ1x/Uuz3kLLrV4YMZ+rVeXPQMlIaRW9cT71FGxXMbBrGLC01eben7EKvDwCIxoM7nh0BAAAAfffn6MsB6oQ+6gmcUiCsFE9dNDZu27q/KHfLmLDYj7GvW/oN+78hcycsg3Z/TXvEFEWGxGlEpBaZmW6MhQipUChlm8OUSPdorkZFLHT7G8aOvjujKRbsERL1cjidxnrOxSJhYpuEn63ngu8UlubAG2fca08SXKRxExvK/WygEHyC0osOPczGTfPRQ2GxxaymK5jMA+2BFp1wezkxJXFl7vT4d4xbL9wDaKuXKDC9yQsAAAAAAAAAAESTQ/HXaM1HAqcUiAhlgy4a3tr3JIqamqx8+hzify9VPh9Yt//wRtbGf6+FNg8OOQJlc8LbSh3NULmuVdEaUrHqQCTHlNOctEBrEVMUIeXy1U+MFYdUkMOMmIqOTQweIbUv7WkVX1J2TZzxeT+MFZuY1Xe68Fk3marKHrnlnIGl4brWLw8tHS6ltQ6Wd1o3HP3AuLUHra+mpOcFE9coh35mYVOMmEZxX1bNbswxidYdhxoRGPX6+2xtlp9be5w4LQEpLwAAAAAAAAAAAIMBpxSIOGUDLzy6td/JY5hgA3l7JIlyN/UP2Iexre3HhcrBJuu2FV9nbVr4C7R2aCy+7Kf+fXs2fSB7fTlqXF+ymIu218RPHv/e8Vtj2Q6Uyq935eCnDztKJ+wNIJtT0WvD742esu9AtKdl+xuj9cO1gV+Y2F+1msYukhTOdJ3HBP+gfLA5bntmqD1Edf+Na7LCkY9+y9wlk3q66uZ39D0b48ftz0lOKeE4kz5oi3LjbmWEMzl3hvPTmKkXbeX3aDUCiSKkBJNteks1CAAAAAAAAAAAgK4BpxQABoEWs+//rftO4fXThHe00vm1cIvpT1tPdTyDRej/R+Es90Qmi38quykqiVDLJH5T7nTHgli3Rfu6Rm+wQ1zvKQKUKd3RtUZcr6irUJTOuty4ggYn26tjNnNX7m0juh1xRMyuOW+/a2uWLgsee+zye6m3XHl58Jicxr2qB9/F/fx+ZfjjIJvIFnliLEbjBNZCk22986Y7f9CSXAWzXCdJkqdC65FcAAAAAAAAAAAAOHy66muSoCoAtA05hYY9M/ofuxocWYJJs5RTLRG8XAtdg65F14RDam/IGSSsnsGUqiza16Zr0rXhkGqDnECKPk5Qwxb72OSEWHZIEeT8cTn8L+17fuTX1mPD8ftuzksOdLzmpaL7h9aYtrdFzgmHxyQ+cPsbhsZqejhy+lRmbPgvOdDphQa15SEZSBaSCQ4pAAAAAAAAAAAAEIiUAkBnfH3VyrSURM/9EheXhmu9KVo3Shb8/doG2yNj3hq5E1runMCb/4L/PdJrTdHaUTIXf9Za5IOWCERNMf5cW9q2qHRA6zkTt8e6MyqUOUs29ZEzMxb55Lhj6LhvOfPXdWt78WX4Ku+fm1sbnjnc9c92L34gQS4Z8pKt2Xyqx+77dpv/nGlc2C5IEr88muEr7hH83rb4gX8aMSXnkVi3g6/ZHJ/t272pV/mQUTITO9RKl0fpBCXGe6CeAAAAAAAAAAAAsQHS9wFgcOgN9KO+rBrlMyVeYLJIow513SlaL8rvlZeb/Q0fbz4jYzmiog4PSl1m8pruUFrSSe2pw8LRwrmVFu5Dv8X/bKxGfBxOfcioGDqByeJ+1r7GXQQoZBJ/pKr3+kWoL/sn4JzqnZbmE87C0PO/+dIXuKeFJH/GpZblhxo1Q2nphBw3isvS2cG6lmr9L0ttLe34zr5p/WKN535uftHPTVNp3yy1rJbKqyYMW5Ow22FJOlOYm5fn3Z6yK5zX++WhpcPjUquv9PLuReu7p/47WCcKnqtN5T77KLe3/ovDdUQCAAAAAAAAAABAf8ApBUCMQRFUPZ07hntNSb04E4FJea9f6klbi0muCTQMjBda/PWVNa4eaxERFV7yn6yKd1gSLmibNBdj2aGvdVTGGV9Gk/Zub+PHmMw9fMhRKPmkG5UbfjI78vW/ahlnH8hm+RU4CLvGyytar3U52euh545b5WHOBlNoJ75e6cU3KqOQTUxiFYIzF/PzusDfTKIbF8zJZNabCT5QGaUM2k8UXOFg9kk6Y3Jm8EQsO6XeXLln/G674z+h50zCP/f2Y+03037QUcSl5sJwpNEjh1SGo2ZNqO63e8+5R8j2XNnkL023LJpE53e2nv1CLKbtIyd5eUJir8zGhkotOLBp3TXaXnhpjgctFAAAAAAAAACASAGnFAAAqAhFdjC/7ShmYsNpcl1pahO54M5AA82FS2nCGmgynvnZWmbybMZ6K5EhkELMxMc1C3Gqzc/7K73H0ANHtHG38rf1HpPYauf8W9kvlqqV+kzPPL+mLmfvSClRffI3redzWTpHkvkYpS6ccuhRhdyt1JnvZUl8zWX+Xu4Mx5Zd/3km17YhfUGbY0oqr3KnjT/6gXFrY1LnP7n+5DNZHgo9R9FSmes2jSwbPHQWN/lOtlkqatzrrdNPKekZeFlBtnjWHG70VOmL7zza3c3vCz1XbD0rzfXDZt79hJ1Voef3+HOSY8nJTve/iHMvlWRzoiz5GniLY9xtI7p1OLQpmtA+sPrYeFvpj5N6XVURzmvTyykJJx5lC+1PZr249aJ4ufpftO/o5n/qistH3YtWSl1Ofb324j7C0//k5JZPp03svx4aUR9yJCMCGgAAAAAAgCMHTikAAABgPwSiRrz2JMHkQPQAZ5JHWJrrw53eLJahaKmWhJbfMy7XMlm+O3RSnqI2MnYMHCb5+SDO+DFM5hmKERKUPyW0f6WRCeUjiSrBxGrZJDZW9di0bn9RHuXHXWJpnnTLIPuHczZm/ne+N1b1/WtHYFukFJflMm5v6nBWCb9t2W3DksbRPkV3Os2Jecoo0KGMqNy0/lSzr3H7gRxI9H27OaEvrRPV1/bvKbZm6bL//VUqj//9pVlb5i6Z5HL53gv9f06n+bIBU8/5MGZssbbxZ252H9Ohc59j9W3DE46l/dnbPhsVb+rxTfBv3ctr8i485ZywRGCuuzP/Ln/fhsdp386TP9uQabuQJtlffuF7f+j3mjPSR8SKI4TamqpT3ZP9ski3+F0Lp2SP37jv33f/pvH/bFLpuHrR79mbe4+aF2mZrnit4u4Ef9NjweNzE5qciGD7Hx9WvtU7zbyr32k9pn8bjeuRM+pB94CFTGbjM7jvmU+uS5sBK6jL7AVbh35Wn3BTMm/6+K1r+2FNQgAAAAAAnQGnFAAAAABAjNCWNlE8poyQ0s2m5mWCfT6Rs7MWcJNnbOj3bh3c84ADqEAaUnNSz4DDlvOE9hFlIzlu3b76mqDDavfiBxLkkiEvtTmmpPIaZ9IMcjwVT1001juIfxH6m3a347hYiWCjCe6yEVm/co5mrSmzkINoTmX+GzZfwtUdf5Dqn7o+c8w94bhu37LqvRwb5Ayktb5qdra2hJ6PFacU6WTHMfH/kezNZwTP7esEfL4s/55ujv85iJoa+ZB9HVdHAkXFtZSYT4jL9v14yzkDAwvgnf/6zpm9fbvvCH5neIolZpyEL5UsHtQipB59VqUW7M8R982OWafWxTu+VHbjvLu9H12SedvFkZbpqje2jS2Wk78KHp/Vt6Lno+OGI701a3PaWoeuf572t0i2398+7JL6aFz3hNfqtiqdVLZgrOTH67v1hyX2blNKra3N0bxHqY6YW1K/tLH6JS9OyTwPVlCfJ+fU3Timxboub7rzB2gDAACAFumqr0mCqgAAAAAA9M3vTra+cecxcRnZxWvsFA0VmEBsNX+11+DQbzvoW+fkdKLUiJS2kiY7Ah9ln86FRlB1H/9QI63f1TRiY/qyrAH9gpFQQ+ZOWJbsaHgy+D3aj6WUiuR42lfHdBxMC2aT29Z3DOIW0q5wXfdA57v3kW8PHlvNze/HigOkKm/ngFCHFFGZ7bgj9NjB5dTQ456VPD5c1//wnWVnWbbu2kapE2lLx4E6wZs+Dn6Hy84lsWKP18q/fiI+QRSnJPq/Kf+NayNFRO37HZvkO1XZxNG+pbslLxpyZWW51pLzI1BXle0ptZ66WLAH6f+DDW9MW/j9kpwDThKcUDChRZJuoE8Pk/uaaMnGJVaMHv3XUARZctXmkmO2l9Y8t25+UrSuK8vWwDrFfh6XBSv8D3Lavj614J+v5ZVOjOZ1yTEpC/NLKyy+F2GFvaEUxmpct3Cm64Vvn2l6HxbQBrSEQsEs10nQBAD6AE4pAAAAAACDEBqBkLlpwz/klsS5lEIujtd9IZXVXBfOa6Wf+WDNvg6RPjdf8UdaR4o+tB9zA2tFx+SIovWkaBuqc1f9zoflZvuXPq9lh8fc+KZoqJkbruvy+Iy/BPcpfd/a5LRPaP+35586Z8vgxHRv/9R+10w5/YpYrhv7OgUTpPJneMuuH2m/viX+zXClUiTcTfJ1+zumdGTd+3vTXRbrGed1q74wFvROk7dxdvcfgsfd7E1ZjXLmnft+7+emYQuUTWA9tAx3wwvhloOi1Ka+vNF/0z93rqYJfjpHESdn960YaefyJbSNhXWlKCLNnOzeYu3T8qw4rrRw0S8vXrK/71VZ+q4O7vfsXvdTtOQ7K6viOrLHTRk7x8RC/SAH4eI1b74yv/z5fx3IFmpyqaN6dqul4b5ma9OdsWAPirKllwjoQ/sH+p51gdRL6XmvYyfU/CGa8lHUrcR9U2RT7YMY8f4PckhtXZtR8+HEddOifnEuvrVJ/DNYYW9eObbsrwerQ5HCz609JInlwQK/btvUujald4cFtDUu15I8SN8HAAAAAACAzvnloaXDabthUOKGWJhc74zQdInkCBSmstPCmZ7vYLzz7vLH3XWmjslKRzf/U1dcPureWLQDRXV0T3fuFYHU0uzYb+pKmjTZPnSbM9yp4igFmdPb+mXHc6TsjNlUZOT8sHS3/DZ43LzHVnR5r2tyD2S79G4t8ZN6XVURbjlofbWNzPE4Rag5uHz3t9el/CsW7fFu5bxCe52pI2Jth7P74AO1U+RMNdt9TcF0oOHm1NdrAykz73VuWxirfci8l756x2JpCayZ6fXGvXewlznmnV88Oi3eUz7+veO3YgQSGcjJ1NSj+WnBxKr0evPVB9M1Ra31G1T17Zi3RiIFa4RYfNlP/SuLU6+WrGL19QV9FnRmu7grmudgPBp5m3SlDVp9a/6Vx7ww+m1oTBuQY8joa8piTSkAAAAAAABAzPL61vwJnjjebfvGboujuQ4Lrc9WkrT+JcG8p3Bm+T67fuiU0BSYsQY5CJPimjrWUwv3+l2dAafU/zgUp1SkoMmY/3P3dwePY3n9qH+ve3uvyRiTtXHyhKNvnh9tOSiSsEqY26KhJLa46NpuMVc/yCne1LSjNfRc713HJ6rRdpMDcvWuhPe8wjZsmN835e5bur0Sa/agdqJuqWj83xnx+nVz825SSx5ay0tI3oEJffc8HynHsNbrx66He6xTGqyj6dic6RpzzSdD8tWSh8ZZdnNCX0ozHqtjq3l5297zeU2TzRb/B9cU9LsMo351IUds/x67T66pj38ALyt03Sllxq0DAAAAAAAAMBrX9R+9SI3rtk9iXgELtJH5nePG2hz2oaN1W7aUaPpoSnb4I28OxqWO2uWf1qUvEZLrHDpuivM+Fau24FLSHYy5KbURrRFU1su8e3q0ZZAkgTfn2/Em7fnIUp/c4SRsccZ/r4YclcJ8YccrvzIbH4u2oIiON15aUmq1yH3ouNUrlar1MsFn9Qk3pQrbMNpfZzK/pGxizimlpXZi1otbL2qRFTv4zcy7Je5s5dRxsWaPpJLetp3Me3Tw2F/jTFFLFnJIOUyJ65lgvYpmuZ7Kme68L9bs0Zay0jSZ9skxpejkd2q+/FT0rGuaKWnrFf7d/e/PmeFcFov2MDP/025XEuuZFAj2V/UZoOC52tTuloIxFQ3HLtH6S3FYUwoAAAAAAAAAQESgyV5yEE4efO3sSKSC68r1KTKK1vM6N6HJSWt7xaotSP+mT0/o79vjyOSLcgae1mP6t2rYg9aNogipYPq+WLVHTX2PG1pNvjuc7vjHeX1Krhr1g+jFfQs7DiS2OFbt0ZA28FxK20cf2ldLjkTRWhnct3BPTEaCUDshVThPp9R99Ml2yferJYtXdEthMQ5NbAvB/8YZ+4UicwZMtnyhliw2W/wgarZoX8js9Fi0B6WpJDvQPm3VdjzYHeIZq7ffSFP3rY/Eoj0aLzTVeWVvCe1v3dF9hdryWBIqF+1KSXjfkVkxS+u6Q/o+AAAAAAAAAAAAgBiEUnM97up3Ie3fbS/51OhrXWgdSl33vjt9WgO39jovvW5mLKaL01r9KN7d52GbL+7sk2TpxhOnJRRBK+pSONP1AhMsj3H2YO4M56fQiPr2iLNuvblF7v+73GnOV2O1nUhY6O+mhXXtCt8tfNlvabnesdv25xFTclRxFHbZ10Rf1OMHAAAAAAAAAAAAAAAAAAAAtKXwU/P6XfXtcDh4AAAAAAAAAAAAAAAAAAAAQKTBmlIAAAAAAAAAAAAAAAAAAAAg4sApBQAAAAAAAAAAAAAAAAAAACIOnFIAAAAAAAAAAAAAAAAAAAAg4sApBQAAAAAAAAAAAAAAAAAAACIOnFIAAAAAAAAAAAAAAAAAAAAg4sApBQAAAAAAAAAAAAAAAAAAACIOnFIAAAAAAAAAAAAAAAAAAAAg4sApBQAAAAAAAAAAAAAAAAAAACIOnFIAAAAAAAAAAAAAAAAAAAAg4sApBQAAAAAAAAAAAAAAAAAAACIOnFIAAAAAAAAAAAAAAAAAAAAg4sApBQAAAAAAAAAAAAAAAAAAACLO/wswAA1Niv+YaMCdAAAAAElFTkSuQmCC") no-repeat; + background-size initial + background-position 0 0 + transition background-position 1s steps(25) + transition-duration 0s + + &.active + transition-duration 1s + background-position -2500px 0 + +</style> diff --git a/src/client/app/common/views/components/password-settings.vue b/src/client/app/common/views/components/password-settings.vue index 356f8b2fa4..eb511d6213 100644 --- a/src/client/app/common/views/components/password-settings.vue +++ b/src/client/app/common/views/components/password-settings.vue @@ -11,33 +11,50 @@ import i18n from '../../../i18n'; export default Vue.extend({ i18n: i18n('common/views/components/password-settings.vue'), methods: { - reset() { - this.$input({ + async reset() { + const { canceled: canceled1, result: currentPassword } = await this.$root.dialog({ title: this.$t('enter-current-password'), - type: 'password' - }).then(currentPassword => { - this.$input({ - title: this.$t('enter-new-password'), + input: { type: 'password' - }).then(newPassword => { - this.$input({ - title: this.$t('enter-new-password-again'), - type: 'password' - }).then(newPassword2 => { - if (newPassword !== newPassword2) { - this.$root.alert({ - title: null, - text: this.$t('not-match') - }); - return; - } - this.$root.api('i/change_password', { - currentPasword: currentPassword, - newPassword: newPassword - }).then(() => { - this.$notify(this.$t('changed')); - }); - }); + } + }); + if (canceled1) return; + + const { canceled: canceled2, result: newPassword } = await this.$root.dialog({ + title: this.$t('enter-new-password'), + input: { + type: 'password' + } + }); + if (canceled2) return; + + const { canceled: canceled3, result: newPassword2 } = await this.$root.dialog({ + title: this.$t('enter-new-password-again'), + input: { + type: 'password' + } + }); + if (canceled3) return; + + if (newPassword !== newPassword2) { + this.$root.dialog({ + title: null, + text: this.$t('not-match') + }); + return; + } + this.$root.api('i/change_password', { + currentPassword, + newPassword + }).then(() => { + this.$root.dialog({ + type: 'success', + text: this.$t('changed') + }); + }).catch(() => { + this.$root.dialog({ + type: 'error', + text: this.$t('failed') }); }); } diff --git a/src/client/app/common/views/components/poll.vue b/src/client/app/common/views/components/poll.vue index 8a31ec83d7..8817d88cc5 100644 --- a/src/client/app/common/views/components/poll.vue +++ b/src/client/app/common/views/components/poll.vue @@ -55,12 +55,12 @@ export default Vue.extend({ noteId: this.note.id, choice: id }).then(() => { - this.poll.choices.forEach(c => { + for (const c of this.poll.choices) { if (c.id == id) { c.votes++; Vue.set(c, 'isVoted', true); } - }); + } this.showResult = true; }); } diff --git a/src/client/app/common/views/components/profile-editor.vue b/src/client/app/common/views/components/profile-editor.vue index 080b8d6fc3..c883a8b91a 100644 --- a/src/client/app/common/views/components/profile-editor.vue +++ b/src/client/app/common/views/components/profile-editor.vue @@ -24,7 +24,7 @@ </ui-input> <ui-input v-model="birthday" type="date"> - <span>{{ $t('birthday') }}</span> + <span slot="title">{{ $t('birthday') }}</span> <span slot="prefix"><fa icon="birthday-cake"/></span> </ui-input> @@ -32,6 +32,12 @@ <span>{{ $t('description') }}</span> </ui-textarea> + <ui-select v-model="lang"> + <span slot="label">{{ $t('language') }}</span> + <span slot="icon"><fa icon="language"/></span> + <option v-for="lang in unique(Object.values(langmap).map(x => x.nativeName)).map(name => Object.keys(langmap).find(k => langmap[k].nativeName == name))" :value="lang" :key="lang">{{ langmap[lang].nativeName }}</option> + </ui-select> + <ui-input type="file" @change="onAvatarChange"> <span>{{ $t('avatar') }}</span> <span slot="icon"><fa icon="image"/></span> @@ -63,7 +69,21 @@ <div> <ui-switch v-model="isLocked" @change="save(false)">{{ $t('is-locked') }}</ui-switch> - <ui-switch v-model="carefulBot" @change="save(false)">{{ $t('careful-bot') }}</ui-switch> + <ui-switch v-model="carefulBot" :disabled="isLocked" @change="save(false)">{{ $t('careful-bot') }}</ui-switch> + <ui-switch v-model="autoAcceptFollowed" :disabled="!isLocked && !carefulBot" @change="save(false)">{{ $t('auto-accept-followed') }}</ui-switch> + </div> + </section> + + <section v-if="enableEmail"> + <header>{{ $t('email') }}</header> + + <div> + <template v-if="$store.state.i.email != null"> + <ui-info v-if="$store.state.i.emailVerified">{{ $t('email-verified') }}</ui-info> + <ui-info v-else warn>{{ $t('email-not-verified') }}</ui-info> + </template> + <ui-input v-model="email" type="email"><span>{{ $t('email-address') }}</span></ui-input> + <ui-button @click="updateEmail()">{{ $t('save') }}</ui-button> </div> </section> </ui-card> @@ -74,16 +94,24 @@ import Vue from 'vue'; import i18n from '../../../i18n'; import { apiUrl, host } from '../../../config'; import { toUnicode } from 'punycode'; +import langmap from 'langmap'; +import { unique } from '../../../../../prelude/array'; export default Vue.extend({ i18n: i18n('common/views/components/profile-editor.vue'), + data() { return { + unique, + langmap, host: toUnicode(host), + enableEmail: false, + email: null, name: null, username: null, location: null, description: null, + lang: null, birthday: null, avatarId: null, bannerId: null, @@ -91,6 +119,7 @@ export default Vue.extend({ isBot: false, isLocked: false, carefulBot: false, + autoAcceptFollowed: false, saving: false, avatarUploading: false, bannerUploading: false @@ -113,10 +142,15 @@ export default Vue.extend({ }, created() { - this.name = this.$store.state.i.name || ''; + this.$root.getMeta().then(meta => { + this.enableEmail = meta.enableEmail; + }); + this.email = this.$store.state.i.email; + this.name = this.$store.state.i.name; this.username = this.$store.state.i.username; this.location = this.$store.state.i.profile.location; this.description = this.$store.state.i.description; + this.lang = this.$store.state.i.lang; this.birthday = this.$store.state.i.profile.birthday; this.avatarId = this.$store.state.i.avatarId; this.bannerId = this.$store.state.i.bannerId; @@ -124,6 +158,7 @@ export default Vue.extend({ this.isBot = this.$store.state.i.isBot; this.isLocked = this.$store.state.i.isLocked; this.carefulBot = this.$store.state.i.carefulBot; + this.autoAcceptFollowed = this.$store.state.i.autoAcceptFollowed; }, methods: { @@ -178,13 +213,15 @@ export default Vue.extend({ name: this.name || null, location: this.location || null, description: this.description || null, + lang: this.lang, birthday: this.birthday || null, - avatarId: this.avatarId, - bannerId: this.bannerId, + avatarId: this.avatarId || undefined, + bannerId: this.bannerId || undefined, isCat: !!this.isCat, isBot: !!this.isBot, isLocked: !!this.isLocked, - carefulBot: !!this.carefulBot + carefulBot: !!this.carefulBot, + autoAcceptFollowed: !!this.autoAcceptFollowed }).then(i => { this.saving = false; this.$store.state.i.avatarId = i.avatarId; @@ -193,12 +230,27 @@ export default Vue.extend({ this.$store.state.i.bannerUrl = i.bannerUrl; if (notify) { - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('saved') }); } }); + }, + + updateEmail() { + this.$root.dialog({ + title: this.$t('@.enter-password'), + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + this.$root.api('i/update_email', { + password: password, + email: this.email == '' ? null : this.email + }); + }); } } }); diff --git a/src/client/app/common/views/components/reaction-picker.vue b/src/client/app/common/views/components/reaction-picker.vue index 62f4930edb..54c8e2a68f 100644 --- a/src/client/app/common/views/components/reaction-picker.vue +++ b/src/client/app/common/views/components/reaction-picker.vue @@ -1,19 +1,19 @@ <template> -<div class="mk-reaction-picker" v-hotkey.global="keymap"> +<div class="rdfaahpb" v-hotkey.global="keymap"> <div class="backdrop" ref="backdrop" @click="close"></div> - <div class="popover" :class="{ compact, big }" ref="popover"> - <p v-if="!compact">{{ title }}</p> + <div class="popover" :class="{ isMobile: $root.isMobile }" ref="popover"> + <p v-if="!$root.isMobile">{{ title }}</p> <div ref="buttons" :class="{ showFocus }"> - <button @click="react('like')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="1" :title="$t('@.reactions.like')"><mk-reaction-icon reaction='like'/></button> - <button @click="react('love')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="2" :title="$t('@.reactions.love')"><mk-reaction-icon reaction='love'/></button> - <button @click="react('laugh')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="3" :title="$t('@.reactions.laugh')"><mk-reaction-icon reaction='laugh'/></button> - <button @click="react('hmm')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="4" :title="$t('@.reactions.hmm')"><mk-reaction-icon reaction='hmm'/></button> - <button @click="react('surprise')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="5" :title="$t('@.reactions.surprise')"><mk-reaction-icon reaction='surprise'/></button> - <button @click="react('congrats')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="6" :title="$t('@.reactions.congrats')"><mk-reaction-icon reaction='congrats'/></button> - <button @click="react('angry')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="7" :title="$t('@.reactions.angry')"><mk-reaction-icon reaction='angry'/></button> - <button @click="react('confused')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="8" :title="$t('@.reactions.confused')"><mk-reaction-icon reaction='confused'/></button> - <button @click="react('rip')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="9" :title="$t('@.reactions.rip')"><mk-reaction-icon reaction='rip'/></button> - <button @click="react('pudding')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="10" :title="$t('@.reactions.pudding')"><mk-reaction-icon reaction='pudding'/></button> + <button @click="react('like')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="1" :title="$t('@.reactions.like')" v-particle><mk-reaction-icon reaction="like"/></button> + <button @click="react('love')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="2" :title="$t('@.reactions.love')" v-particle><mk-reaction-icon reaction="love"/></button> + <button @click="react('laugh')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="3" :title="$t('@.reactions.laugh')" v-particle><mk-reaction-icon reaction="laugh"/></button> + <button @click="react('hmm')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="4" :title="$t('@.reactions.hmm')" v-particle><mk-reaction-icon reaction="hmm"/></button> + <button @click="react('surprise')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="5" :title="$t('@.reactions.surprise')" v-particle><mk-reaction-icon reaction="surprise"/></button> + <button @click="react('congrats')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="6" :title="$t('@.reactions.congrats')" v-particle><mk-reaction-icon reaction="congrats"/></button> + <button @click="react('angry')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="7" :title="$t('@.reactions.angry')" v-particle><mk-reaction-icon reaction="angry"/></button> + <button @click="react('confused')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="8" :title="$t('@.reactions.confused')" v-particle><mk-reaction-icon reaction="confused"/></button> + <button @click="react('rip')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="9" :title="$t('@.reactions.rip')" v-particle><mk-reaction-icon reaction="rip"/></button> + <button @click="react('pudding')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="10" :title="$t('@.reactions.pudding')" v-particle><mk-reaction-icon reaction="pudding"/></button> </div> </div> </div> @@ -22,7 +22,7 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import * as anime from 'animejs'; +import anime from 'animejs'; export default Vue.extend({ i18n: i18n('common/views/components/reaction-picker.vue'), @@ -36,22 +36,10 @@ export default Vue.extend({ required: true }, - compact: { - type: Boolean, - required: false, - default: false - }, - cb: { required: false }, - big: { - type: Boolean, - required: false, - default: false - }, - showFocus: { type: Boolean, required: false, @@ -115,7 +103,7 @@ export default Vue.extend({ const width = popover.offsetWidth; const height = popover.offsetHeight; - if (this.compact) { + if (this.$root.isMobile) { const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); popover.style.left = (x - (width / 2)) + 'px'; @@ -210,9 +198,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -$border-color = rgba(27, 31, 35, 0.15) - -.mk-reaction-picker +.rdfaahpb position initial > .backdrop @@ -230,41 +216,12 @@ $border-color = rgba(27, 31, 35, 0.15) position absolute z-index 10001 background $bgcolor - border 1px solid $border-color border-radius 4px box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) transform scale(0.5) opacity 0 - $balloon-size = 16px - - &:not(.compact) - margin-top $balloon-size - transform-origin center -($balloon-size) - - &:before - content "" - display block - position absolute - top -($balloon-size * 2) - left s('calc(50% - %s)', $balloon-size) - border-top solid $balloon-size transparent - border-left solid $balloon-size transparent - border-right solid $balloon-size transparent - border-bottom solid $balloon-size $border-color - - &:after - content "" - display block - position absolute - top -($balloon-size * 2) + 1.5px - left s('calc(50% - %s)', $balloon-size) - border-top solid $balloon-size transparent - border-left solid $balloon-size transparent - border-right solid $balloon-size transparent - border-bottom solid $balloon-size $bgcolor - - &.big + &.isMobile > div width 280px @@ -274,13 +231,30 @@ $border-color = rgba(27, 31, 35, 0.15) font-size 28px border-radius 4px + &:not(.isMobile) + $arrow-size = 16px + + margin-top $arrow-size + transform-origin center -($arrow-size) + + &:before + content "" + display block + position absolute + top -($arrow-size * 2) + left s('calc(50% - %s)', $arrow-size) + border-top solid $arrow-size transparent + border-left solid $arrow-size transparent + border-right solid $arrow-size transparent + border-bottom solid $arrow-size $bgcolor + > p display block margin 0 padding 8px 10px font-size 14px color var(--popupFg) - border-bottom solid 1px var(--faceDivider) + border-bottom solid var(--lineWidth) var(--faceDivider) > div padding 4px diff --git a/src/client/app/common/views/components/reactions-viewer.vue b/src/client/app/common/views/components/reactions-viewer.vue index 7f17d16a71..440e6366fd 100644 --- a/src/client/app/common/views/components/reactions-viewer.vue +++ b/src/client/app/common/views/components/reactions-viewer.vue @@ -1,35 +1,130 @@ <template> -<div class="mk-reactions-viewer"> +<div class="mk-reactions-viewer" :class="{ isMe }"> <template v-if="reactions"> - <span :class="{ reacted: note.myReaction == 'like' }" @click="react('like')" v-if="reactions.like"><mk-reaction-icon reaction="like"/><span>{{ reactions.like }}</span></span> - <span :class="{ reacted: note.myReaction == 'love' }" @click="react('love')" v-if="reactions.love"><mk-reaction-icon reaction="love"/><span>{{ reactions.love }}</span></span> - <span :class="{ reacted: note.myReaction == 'laugh' }" @click="react('laugh')" v-if="reactions.laugh"><mk-reaction-icon reaction="laugh"/><span>{{ reactions.laugh }}</span></span> - <span :class="{ reacted: note.myReaction == 'hmm' }" @click="react('hmm')" v-if="reactions.hmm"><mk-reaction-icon reaction="hmm"/><span>{{ reactions.hmm }}</span></span> - <span :class="{ reacted: note.myReaction == 'surprise' }" @click="react('surprise')" v-if="reactions.surprise"><mk-reaction-icon reaction="surprise"/><span>{{ reactions.surprise }}</span></span> - <span :class="{ reacted: note.myReaction == 'congrats' }" @click="react('congrats')" v-if="reactions.congrats"><mk-reaction-icon reaction="congrats"/><span>{{ reactions.congrats }}</span></span> - <span :class="{ reacted: note.myReaction == 'angry' }" @click="react('angry')" v-if="reactions.angry"><mk-reaction-icon reaction="angry"/><span>{{ reactions.angry }}</span></span> - <span :class="{ reacted: note.myReaction == 'confused' }" @click="react('confused')" v-if="reactions.confused"><mk-reaction-icon reaction="confused"/><span>{{ reactions.confused }}</span></span> - <span :class="{ reacted: note.myReaction == 'rip' }" @click="react('rip')" v-if="reactions.rip"><mk-reaction-icon reaction="rip"/><span>{{ reactions.rip }}</span></span> - <span :class="{ reacted: note.myReaction == 'pudding' }" @click="react('pudding')" v-if="reactions.pudding"><mk-reaction-icon reaction="pudding"/><span>{{ reactions.pudding }}</span></span> + <span :class="{ reacted: note.myReaction == 'like' }" @click="toggleReaction('like')" v-if="reactions.like" v-particle="!isMe"><mk-reaction-icon reaction="like" ref="like"/><span>{{ reactions.like }}</span></span> + <span :class="{ reacted: note.myReaction == 'love' }" @click="toggleReaction('love')" v-if="reactions.love" v-particle="!isMe"><mk-reaction-icon reaction="love" ref="love"/><span>{{ reactions.love }}</span></span> + <span :class="{ reacted: note.myReaction == 'laugh' }" @click="toggleReaction('laugh')" v-if="reactions.laugh" v-particle="!isMe"><mk-reaction-icon reaction="laugh" ref="laugh"/><span>{{ reactions.laugh }}</span></span> + <span :class="{ reacted: note.myReaction == 'hmm' }" @click="toggleReaction('hmm')" v-if="reactions.hmm" v-particle="!isMe"><mk-reaction-icon reaction="hmm" ref="hmm"/><span>{{ reactions.hmm }}</span></span> + <span :class="{ reacted: note.myReaction == 'surprise' }" @click="toggleReaction('surprise')" v-if="reactions.surprise" v-particle="!isMe"><mk-reaction-icon reaction="surprise" ref="surprise"/><span>{{ reactions.surprise }}</span></span> + <span :class="{ reacted: note.myReaction == 'congrats' }" @click="toggleReaction('congrats')" v-if="reactions.congrats" v-particle="!isMe"><mk-reaction-icon reaction="congrats" ref="congrats"/><span>{{ reactions.congrats }}</span></span> + <span :class="{ reacted: note.myReaction == 'angry' }" @click="toggleReaction('angry')" v-if="reactions.angry" v-particle="!isMe"><mk-reaction-icon reaction="angry" ref="angry"/><span>{{ reactions.angry }}</span></span> + <span :class="{ reacted: note.myReaction == 'confused' }" @click="toggleReaction('confused')" v-if="reactions.confused" v-particle="!isMe"><mk-reaction-icon reaction="confused" ref="confused"/><span>{{ reactions.confused }}</span></span> + <span :class="{ reacted: note.myReaction == 'rip' }" @click="toggleReaction('rip')" v-if="reactions.rip" v-particle="!isMe"><mk-reaction-icon reaction="rip" ref="rip"/><span>{{ reactions.rip }}</span></span> + <span :class="{ reacted: note.myReaction == 'pudding' }" @click="toggleReaction('pudding')" v-if="reactions.pudding" v-particle="!isMe"><mk-reaction-icon reaction="pudding" ref="pudding"/><span>{{ reactions.pudding }}</span></span> </template> </div> </template> <script lang="ts"> import Vue from 'vue'; +import Icon from './reaction-icon.vue'; +import anime from 'animejs'; export default Vue.extend({ - props: ['note'], + props: { + note: { + type: Object, + required: true + } + }, computed: { - reactions(): number { + reactions(): any { return this.note.reactionCounts; + }, + isMe(): boolean { + return this.$store.getters.isSignedIn && (this.$store.state.i.id === this.note.userId); + } + }, + watch: { + 'reactions.like'() { + this.anime('like'); + }, + 'reactions.love'() { + this.anime('love'); + }, + 'reactions.laugh'() { + this.anime('laugh'); + }, + 'reactions.hmm'() { + this.anime('hmm'); + }, + 'reactions.surprise'() { + this.anime('surprise'); + }, + 'reactions.congrats'() { + this.anime('congrats'); + }, + 'reactions.angry'() { + this.anime('angry'); + }, + 'reactions.confused'() { + this.anime('confused'); + }, + 'reactions.rip'() { + this.anime('rip'); + }, + 'reactions.pudding'() { + this.anime('pudding'); } }, methods: { - react(reaction: string) { - this.$root.api('notes/reactions/create', { - noteId: this.note.id, - reaction: reaction + toggleReaction(reaction: string) { + if (this.isMe) return; + + const oldReaction = this.note.myReaction; + if (oldReaction) { + this.$root.api('notes/reactions/delete', { + noteId: this.note.id + }).then(() => { + if (oldReaction !== reaction) { + this.$root.api('notes/reactions/create', { + noteId: this.note.id, + reaction: reaction + }); + } + }); + } else { + this.$root.api('notes/reactions/create', { + noteId: this.note.id, + reaction: reaction + }); + } + }, + anime(reaction: string) { + if (this.$store.state.device.reduceMotion) return; + if (document.hidden) return; + + this.$nextTick(() => { + const rect = this.$refs[reaction].$el.getBoundingClientRect(); + + const x = rect.left; + const y = rect.top; + + const icon = new Icon({ + parent: this, + propsData: { + reaction: reaction + } + }).$mount(); + + icon.$el.style.position = 'absolute'; + icon.$el.style.zIndex = 100; + icon.$el.style.top = (y + window.scrollY) + 'px'; + icon.$el.style.left = (x + window.scrollX) + 'px'; + icon.$el.style.fontSize = window.getComputedStyle(this.$refs[reaction].$el).fontSize; + + document.body.appendChild(icon.$el); + + anime({ + targets: icon.$el, + opacity: [1, 0], + translateY: [0, -64], + duration: 1000, + easing: 'linear', + complete: () => { + icon.destroyDom(); + } + }); }); } } @@ -43,12 +138,20 @@ export default Vue.extend({ &:empty display none + &.isMe + > span + cursor default !important + + &:hover + background var(--reactionViewerButtonBg) !important + > span display inline-block height 32px margin-right 6px padding 0 6px border-radius 4px + cursor pointer * user-select none @@ -61,7 +164,6 @@ export default Vue.extend({ color var(--primaryForeground) &:not(.reacted) - cursor pointer background var(--reactionViewerButtonBg) &:hover diff --git a/src/client/app/common/views/components/renote.vue b/src/client/app/common/views/components/renote.vue new file mode 100644 index 0000000000..591c546eed --- /dev/null +++ b/src/client/app/common/views/components/renote.vue @@ -0,0 +1,109 @@ +<template> +<div class="puqkfets" :class="{ mini }"> + <mk-avatar class="avatar" :user="note.user"/> + <fa icon="retweet"/> + <i18n path="@.renoted-by" tag="span"> + <router-link class="name" :to="note.user | userPage" v-user-preview="note.userId" place="user"> + <mk-user-name :user="note.user"/> + </router-link> + </i18n> + <div class="info"> + <span class="mobile" v-if="note.viaMobile"><fa icon="mobile-alt"/></span> + <mk-time :time="note.createdAt"/> + <span class="visibility" v-if="note.visibility != 'public'"> + <fa v-if="note.visibility == 'home'" icon="home"/> + <fa v-if="note.visibility == 'followers'" icon="unlock"/> + <fa v-if="note.visibility == 'specified'" icon="envelope"/> + </span> + <span class="localOnly" v-if="note.localOnly == true"><fa icon="heart"/></span> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; + +export default Vue.extend({ + i18n: i18n(), + props: { + note: { + type: Object, + required: true + }, + mini: { + type: Boolean, + required: false, + default: false + } + } +}); +</script> + +<style lang="stylus" scoped> +.puqkfets + display flex + align-items center + padding 16px 32px 8px 32px + line-height 28px + white-space pre + color var(--renoteText) + background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%) + + &.mini + padding 8px 16px + + @media (min-width 500px) + padding 16px + + @media (min-width 600px) + padding 16px 32px + + > .avatar + @media (min-width 500px) + width 28px + height 28px + + > .avatar + flex-shrink 0 + display inline-block + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px + + > [data-icon] + margin-right 4px + + > span + overflow hidden + flex-shrink 1 + text-overflow ellipsis + white-space nowrap + + > .name + font-weight bold + + > .info + margin-left auto + font-size 0.9em + + > .mobile + margin-right 8px + + > .mk-time + flex-shrink 0 + + > .visibility + margin-left 8px + + [data-icon] + margin-right 0 + + > .localOnly + margin-left 4px + + [data-icon] + margin-right 0 + +</style> diff --git a/src/client/app/common/views/components/signin.vue b/src/client/app/common/views/components/signin.vue index c1a7522b00..93d19edf22 100644 --- a/src/client/app/common/views/components/signin.vue +++ b/src/client/app/common/views/components/signin.vue @@ -6,11 +6,14 @@ <span slot="prefix">@</span> <span slot="suffix">@{{ host }}</span> </ui-input> - <ui-input v-model="password" type="password" required styl="fill"> + <ui-input v-model="password" type="password" :with-password-toggle="true" required styl="fill"> <span>{{ $t('password') }}</span> <span slot="prefix"><fa icon="lock"/></span> </ui-input> - <ui-input v-if="user && user.twoFactorEnabled" v-model="token" type="number" required styl="fill"/> + <ui-input v-if="user && user.twoFactorEnabled" v-model="token" type="number" required styl="fill"> + <span>{{ $t('@.2fa') }}</span> + <span slot="prefix"><fa icon="gavel"/></span> + </ui-input> <ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('signin') }}</ui-button> <p v-if="meta && meta.enableTwitterIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/twitter`">{{ $t('signin-with-twitter') }}</a></p> <p v-if="meta && meta.enableGithubIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/github`">{{ $t('signin-with-github') }}</a></p> @@ -26,6 +29,7 @@ import { toUnicode } from 'punycode'; export default Vue.extend({ i18n: i18n('common/views/components/signin.vue'), + props: { withAvatar: { type: Boolean, @@ -33,6 +37,7 @@ export default Vue.extend({ default: true } }, + data() { return { signing: false, @@ -45,11 +50,13 @@ export default Vue.extend({ meta: null }; }, + created() { this.$root.getMeta().then(meta => { this.meta = meta; }); }, + methods: { onUsernameChange() { this.$root.api('users/show', { @@ -60,6 +67,7 @@ export default Vue.extend({ this.user = null; }); }, + onSubmit() { this.signing = true; @@ -67,7 +75,8 @@ export default Vue.extend({ username: this.username, password: this.password, token: this.user && this.user.twoFactorEnabled ? this.token : undefined - }, true).then(() => { + }).then(res => { + localStorage.setItem('i', res.i); location.reload(); }).catch(() => { alert(this.$t('login-failed')); @@ -79,8 +88,6 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> - - .mk-signin color #555 diff --git a/src/client/app/common/views/components/signup.vue b/src/client/app/common/views/components/signup.vue index e38c9284d1..f38aaad8ed 100644 --- a/src/client/app/common/views/components/signup.vue +++ b/src/client/app/common/views/components/signup.vue @@ -50,6 +50,7 @@ import { toUnicode } from 'punycode'; export default Vue.extend({ i18n: i18n('common/views/components/signup.vue'), + data() { return { host: toUnicode(host), @@ -64,6 +65,7 @@ export default Vue.extend({ meta: null } }, + computed: { shouldShowProfileUrl(): boolean { return (this.username != '' && @@ -72,17 +74,20 @@ export default Vue.extend({ this.usernameState != 'max-range'); } }, + created() { this.$root.getMeta().then(meta => { this.meta = meta; }); }, + mounted() { const head = document.getElementsByTagName('head')[0]; const script = document.createElement('script'); script.setAttribute('src', 'https://www.google.com/recaptcha/api.js'); head.appendChild(script); }, + methods: { onChangeUsername() { if (this.username == '') { @@ -111,6 +116,7 @@ export default Vue.extend({ this.usernameState = 'error'; }); }, + onChangePassword() { if (this.password == '') { this.passwordStrength = ''; @@ -120,6 +126,7 @@ export default Vue.extend({ const strength = getPasswordStrength(this.password); this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; }, + onChangePasswordRetype() { if (this.retypedPassword == '') { this.passwordRetypeState = null; @@ -128,18 +135,20 @@ export default Vue.extend({ this.passwordRetypeState = this.password == this.retypedPassword ? 'match' : 'not-match'; }, + onSubmit() { this.$root.api('signup', { username: this.username, password: this.password, invitationCode: this.invitationCode, 'g-recaptcha-response': this.meta.enableRecaptcha ? (window as any).grecaptcha.getResponse() : null - }, true).then(() => { + }).then(() => { this.$root.api('signin', { username: this.username, password: this.password - }, true).then(() => { - location.href = '/'; + }).then(res => { + localStorage.setItem('i', res.i); + location.reload(); }); }).catch(() => { alert(this.$t('some-error')); @@ -154,8 +163,6 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> - - .mk-signup min-width 302px </style> diff --git a/src/client/app/common/views/components/stream-indicator.vue b/src/client/app/common/views/components/stream-indicator.vue index 0a1e8bf9d3..8ab1cfcfeb 100644 --- a/src/client/app/common/views/components/stream-indicator.vue +++ b/src/client/app/common/views/components/stream-indicator.vue @@ -18,7 +18,7 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import * as anime from 'animejs'; +import anime from 'animejs'; export default Vue.extend({ i18n: i18n('common/views/components/stream-indicator.vue'), diff --git a/src/client/app/common/views/components/theme.vue b/src/client/app/common/views/components/theme.vue index 8e23d4cfa7..1a2381e563 100644 --- a/src/client/app/common/views/components/theme.vue +++ b/src/client/app/common/views/components/theme.vue @@ -1,99 +1,104 @@ <template> -<div class="nicnklzforebnpfgasiypmpdaaglujqm"> - <label> - <span><fa :icon="faSun"/> {{ $t('light-theme') }}</span> - <ui-select v-model="light" :placeholder="$t('light-theme')"> - <optgroup :label="$t('light-themes')"> - <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="$t('dark-themes')"> - <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - </ui-select> - </label> +<ui-card> + <div slot="title"><fa icon="palette"/> {{ $t('theme') }}</div> + <section class="nicnklzforebnpfgasiypmpdaaglujqm fit-top"> + <label> + <ui-select v-model="light" :placeholder="$t('light-theme')"> + <span slot="label"><fa :icon="faSun"/> {{ $t('light-theme') }}</span> + <optgroup :label="$t('light-themes')"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$t('dark-themes')"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </ui-select> + </label> - <label> - <span><fa :icon="faMoon"/> {{ $t('dark-theme') }}</span> - <ui-select v-model="dark" :placeholder="$t('dark-theme')"> - <optgroup :label="$t('dark-themes')"> - <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="$t('light-themes')"> - <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - </ui-select> - </label> + <label> + <ui-select v-model="dark" :placeholder="$t('dark-theme')"> + <span slot="label"><fa :icon="faMoon"/> {{ $t('dark-theme') }}</span> + <optgroup :label="$t('dark-themes')"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$t('light-themes')"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </ui-select> + </label> - <details class="creator"> - <summary><fa icon="palette"/> {{ $t('create-a-theme') }}</summary> - <div> - <span>{{ $t('base-theme') }}:</span> - <ui-radio v-model="myThemeBase" value="light">{{ $t('base-theme-light') }}</ui-radio> - <ui-radio v-model="myThemeBase" value="dark">{{ $t('base-theme-dark') }}</ui-radio> - </div> - <div> - <ui-input v-model="myThemeName"> - <span>{{ $t('theme-name') }}</span> - </ui-input> - <ui-textarea v-model="myThemeDesc"> - <span>{{ $t('desc') }}</span> - </ui-textarea> - </div> - <div> - <div style="padding-bottom:8px;">{{ $t('primary-color') }}:</div> - <color-picker v-model="myThemePrimary"/> - </div> - <div> - <div style="padding-bottom:8px;">{{ $t('secondary-color') }}:</div> - <color-picker v-model="myThemeSecondary"/> - </div> - <div> - <div style="padding-bottom:8px;">{{ $t('text-color') }}:</div> - <color-picker v-model="myThemeText"/> - </div> - <ui-button @click="preview()"><fa icon="eye"/> {{ $t('preview-created-theme') }}</ui-button> - <ui-button primary @click="gen()"><fa :icon="['far', 'save']"/> {{ $t('save-created-theme') }}</ui-button> - </details> + <a href="https://assets.msky.cafe/theme/list" target="_blank">{{ $t('find-more-theme') }}</a> - <details> - <summary><fa icon="download"/> {{ $t('install-a-theme') }}</summary> - <ui-button @click="import_()"><fa icon="file-import"/> {{ $t('import') }}</ui-button> - <input ref="file" type="file" accept=".misskeytheme" style="display:none;" @change="onUpdateImportFile"/> - <p>{{ $t('import-by-code') }}:</p> - <ui-textarea v-model="installThemeCode"> - <span>{{ $t('theme-code') }}</span> - </ui-textarea> - <ui-button @click="() => install(this.installThemeCode)"><fa icon="check"/> {{ $t('install') }}</ui-button> - </details> + <details class="creator"> + <summary><fa icon="palette"/> {{ $t('create-a-theme') }}</summary> + <div> + <span>{{ $t('base-theme') }}:</span> + <ui-radio v-model="myThemeBase" value="light">{{ $t('base-theme-light') }}</ui-radio> + <ui-radio v-model="myThemeBase" value="dark">{{ $t('base-theme-dark') }}</ui-radio> + </div> + <div> + <ui-input v-model="myThemeName"> + <span>{{ $t('theme-name') }}</span> + </ui-input> + <ui-textarea v-model="myThemeDesc"> + <span>{{ $t('desc') }}</span> + </ui-textarea> + </div> + <div> + <div style="padding-bottom:8px;">{{ $t('primary-color') }}:</div> + <color-picker v-model="myThemePrimary"/> + </div> + <div> + <div style="padding-bottom:8px;">{{ $t('secondary-color') }}:</div> + <color-picker v-model="myThemeSecondary"/> + </div> + <div> + <div style="padding-bottom:8px;">{{ $t('text-color') }}:</div> + <color-picker v-model="myThemeText"/> + </div> + <ui-button @click="preview()"><fa icon="eye"/> {{ $t('preview-created-theme') }}</ui-button> + <ui-button primary @click="gen()"><fa :icon="['far', 'save']"/> {{ $t('save-created-theme') }}</ui-button> + </details> - <details> - <summary><fa icon="folder-open"/> {{ $t('manage-themes') }}</summary> - <ui-select v-model="selectedThemeId" :placeholder="$t('select-theme')"> - <optgroup :label="$t('builtin-themes')"> - <option v-for="x in builtinThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="$t('my-themes')"> - <option v-for="x in installedThemes.filter(t => t.author == this.$store.state.i.username)" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="$t('installed-themes')"> - <option v-for="x in installedThemes.filter(t => t.author != this.$store.state.i.username)" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - </ui-select> - <template v-if="selectedTheme"> - <ui-input readonly :value="selectedTheme.author"> - <span>{{ $t('author') }}</span> - </ui-input> - <ui-textarea v-if="selectedTheme.desc" readonly :value="selectedTheme.desc"> - <span>{{ $t('desc') }}</span> - </ui-textarea> - <ui-textarea readonly :value="selectedThemeCode"> + <details> + <summary><fa icon="download"/> {{ $t('install-a-theme') }}</summary> + <ui-button @click="import_()"><fa icon="file-import"/> {{ $t('import') }}</ui-button> + <input ref="file" type="file" accept=".misskeytheme" style="display:none;" @change="onUpdateImportFile"/> + <p>{{ $t('import-by-code') }}:</p> + <ui-textarea v-model="installThemeCode"> <span>{{ $t('theme-code') }}</span> </ui-textarea> - <ui-button @click="export_()" link :download="`${selectedTheme.name}.misskeytheme`" ref="export"><fa icon="box"/> {{ $t('export') }}</ui-button> - <ui-button @click="uninstall()" v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><fa :icon="['far', 'trash-alt']"/> {{ $t('uninstall') }}</ui-button> - </template> - </details> -</div> + <ui-button @click="() => install(this.installThemeCode)"><fa icon="check"/> {{ $t('install') }}</ui-button> + </details> + + <details> + <summary><fa icon="folder-open"/> {{ $t('manage-themes') }}</summary> + <ui-select v-model="selectedThemeId" :placeholder="$t('select-theme')"> + <optgroup :label="$t('builtin-themes')"> + <option v-for="x in builtinThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$t('my-themes')"> + <option v-for="x in installedThemes.filter(t => t.author == this.$store.state.i.username)" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$t('installed-themes')"> + <option v-for="x in installedThemes.filter(t => t.author != this.$store.state.i.username)" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </ui-select> + <template v-if="selectedTheme"> + <ui-input readonly :value="selectedTheme.author"> + <span>{{ $t('author') }}</span> + </ui-input> + <ui-textarea v-if="selectedTheme.desc" readonly :value="selectedTheme.desc"> + <span>{{ $t('desc') }}</span> + </ui-textarea> + <ui-textarea readonly tall :value="selectedThemeCode"> + <span>{{ $t('theme-code') }}</span> + </ui-textarea> + <ui-button @click="export_()" link :download="`${selectedTheme.name}.misskeytheme`" ref="export"><fa icon="box"/> {{ $t('export') }}</ui-button> + <ui-button @click="uninstall()" v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><fa :icon="['far', 'trash-alt']"/> {{ $t('uninstall') }}</ui-button> + </template> + </details> + </section> +</ui-card> </template> <script lang="ts"> @@ -223,7 +228,7 @@ export default Vue.extend({ try { theme = JSON5.parse(code); } catch (e) { - this.$root.alert({ + this.$root.dialog({ type: 'error', text: this.$t('invalid-theme') }); @@ -236,7 +241,7 @@ export default Vue.extend({ } if (theme.id == null) { - this.$root.alert({ + this.$root.dialog({ type: 'error', text: this.$t('invalid-theme') }); @@ -244,7 +249,7 @@ export default Vue.extend({ } if (this.$store.state.device.themes.some(t => t.id == theme.id)) { - this.$root.alert({ + this.$root.dialog({ type: 'info', text: this.$t('already-installed') }); @@ -256,7 +261,7 @@ export default Vue.extend({ key: 'themes', value: themes }); - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('installed').replace('{}', theme.name) }); @@ -269,7 +274,7 @@ export default Vue.extend({ key: 'themes', value: themes }); - this.$root.alert({ + this.$root.dialog({ type: 'info', text: this.$t('uninstalled').replace('{}', theme.name) }); @@ -306,7 +311,7 @@ export default Vue.extend({ const theme = this.myTheme; if (theme.name == null || theme.name.trim() == '') { - this.$root.alert({ + this.$root.dialog({ type: 'warning', text: this.$t('theme-name-required') }); @@ -320,7 +325,7 @@ export default Vue.extend({ key: 'themes', value: themes }); - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('saved') }); @@ -331,8 +336,13 @@ export default Vue.extend({ <style lang="stylus" scoped> .nicnklzforebnpfgasiypmpdaaglujqm + > a + display block + margin-top -16px + margin-bottom 16px + > details - border-top solid 1px var(--faceDivider) + border-top solid var(--lineWidth) var(--faceDivider) > summary padding 16px 0 @@ -343,5 +353,5 @@ export default Vue.extend({ > .creator > div padding 16px 0 - border-bottom solid 1px var(--faceDivider) + border-bottom solid var(--lineWidth) var(--faceDivider) </style> diff --git a/src/client/app/common/views/components/time.vue b/src/client/app/common/views/components/time.vue index 84f701469e..8cfcc4cb4f 100644 --- a/src/client/app/common/views/components/time.vue +++ b/src/client/app/common/views/components/time.vue @@ -1,5 +1,5 @@ <template> -<time class="mk-time"> +<time class="mk-time" :title="absolute"> <span v-if=" mode == 'relative' ">{{ relative }}</span> <span v-if=" mode == 'absolute' ">{{ absolute }}</span> <span v-if=" mode == 'detail' ">{{ absolute }} ({{ relative }})</span> @@ -33,14 +33,7 @@ export default Vue.extend({ return typeof this.time == 'string' ? new Date(this.time) : this.time; }, absolute(): string { - const time = this._time; - return ( - time.getFullYear() + '年' + - (time.getMonth() + 1) + '月' + - time.getDate() + '日' + - ' ' + - time.getHours() + '時' + - time.getMinutes() + '分'); + return this._time.toLocaleString(); }, relative(): string { const time = this._time; diff --git a/src/client/app/common/views/components/twitter-setting.vue b/src/client/app/common/views/components/twitter-setting.vue deleted file mode 100644 index f75bbb7fbf..0000000000 --- a/src/client/app/common/views/components/twitter-setting.vue +++ /dev/null @@ -1,65 +0,0 @@ -<template> -<div class="mk-twitter-setting"> - <p>{{ $t('description') }}</p> - <p class="account" v-if="$store.state.i.twitter" :title="`Twitter ID: ${$store.state.i.twitter.userId}`">{{ $t('connected-to') }}: <a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p> - <p> - <a :href="`${apiUrl}/connect/twitter`" target="_blank" @click.prevent="connect">{{ $store.state.i.twitter ? this.$t('reconnect') : this.$t('connect') }}</a> - <span v-if="$store.state.i.twitter"> or </span> - <a :href="`${apiUrl}/disconnect/twitter`" target="_blank" v-if="$store.state.i.twitter" @click.prevent="disconnect">{{ $t('disconnect') }}</a> - </p> - <p class="id" v-if="$store.state.i.twitter">Twitter ID: {{ $store.state.i.twitter.userId }}</p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { apiUrl } from '../../../config'; - -export default Vue.extend({ - i18n: i18n('common/views/components/twitter-setting.vue'), - data() { - return { - form: null, - apiUrl - }; - }, - mounted() { - this.$watch('$store.state.i', () => { - if (this.$store.state.i.twitter) { - if (this.form) this.form.close(); - } - }, { - deep: true - }); - }, - methods: { - connect() { - this.form = window.open(apiUrl + '/connect/twitter', - 'twitter_connect_window', - 'height=570, width=520'); - }, - - disconnect() { - window.open(apiUrl + '/disconnect/twitter', - 'twitter_disconnect_window', - 'height=570, width=520'); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-twitter-setting - .account - border solid 1px #e1e8ed - border-radius 4px - padding 16px - - a - font-weight bold - color inherit - - .id - color #8899a6 -</style> diff --git a/src/client/app/common/views/components/ui/button.vue b/src/client/app/common/views/components/ui/button.vue index d7d65ad87e..7b443a1ac5 100644 --- a/src/client/app/common/views/components/ui/button.vue +++ b/src/client/app/common/views/components/ui/button.vue @@ -1,11 +1,15 @@ <template> <component class="dmtdnykelhudezerjlfpbhgovrgnqqgr" :is="link ? 'a' : 'button'" - :class="[styl, { inline, primary }]" + :class="{ inline, primary, wait }" :type="type" @click="$emit('click')" + @mousedown="onMousedown" > - <slot></slot> + <div ref="ripples" class="ripples"></div> + <div class="content"> + <slot></slot> + </div> </component> </template> @@ -44,11 +48,11 @@ export default Vue.extend({ required: false, default: false }, - }, - data() { - return { - styl: 'fill' - }; + wait: { + type: Boolean, + required: false, + default: false + }, }, mounted() { if (this.autofocus) { @@ -56,6 +60,47 @@ export default Vue.extend({ this.$el.focus(); }); } + }, + methods: { + onMousedown(e: MouseEvent) { + function distance(p, q) { + const sqrt = Math.sqrt, pow = Math.pow; + return sqrt(pow(p.x - q.x, 2) + pow(p.y - q.y, 2)); + } + + function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY) { + const origin = {x: circleCenterX, y: circleCenterY}; + const dist1 = distance({x: 0, y: 0}, origin); + const dist2 = distance({x: boxW, y: 0}, origin); + const dist3 = distance({x: 0, y: boxH}, origin); + const dist4 = distance({x: boxW, y: boxH }, origin); + return Math.max(dist1, dist2, dist3, dist4) * 2; + } + + const rect = e.target.getBoundingClientRect(); + + const ripple = document.createElement('div'); + ripple.style.top = (e.clientY - rect.top - 1).toString() + 'px'; + ripple.style.left = (e.clientX - rect.left - 1).toString() + 'px'; + + this.$refs.ripples.appendChild(ripple); + + const circleCenterX = e.clientX - rect.left; + const circleCenterY = e.clientY - rect.top; + + const scale = calcCircleScale(e.target.clientWidth, e.target.clientHeight, circleCenterX, circleCenterY); + + setTimeout(() => { + ripple.style.transform = 'scale(' + (scale / 2) + ')'; + }, 1); + setTimeout(() => { + ripple.style.transition = 'all 1s ease'; + ripple.style.opacity = '0'; + }, 1000); + setTimeout(() => { + if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple); + }, 2000); + } } }); </script> @@ -76,9 +121,31 @@ export default Vue.extend({ box-shadow none text-decoration none user-select none + color var(--text) + background var(--buttonBg) + + &:not(:disabled):hover + background var(--buttonHoverBg) + + &:not(:disabled):active + background var(--buttonActiveBg) + + &.primary + color var(--primaryForeground) + background var(--primary) + + &:not(:disabled):hover + background var(--primaryLighten5) + + &:not(:disabled):active + background var(--primaryDarken5) * pointer-events none + user-select none + + &:disabled + opacity 0.7 &:focus &:after @@ -103,34 +170,50 @@ export default Vue.extend({ &.primary font-weight bold - &.fill - color var(--text) - background var(--buttonBg) - - &:hover - background var(--buttonHoverBg) + &.wait + background linear-gradient( + 45deg, + var(--primaryDarken10) 25%, + var(--primary) 25%, + var(--primary) 50%, + var(--primaryDarken10) 50%, + var(--primaryDarken10) 75%, + var(--primary) 75%, + var(--primary) + ) + background-size 32px 32px + animation stripe-bg 1.5s linear infinite + opacity 0.7 + cursor wait - &:active - background var(--buttonActiveBg) + @keyframes stripe-bg + from {background-position: 0 0;} + to {background-position: -64px 32px;} - &.primary - color var(--primaryForeground) - background var(--primary) + > .ripples + position absolute + z-index 0 + top 0 + left 0 + width 100% + height 100% + border-radius 6px + overflow hidden - &:hover - background var(--primaryLighten5) - - &:active - background var(--primaryDarken5) - - &:not(.fill) - color var(--primary) - background none + >>> div + position absolute + width 2px + height 2px + border-radius 100% + background rgba(0, 0, 0, 0.1) + opacity 1 + transform scale(1) + transition all 0.5s cubic-bezier(0, .5, .5, 1) - &:hover - color var(--primaryDarken5) + &.primary > .ripples >>> div + background rgba(0, 0, 0, 0.15) - &:active - background var(--primaryAlpha03) + > .content + z-index 1 </style> diff --git a/src/client/app/common/views/components/ui/card.vue b/src/client/app/common/views/components/ui/card.vue index dbbf7b14a0..f08085ec0b 100644 --- a/src/client/app/common/views/components/ui/card.vue +++ b/src/client/app/common/views/components/ui/card.vue @@ -22,6 +22,7 @@ export default Vue.extend({ <style lang="stylus" scoped> .ui-card margin 16px + max-width 850px color var(--faceText) background var(--face) border-radius var(--round) @@ -40,7 +41,7 @@ export default Vue.extend({ > section padding 20px 16px - border-top solid 1px var(--faceDivider) + border-top solid var(--lineWidth) var(--faceDivider) @media (min-width 500px) padding 32px diff --git a/src/client/app/common/views/components/ui/horizon-group.vue b/src/client/app/common/views/components/ui/horizon-group.vue index 0d4eafae52..33d0300101 100644 --- a/src/client/app/common/views/components/ui/horizon-group.vue +++ b/src/client/app/common/views/components/ui/horizon-group.vue @@ -1,5 +1,5 @@ <template> -<div class="vnxwkwuf" :class="{ inputs, noGrow }"> +<div class="vnxwkwuf" :class="{ inputs, noGrow }" :data-children-count="children"> <slot></slot> </div> </template> @@ -21,21 +21,56 @@ export default Vue.extend({ required: false, default: false } + }, + data() { + return { + children: 0 + }; + }, + mounted() { + this.$nextTick(() => { + this.children = this.$slots.default.length; + }); } }); </script> <style lang="stylus" scoped> .vnxwkwuf + margin 16px 0 + &.inputs margin 32px 0 + &.fit-top + margin-top 0 + + &.fit-bottom + margin-bottom 0 + &:not(.noGrow) display flex > * flex 1 + min-width 0 !important > *:not(:last-child) - margin-right 16px + margin-right 16px !important + + &[data-children-count="3"] + @media (max-width 600px) + display block + + > * + display block + width 100% !important + margin 16px 0 !important + + &:first-child + margin-top 0 !important + + &:last-child + margin-bottom 0 !important + </style> diff --git a/src/client/app/common/views/components/ui/input.vue b/src/client/app/common/views/components/ui/input.vue index 76bb34da61..e3b7551c29 100644 --- a/src/client/app/common/views/components/ui/input.vue +++ b/src/client/app/common/views/components/ui/input.vue @@ -6,33 +6,45 @@ <div class="value" ref="passwordMetar"></div> </div> <span class="label" ref="label"><slot></slot></span> + <span class="title" ref="title"><slot name="title"></slot></span> <div class="prefix" ref="prefix"><slot name="prefix"></slot></div> <template v-if="type != 'file'"> <input ref="input" - :type="type" - v-model="v" - :disabled="disabled" - :required="required" - :readonly="readonly" - :pattern="pattern" - :autocomplete="autocomplete" - :spellcheck="spellcheck" - @focus="focused = true" - @blur="focused = false"> + :type="type" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + @focus="focused = true" + @blur="focused = false" + @keydown="$emit('keydown', $event)" + > </template> <template v-else> <input ref="input" - type="text" - :value="placeholder" - readonly - @click="chooseFile"> + type="text" + :value="filePlaceholder" + readonly + @click="chooseFile" + > <input ref="file" - type="file" - :value="value" - @change="onChangeFile"> + type="file" + :value="value" + @change="onChangeFile" + > </template> <div class="suffix" ref="suffix"><slot name="suffix"></slot></div> </div> + <div class="toggle" v-if="withPasswordToggle"> + <a @click='togglePassword'> + <span v-if="type == 'password'"><fa :icon="['fa', 'eye']"/> {{ $t('@.show-password') }}</span> + <span v-if="type != 'password'"><fa :icon="['far', 'eye-slash']"/> {{ $t('@.hide-password') }}</span> + </a> + </div> <div class="desc"><slot name="desc"></slot></div> </div> </template> @@ -71,6 +83,15 @@ export default Vue.extend({ type: String, required: false }, + placeholder: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, autocomplete: { required: false }, @@ -82,6 +103,11 @@ export default Vue.extend({ required: false, default: false }, + withPasswordToggle: { + type: Boolean, + required: false, + default: false + }, inline: { type: Boolean, required: false, @@ -106,7 +132,7 @@ export default Vue.extend({ filled(): boolean { return this.v != '' && this.v != null; }, - placeholder(): string { + filePlaceholder(): string { if (this.type != 'file') return null; if (this.v == null) return null; @@ -139,6 +165,12 @@ export default Vue.extend({ } }, mounted() { + if (this.autofocus) { + this.$nextTick(() => { + this.$refs.input.focus(); + }); + } + this.$nextTick(() => { if (this.$refs.prefix) { this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px'; @@ -157,6 +189,13 @@ export default Vue.extend({ focus() { this.$refs.input.focus(); }, + togglePassword() { + if (this.type == 'password') { + this.type = 'text' + } else { + this.type = 'password' + } + }, chooseFile() { this.$refs.file.click(); }, @@ -261,6 +300,20 @@ root(fill) transform-origin top left transform scale(1) + > .title + position absolute + z-index 1 + top fill ? -24px : -17px + left 0 !important + pointer-events none + font-size 16px + line-height 32px + color var(--inputLabel) + pointer-events none + //will-change transform + transform-origin top left + transform scale(.75) + > input display block width 100% @@ -321,10 +374,24 @@ root(fill) if fill padding-right 12px + > .toggle + cursor pointer + padding-left 0.5em + font-size 0.7em + opacity 0.7 + text-align left + + > a + color var(--inputLabel) + text-decoration none + > .desc margin 6px 0 font-size 13px + &:empty + display none + * margin 0 diff --git a/src/client/app/common/views/components/ui/radio.vue b/src/client/app/common/views/components/ui/radio.vue index 868a339aa4..468318b58e 100644 --- a/src/client/app/common/views/components/ui/radio.vue +++ b/src/client/app/common/views/components/ui/radio.vue @@ -25,11 +25,9 @@ export default Vue.extend({ }, props: { model: { - type: String, required: false }, value: { - type: String, required: false }, disabled: { @@ -66,10 +64,10 @@ export default Vue.extend({ &.checked > .button - border-color var(--primary) + border-color var(--radioActive) &:after - background-color var(--primary) + background-color var(--radioActive) transform scale(1) opacity 1 diff --git a/src/client/app/common/views/components/ui/select.vue b/src/client/app/common/views/components/ui/select.vue index da6f9696b5..e8b45a4a29 100644 --- a/src/client/app/common/views/components/ui/select.vue +++ b/src/client/app/common/views/components/ui/select.vue @@ -1,15 +1,17 @@ <template> -<div class="ui-select" :class="[{ focused, filled }, styl]"> +<div class="ui-select" :class="[{ focused, disabled, filled, inline }, styl]"> <div class="icon" ref="icon"><slot name="icon"></slot></div> <div class="input" @click="focus"> <span class="label" ref="label"><slot name="label"></slot></span> <div class="prefix" ref="prefix"><slot name="prefix"></slot></div> <select ref="input" - :value="v" - :required="required" - @input="$emit('input', $event.target.value)" - @focus="focused = true" - @blur="focused = false"> + :value="v" + :required="required" + :disabled="disabled" + @input="$emit('input', $event.target.value)" + @focus="focused = true" + @blur="focused = false" + > <slot></slot> </select> <div class="suffix"><slot name="suffix"></slot></div> @@ -22,6 +24,11 @@ import Vue from 'vue'; export default Vue.extend({ + inject: { + horizonGrouped: { + default: false + } + }, props: { value: { required: false @@ -30,11 +37,22 @@ export default Vue.extend({ type: Boolean, required: false }, + disabled: { + type: Boolean, + required: false + }, styl: { type: String, required: false, default: 'line' - } + }, + inline: { + type: Boolean, + required: false, + default(): boolean { + return this.horizonGrouped; + } + }, }, data() { return { @@ -76,7 +94,7 @@ root(fill) width 24px text-align center line-height 32px - color rgba(#000, 0.54) + color var(--inputLabel) &:not(:empty) + .input margin-left 28px @@ -122,7 +140,7 @@ root(fill) transition-duration 0.3s font-size 16px line-height 32px - color rgba(#000, 0.54) + color var(--inputLabel) pointer-events none //will-change transform transform-origin top left @@ -171,6 +189,9 @@ root(fill) margin 6px 0 font-size 13px + &:empty + display none + * margin 0 @@ -200,4 +221,14 @@ root(fill) &:not(.fill) root(false) + &.inline + display inline-block + margin 0 + + &.disabled + opacity 0.7 + + &, * + cursor not-allowed !important + </style> diff --git a/src/client/app/common/views/components/ui/switch.vue b/src/client/app/common/views/components/ui/switch.vue index c9a9cb7911..48a296c36d 100644 --- a/src/client/app/common/views/components/ui/switch.vue +++ b/src/client/app/common/views/components/ui/switch.vue @@ -77,11 +77,11 @@ export default Vue.extend({ &.checked > .button - background-color var(--primaryAlpha04) - border-color var(--primaryAlpha04) + background-color var(--switchActiveTrack) + border-color var(--switchActiveTrack) > * - background-color var(--primary) + background-color var(--switchActive) transform translateX(14px) > input @@ -123,7 +123,7 @@ export default Vue.extend({ > span display block line-height 20px - color currentColor + color var(--text) transition inherit > p diff --git a/src/client/app/common/views/components/ui/textarea.vue b/src/client/app/common/views/components/ui/textarea.vue index 8ebc79e097..d265c7ac6d 100644 --- a/src/client/app/common/views/components/ui/textarea.vue +++ b/src/client/app/common/views/components/ui/textarea.vue @@ -1,5 +1,5 @@ <template> -<div class="ui-textarea" :class="{ focused, filled, tall }"> +<div class="ui-textarea" :class="{ focused, filled, tall, pre }"> <div class="input"> <span class="label" ref="label"><slot></slot></span> <textarea ref="input" @@ -46,6 +46,11 @@ export default Vue.extend({ required: false, default: false }, + pre: { + type: Boolean, + required: false, + default: false + }, }, data() { return { @@ -126,6 +131,8 @@ root(fill) > textarea display block width 100% + min-width 100% + max-width 100% min-height 100px padding 0 font inherit @@ -143,6 +150,9 @@ root(fill) font-size 13px opacity 0.7 + &:empty + display none + * margin 0 @@ -170,6 +180,11 @@ root(fill) > textarea min-height 200px + &.pre + > .input + > textarea + white-space pre + .ui-textarea.fill root(true) diff --git a/src/client/app/common/views/components/url-preview.vue b/src/client/app/common/views/components/url-preview.vue index 86489cf8be..958abe00f0 100644 --- a/src/client/app/common/views/components/url-preview.vue +++ b/src/client/app/common/views/components/url-preview.vue @@ -8,16 +8,16 @@ </blockquote> </div> <div v-else class="mk-url-preview"> - <a :class="{ mini }" :href="url" target="_blank" :title="url" v-if="!fetching"> + <a :class="{ mini, compact }" :href="url" target="_blank" :title="url" v-if="!fetching"> <div class="thumbnail" v-if="thumbnail" :style="`background-image: url(${thumbnail})`"></div> <article> <header> - <h1>{{ title }}</h1> + <h1 :title="title">{{ title }}</h1> </header> - <p>{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p> + <p v-if="description" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p> <footer> <img class="icon" v-if="icon" :src="icon"/> - <p>{{ sitename }}</p> + <p :title="sitename">{{ sitename }}</p> </footer> </article> </a> @@ -120,6 +120,12 @@ export default Vue.extend({ default: false }, + compact: { + type: Boolean, + required: false, + default: false + }, + mini: { type: Boolean, required: false, @@ -170,6 +176,9 @@ export default Vue.extend({ return; } + if (url.hostname === 'music.youtube.com') + url.hostname = 'youtube.com'; + fetch(`/url?url=${encodeURIComponent(this.url)}`).then(res => { res.json().then(info => { if (info.url == null) return; @@ -204,7 +213,7 @@ export default Vue.extend({ > a display block font-size 14px - border solid 1px var(--urlPreviewBorder) + border solid var(--lineWidth) var(--urlPreviewBorder) border-radius 4px overflow hidden @@ -299,6 +308,23 @@ export default Vue.extend({ width 12px height 12px + &.compact + > .thumbnail + position: absolute + width 56px + height 100% + + > article + left 56px + width calc(100% - 56px) + padding 4px + + > header + margin-bottom 2px + + > footer + margin-top 2px + &.mini font-size 10px @@ -322,4 +348,27 @@ export default Vue.extend({ width 12px height 12px + &.compact + > .thumbnail + position: absolute + width 56px + height 100% + + > article + left 56px + width calc(100% - 56px) + padding 4px + + > header + margin-bottom 2px + + > footer + margin-top 2px + + &.compact + > article + > header h1, p, footer + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; </style> diff --git a/src/client/app/common/views/components/user-list-editor.vue b/src/client/app/common/views/components/user-list-editor.vue new file mode 100644 index 0000000000..1b068da86a --- /dev/null +++ b/src/client/app/common/views/components/user-list-editor.vue @@ -0,0 +1,150 @@ +<template> +<div class="cudqjmnl"> + <ui-card> + <div slot="title"><fa :icon="faList"/> {{ list.title }}</div> + + <section> + <ui-button @click="rename"><fa :icon="faICursor"/> {{ $t('rename') }}</ui-button> + <ui-button @click="del"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button> + </section> + </ui-card> + + <ui-card> + <div slot="title"><fa :icon="faUsers"/> {{ $t('users') }}</div> + + <section> + <sequential-entrance animation="entranceFromTop" delay="25"> + <div class="phcqulfl" v-for="user in users"> + <div> + <a :href="user | userPage"> + <mk-avatar class="avatar" :user="user" :disable-link="true"/> + </a> + </div> + <div> + <header> + <b><mk-user-name :user="user"/></b> + <span class="username">@{{ user | acct }}</span> + </header> + <div> + <a @click="remove(user)">{{ $t('remove-user') }}</a> + </div> + </div> + </div> + </sequential-entrance> + </section> + </ui-card> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import { faList, faICursor, faUsers } from '@fortawesome/free-solid-svg-icons'; +import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; + +export default Vue.extend({ + i18n: i18n('common/views/components/user-list-editor.vue'), + + props: { + list: { + required: true + } + }, + + data() { + return { + users: [], + faList, faICursor, faTrashAlt, faUsers + }; + }, + + mounted() { + this.fetchUsers(); + }, + + methods: { + fetchUsers() { + this.$root.api('users/show', { + userIds: this.list.userIds + }).then(users => { + this.users = users; + }); + }, + + rename() { + this.$root.dialog({ + title: this.$t('rename'), + input: { + default: this.list.title + } + }).then(({ canceled, result: title }) => { + if (canceled) return; + this.$root.api('users/lists/update', { + listId: this.list.id, + title: title + }); + }); + }, + + del() { + this.$root.dialog({ + type: 'warning', + text: this.$t('delete-are-you-sure').replace('$1', this.list.title), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + this.$root.api('users/lists/delete', { + listId: this.list.id + }).then(() => { + this.$root.dialog({ + type: 'success', + text: this.$t('deleted') + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }); + }, + + remove(user: any) { + this.$root.api('users/lists/pull', { + listId: this.list.id, + userId: user.id + }).then(() => { + this.fetchUsers(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.cudqjmnl + .phcqulfl + display flex + padding 16px 0 + border-top solid 1px var(--faceDivider) + + > div:first-child + > a + > .avatar + width 64px + height 64px + + > div:last-child + flex 1 + padding-left 16px + + @media (max-width 500px) + font-size 14px + + > header + > .username + margin-left 8px + opacity 0.7 + +</style> diff --git a/src/client/app/common/views/components/user-menu.vue b/src/client/app/common/views/components/user-menu.vue new file mode 100644 index 0000000000..7874c43493 --- /dev/null +++ b/src/client/app/common/views/components/user-menu.vue @@ -0,0 +1,154 @@ +<template> +<div style="position:initial"> + <mk-menu :source="source" :items="items" @closed="closed"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import copyToClipboard from '../../../common/scripts/copy-to-clipboard'; +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + i18n: i18n('common/views/components/user-menu.vue'), + + props: ['user', 'source'], + + data() { + let menu = [{ + icon: ['fas', 'at'], + text: this.$t('mention'), + action: () => { + this.$post({ mention: this.user }); + } + }, null, { + icon: ['fas', 'list'], + text: this.$t('push-to-list'), + action: this.pushList + }, null, { + icon: this.user.isMuted ? ['fas', 'eye'] : ['far', 'eye-slash'], + text: this.user.isMuted ? this.$t('unmute') : this.$t('mute'), + action: this.toggleMute + }, { + icon: 'ban', + text: this.user.isBlocking ? this.$t('unblock') : this.$t('block'), + action: this.toggleBlock + }, null, { + icon: faExclamationCircle, + text: this.$t('report-abuse'), + action: this.reportAbuse + }]; + + return { + items: menu + }; + }, + + methods: { + closed() { + this.$nextTick(() => { + this.destroyDom(); + }); + }, + + async pushList() { + const lists = await this.$root.api('users/lists/list'); + const { canceled, result: listId } = await this.$root.dialog({ + type: null, + title: this.$t('select-list'), + select: { + items: lists.map(list => ({ + value: list.id, text: list.title + })) + }, + showCancelButton: true + }); + if (canceled) return; + await this.$root.api('users/lists/push', { + listId: listId, + userId: this.user.id + }); + this.$root.dialog({ + type: 'success', + splash: true + }); + }, + + toggleMute() { + if (this.user.isMuted) { + this.$root.api('mute/delete', { + userId: this.user.id + }).then(() => { + this.user.isMuted = false; + }, () => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + } else { + this.$root.api('mute/create', { + userId: this.user.id + }).then(() => { + this.user.isMuted = true; + }, () => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + } + }, + + toggleBlock() { + if (this.user.isBlocking) { + this.$root.api('blocking/delete', { + userId: this.user.id + }).then(() => { + this.user.isBlocking = false; + }, () => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + } else { + this.$root.api('blocking/create', { + userId: this.user.id + }).then(() => { + this.user.isBlocking = true; + }, () => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + } + }, + + async reportAbuse() { + const reported = this.$t('report-abuse-reported'); // なぜか後で参照すると null になるので最初にメモリに確保しておく + const { canceled, result: comment } = await this.$root.dialog({ + title: this.$t('report-abuse-detail'), + input: true + }); + if (canceled) return; + this.$root.api('users/report-abuse', { + userId: this.user.id, + comment: comment + }).then(() => { + this.$root.dialog({ + type: 'success', + text: reported + }); + }, e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + } + } +}); +</script> diff --git a/src/client/app/common/views/components/user-name.vue b/src/client/app/common/views/components/user-name.vue new file mode 100644 index 0000000000..3959193eb4 --- /dev/null +++ b/src/client/app/common/views/components/user-name.vue @@ -0,0 +1,16 @@ +<template> +<mfm :text="user.name || user.username" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + user: { + type: Object, + required: true + } + } +}); +</script> diff --git a/src/client/app/common/views/components/visibility-chooser.vue b/src/client/app/common/views/components/visibility-chooser.vue index b7cc56103c..5aa481ed9a 100644 --- a/src/client/app/common/views/components/visibility-chooser.vue +++ b/src/client/app/common/views/components/visibility-chooser.vue @@ -1,7 +1,7 @@ <template> -<div class="mk-visibility-chooser"> +<div class="gqyayizv"> <div class="backdrop" ref="backdrop" @click="close"></div> - <div class="popover" :class="{ compact }" ref="popover"> + <div class="popover" :class="{ isMobile: $root.isMobile }" ref="popover"> <div @click="choose('public')" :class="{ active: v == 'public' }"> <div><fa icon="globe"/></div> <div> @@ -29,12 +29,6 @@ <span>{{ $t('specified-desc') }}</span> </div> </div> - <div @click="choose('private')" :class="{ active: v == 'private' }"> - <div><fa icon="lock"/></div> - <div> - <span>{{ $t('private') }}</span> - </div> - </div> <div @click="choose('local-public')" :class="{ active: v == 'local-public' }"> <div><fa icon="globe"/></div> <div> @@ -61,14 +55,22 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import * as anime from 'animejs'; +import anime from 'animejs'; export default Vue.extend({ i18n: i18n('common/views/components/visibility-chooser.vue'), - props: ['source', 'compact'], + props: { + source: { + required: true + }, + currentVisibility: { + type: String, + required: false + } + }, data() { return { - v: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility + v: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : (this.currentVisibility || this.$store.state.settings.defaultNoteVisibility) } }, mounted() { @@ -82,7 +84,7 @@ export default Vue.extend({ let left; let top; - if (this.compact) { + if (this.$root.isMobile) { const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); left = (x - (width / 2)); @@ -148,9 +150,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -$border-color = rgba(27, 31, 35, 0.15) - -.mk-visibility-chooser +.gqyayizv position initial > .backdrop @@ -170,39 +170,27 @@ $border-color = rgba(27, 31, 35, 0.15) width 240px padding 8px 0 background $bgcolor - border 1px solid $border-color border-radius 4px box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) transform scale(0.5) opacity 0 - $balloon-size = 10px + &:not(.isMobile) + $arrow-size = 10px - &:not(.compact) - margin-top $balloon-size - transform-origin center -($balloon-size) + margin-top $arrow-size + transform-origin center -($arrow-size) &:before content "" display block position absolute - top -($balloon-size * 2) - left s('calc(50% - %s)', $balloon-size) - border-top solid $balloon-size transparent - border-left solid $balloon-size transparent - border-right solid $balloon-size transparent - border-bottom solid $balloon-size $border-color - - &:after - content "" - display block - position absolute - top -($balloon-size * 2) + 1.5px - left s('calc(50% - %s)', $balloon-size) - border-top solid $balloon-size transparent - border-left solid $balloon-size transparent - border-right solid $balloon-size transparent - border-bottom solid $balloon-size $bgcolor + top -($arrow-size * 2) + left s('calc(50% - %s)', $arrow-size) + border-top solid $arrow-size transparent + border-left solid $arrow-size transparent + border-right solid $arrow-size transparent + border-bottom solid $arrow-size $bgcolor > div display flex @@ -241,4 +229,5 @@ $border-color = rgba(27, 31, 35, 0.15) > span:last-child:not(:first-child) opacity 0.6 + </style> diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue index cad09a11a6..1150304810 100644 --- a/src/client/app/common/views/components/welcome-timeline.vue +++ b/src/client/app/common/views/components/welcome-timeline.vue @@ -5,7 +5,9 @@ <mk-avatar class="avatar" :user="note.user" target="_blank"/> <div class="body"> <header> - <router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id">{{ note.user | userName }}</router-link> + <router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id"> + <mk-user-name :user="note.user"/> + </router-link> <span class="username">@{{ note.user | acct }}</span> <div class="info"> <router-link class="created-at" :to="note | notePage"> @@ -14,7 +16,7 @@ </div> </header> <div class="text"> - <misskey-flavored-markdown v-if="note.text" :text="note.text" :customEmojis="note.emojis"/> + <mfm v-if="note.text" :text="note.cw != null ? note.cw : note.text" :author="note.user" :custom-emojis="note.emojis"/> </div> </div> </div> diff --git a/src/client/app/common/views/directives/autocomplete.ts b/src/client/app/common/views/directives/autocomplete.ts index 4440747e19..7f8e409a7c 100644 --- a/src/client/app/common/views/directives/autocomplete.ts +++ b/src/client/app/common/views/directives/autocomplete.ts @@ -21,21 +21,24 @@ class Autocomplete { private suggestion: any; private textarea: any; private vm: any; - private model: any; private currentType: string; + private opts: { + model: string; + }; + private opening: boolean; private get text(): string { - return this.vm[this.model]; + return this.vm[this.opts.model]; } private set text(text: string) { - this.vm[this.model] = text; + this.vm[this.opts.model] = text; } /** * 対象のテキストエリアを与えてインスタンスを初期化します。 */ - constructor(textarea, vm, model) { + constructor(textarea, vm, opts) { //#region BIND this.onInput = this.onInput.bind(this); this.complete = this.complete.bind(this); @@ -45,7 +48,8 @@ class Autocomplete { this.suggestion = null; this.textarea = textarea; this.vm = vm; - this.model = model; + this.opts = opts; + this.opening = false; } /** @@ -126,6 +130,8 @@ class Autocomplete { if (type != this.currentType) { this.close(); } + if (this.opening) return; + this.opening = true; this.currentType = type; //#region サジェストを表示すべき位置を計算 @@ -141,6 +147,8 @@ class Autocomplete { this.suggestion.x = x; this.suggestion.y = y; this.suggestion.q = q; + + this.opening = false; } else { const MkAutocomplete = await import('../components/autocomplete.vue').then(m => m.default); @@ -160,6 +168,8 @@ class Autocomplete { // 要素追加 document.body.appendChild(this.suggestion.$el); + + this.opening = false; } } diff --git a/src/client/app/common/views/directives/index.ts b/src/client/app/common/views/directives/index.ts index 268f07a950..1bb4fd6d4d 100644 --- a/src/client/app/common/views/directives/index.ts +++ b/src/client/app/common/views/directives/index.ts @@ -1,5 +1,7 @@ import Vue from 'vue'; import autocomplete from './autocomplete'; +import particle from './particle'; Vue.directive('autocomplete', autocomplete); +Vue.directive('particle', particle); diff --git a/src/client/app/common/views/directives/particle.ts b/src/client/app/common/views/directives/particle.ts new file mode 100644 index 0000000000..5f8413117f --- /dev/null +++ b/src/client/app/common/views/directives/particle.ts @@ -0,0 +1,26 @@ +import Particle from '../components/particle.vue'; + +export default { + bind(el, binding, vn) { + if (vn.context.$store.state.device.reduceMotion) return; + + el.addEventListener('click', () => { + if (binding.value === false) return; + + const rect = el.getBoundingClientRect(); + + const x = rect.left + (el.clientWidth / 2); + const y = rect.top + (el.clientHeight / 2); + + const particle = new Particle({ + parent: vn.context, + propsData: { + x, + y + } + }).$mount(); + + document.body.appendChild(particle.$el); + }); + } +}; diff --git a/src/client/app/common/views/filters/index.ts b/src/client/app/common/views/filters/index.ts index 1759c19c2c..3dccbfc923 100644 --- a/src/client/app/common/views/filters/index.ts +++ b/src/client/app/common/views/filters/index.ts @@ -1,3 +1,10 @@ +import Vue from 'vue'; +import * as JSON5 from 'json5'; + +Vue.filter('json5', x => { + return JSON5.stringify(x, null, 2); +}); + require('./bytes'); require('./number'); require('./user'); diff --git a/src/client/app/common/views/filters/number.ts b/src/client/app/common/views/filters/number.ts index 08f9fea805..8c799d9442 100644 --- a/src/client/app/common/views/filters/number.ts +++ b/src/client/app/common/views/filters/number.ts @@ -1,6 +1,3 @@ import Vue from 'vue'; -Vue.filter('number', (n) => { - if (n == null) return 'N/A'; - return n.toLocaleString(); -}); +Vue.filter('number', n => n == null ? 'N/A' : n.toLocaleString()); diff --git a/src/client/app/common/views/filters/user.ts b/src/client/app/common/views/filters/user.ts index e5220229b7..9d4ae5c58b 100644 --- a/src/client/app/common/views/filters/user.ts +++ b/src/client/app/common/views/filters/user.ts @@ -1,6 +1,7 @@ import Vue from 'vue'; import getAcct from '../../../../../misc/acct/render'; import getUserName from '../../../../../misc/get-user-name'; +import { url } from '../../../config'; Vue.filter('acct', user => { return getAcct(user); @@ -10,6 +11,6 @@ Vue.filter('userName', user => { return getUserName(user); }); -Vue.filter('userPage', (user, path?) => { - return `/@${Vue.filter('acct')(user)}${(path ? `/${path}` : '')}`; +Vue.filter('userPage', (user, path?, absolute = false) => { + return `${absolute ? url : ''}/@${Vue.filter('acct')(user)}${(path ? `/${path}` : '')}`; }); diff --git a/src/client/app/common/views/pages/follow.vue b/src/client/app/common/views/pages/follow.vue index 9db53fdf8a..4d1febaec0 100644 --- a/src/client/app/common/views/pages/follow.vue +++ b/src/client/app/common/views/pages/follow.vue @@ -1,15 +1,18 @@ <template> <div class="syxhndwprovvuqhmyvveewmbqayniwkv" v-if="!fetching"> - <div class="signed-in-as" v-html="this.$t('signed-in-as').replace('{}', `<b>${myName}`)"></div> - + <div class="signed-in-as"> + <mfm :text="$t('signed-in-as').replace('{}', myName)" :should-break="false" :plain-text="true" :custom-emojis="$store.state.i.emojis"/> + </div> <main> <div class="banner" :style="bannerStyle"></div> <mk-avatar class="avatar" :user="user" :disable-preview="true"/> <div class="body"> - <router-link :to="user | userPage" class="name">{{ user | userName }}</router-link> + <router-link :to="user | userPage" class="name"> + <mk-user-name :user="user"/> + </router-link> <span class="username">@{{ user | acct }}</span> <div class="description"> - <misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/> + <mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> </div> </div> </main> @@ -20,7 +23,7 @@ :disabled="followWait"> <template v-if="!followWait"> <template v-if="user.hasPendingFollowRequestFromYou && user.isLocked"><fa icon="hourglass-half"/> {{ $t('request-pending') }}</template> - <template v-else-if="user.hasPendingFollowRequestFromYou && !user.isLocked"><fa icon="hourglass-start"/> {{ $t('follow-processing') }}</template> + <template v-else-if="user.hasPendingFollowRequestFromYou && !user.isLocked"><fa icon="spinner"/> {{ $t('follow-processing') }}</template> <template v-else-if="user.isFollowing"><fa icon="minus"/> {{ $t('following') }}</template> <template v-else-if="!user.isFollowing && user.isLocked"><fa icon="plus"/> {{ $t('follow-request') }}</template> <template v-else-if="!user.isFollowing && !user.isLocked"><fa icon="plus"/> {{ $t('follow') }}</template> @@ -125,6 +128,7 @@ export default Vue.extend({ > .signed-in-as margin-bottom 16px font-size 14px + font-weight bold > main margin-bottom 16px diff --git a/src/client/app/common/views/pages/not-found.vue b/src/client/app/common/views/pages/not-found.vue new file mode 100644 index 0000000000..cb1b19687c --- /dev/null +++ b/src/client/app/common/views/pages/not-found.vue @@ -0,0 +1,65 @@ +<template> +<figure class="megtcxgu"> + <img :src="src" alt=""> + <figcaption> + <h1><span>Not found</span></h1> + <p><span>{{ $t('page-not-found') }}</span></p> + </figcaption> +</figure> +</template> + +<script lang="ts"> +import Vue from 'vue' +import i18n from '../../../i18n'; + +export default Vue.extend({ + i18n: i18n('common/views/pages/not-found.vue'), + data() { + return { + src: '' + } + }, + created() { + this.$root.getMeta().then(meta => { + if (meta.errorImageUrl) + this.src = meta.errorImageUrl; + }); + } +}) +</script> + +<style lang="stylus" scoped> +.megtcxgu + align-items center + bottom 0 + display flex + justify-content center + left 0 + margin auto + position fixed + right 0 + top 0 + + > img + width 500px + + > figcaption + margin 8px + + h1, + p + color var(--text) + display flex + flex-flow column + + * + position relative + width 100% + + @media (max-width: 767px) + flex-flow column + + > figcaption + text-align center + +</style> diff --git a/src/client/app/common/views/widgets/donation.vue b/src/client/app/common/views/widgets/donation.vue deleted file mode 100644 index 057813891c..0000000000 --- a/src/client/app/common/views/widgets/donation.vue +++ /dev/null @@ -1,56 +0,0 @@ -<template> -<div> - <mk-widget-container :show-header="false"> - <article class="dolfvtibguprpxxhfndqaosjitixjohx"> - <h1><fa icon="heart"/>{{ $t('title') }}</h1> - <p v-if="meta"> - {{ this.$t('text').substr(0, this.$t('text').indexOf('{')) }} - <a :href="'mailto:' + meta.maintainer.email">{{ meta.maintainer.name }}</a> - {{ this.$t('text').substr(this.$t('text').indexOf('}') + 1) }} - </p> - </article> - </mk-widget-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; - -export default define({ - name: 'donation' -}).extend({ - i18n: i18n('common/views/widgets/donation.vue'), - data() { - return { - meta: null - }; - }, - created() { - this.$root.getMeta().then(meta => { - this.meta = meta; - }); - } -}); -</script> - -<style lang="stylus" scoped> -.dolfvtibguprpxxhfndqaosjitixjohx - padding 20px - background var(--donationBg) - color var(--donationFg) - - > h1 - margin 0 0 5px 0 - font-size 1em - - > [data-icon] - margin-right 0.25em - - > p - display block - z-index 1 - margin 0 - font-size 0.8em - -</style> diff --git a/src/client/app/common/views/widgets/index.ts b/src/client/app/common/views/widgets/index.ts index 7d548ef353..7fca79f1fc 100644 --- a/src/client/app/common/views/widgets/index.ts +++ b/src/client/app/common/views/widgets/index.ts @@ -11,7 +11,6 @@ import wCalendar from './calendar.vue'; import wPhotoStream from './photo-stream.vue'; import wSlideshow from './slideshow.vue'; import wTips from './tips.vue'; -import wDonation from './donation.vue'; import wNav from './nav.vue'; import wHashtags from './hashtags.vue'; @@ -21,7 +20,6 @@ Vue.component('mkw-calendar', wCalendar); Vue.component('mkw-photo-stream', wPhotoStream); Vue.component('mkw-slideshow', wSlideshow); Vue.component('mkw-tips', wTips); -Vue.component('mkw-donation', wDonation); Vue.component('mkw-broadcast', wBroadcast); Vue.component('mkw-server', wServer); Vue.component('mkw-posts-monitor', wPostsMonitor); diff --git a/src/client/app/common/views/widgets/memo.vue b/src/client/app/common/views/widgets/memo.vue index d47257d22d..75a775d77e 100644 --- a/src/client/app/common/views/widgets/memo.vue +++ b/src/client/app/common/views/widgets/memo.vue @@ -72,7 +72,7 @@ export default define({ color var(--inputText) background var(--face) border none - border-bottom solid 1px var(--faceDivider) + border-bottom solid var(--lineWidth) var(--faceDivider) border-radius 0 > button diff --git a/src/client/app/common/views/widgets/photo-stream.vue b/src/client/app/common/views/widgets/photo-stream.vue index 13bae64bd0..516c626323 100644 --- a/src/client/app/common/views/widgets/photo-stream.vue +++ b/src/client/app/common/views/widgets/photo-stream.vue @@ -10,7 +10,6 @@ :style="`background-image: url(${image.thumbnailUrl || image.url})`" draggable="true" @dragstart="onDragstart(image, $event)" - @dragend="onDragend" ></div> </div> <p :class="$style.empty" v-if="!fetching && images.length == 0">{{ $t('no-photos') }}</p> @@ -78,10 +77,6 @@ export default define({ e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('mk_drive_file', JSON.stringify(file)); }, - - onDragend(e) { - this.browser.isDragSource = false; - }, } }); </script> diff --git a/src/client/app/common/views/widgets/posts-monitor.vue b/src/client/app/common/views/widgets/posts-monitor.vue index 9b2cc5a6cd..1af306b881 100644 --- a/src/client/app/common/views/widgets/posts-monitor.vue +++ b/src/client/app/common/views/widgets/posts-monitor.vue @@ -164,7 +164,7 @@ export default define({ this.draw(); }, onStatsLog(statsLog) { - statsLog.forEach(stats => this.onStats(stats)); + for (const stats of statsLog) this.onStats(stats); } } }); diff --git a/src/client/app/common/views/widgets/rss.vue b/src/client/app/common/views/widgets/rss.vue index 05b01a7055..107c49adb1 100644 --- a/src/client/app/common/views/widgets/rss.vue +++ b/src/client/app/common/views/widgets/rss.vue @@ -77,7 +77,7 @@ export default define({ display block padding 4px 0 color var(--text) - border-bottom dashed 1px var(--faceDivider) + border-bottom dashed var(--lineWidth) var(--faceDivider) &:last-child border-bottom none diff --git a/src/client/app/common/views/widgets/server.cpu-memory.vue b/src/client/app/common/views/widgets/server.cpu-memory.vue index 4a0341ddcd..92e5479b1b 100644 --- a/src/client/app/common/views/widgets/server.cpu-memory.vue +++ b/src/client/app/common/views/widgets/server.cpu-memory.vue @@ -121,7 +121,7 @@ export default Vue.extend({ this.memP = (stats.mem.used / stats.mem.total * 100).toFixed(0); }, onStatsLog(statsLog) { - statsLog.reverse().forEach(stats => this.onStats(stats)); + for (const stats of statsLog.reverse()) this.onStats(stats); } } }); diff --git a/src/client/app/common/views/widgets/server.cpu.vue b/src/client/app/common/views/widgets/server.cpu.vue index 986577c51f..c08971e11c 100644 --- a/src/client/app/common/views/widgets/server.cpu.vue +++ b/src/client/app/common/views/widgets/server.cpu.vue @@ -3,7 +3,7 @@ <x-pie class="pie" :value="usage"/> <div> <p><fa icon="microchip"/>CPU</p> - <p>{{ meta.cpu.cores }} Cores</p> + <p>{{ meta.cpu.cores }} Logical cores</p> <p>{{ meta.cpu.model }}</p> </div> </div> diff --git a/src/client/app/common/views/widgets/server.info.vue b/src/client/app/common/views/widgets/server.info.vue index 00bc83de9a..f7efb6fa2a 100644 --- a/src/client/app/common/views/widgets/server.info.vue +++ b/src/client/app/common/views/widgets/server.info.vue @@ -22,5 +22,5 @@ export default Vue.extend({ > p margin 0 font-size 12px - color #505050 + color var(--text) </style> diff --git a/src/client/app/common/views/widgets/server.uptimes.vue b/src/client/app/common/views/widgets/server.uptimes.vue index 06713d83ce..0da5c4ec50 100644 --- a/src/client/app/common/views/widgets/server.uptimes.vue +++ b/src/client/app/common/views/widgets/server.uptimes.vue @@ -1,13 +1,14 @@ <template> <div class="uptimes"> <p>Uptimes</p> - <p>Process: {{ process ? process.toFixed(0) : '---' }}s</p> - <p>OS: {{ os ? os.toFixed(0) : '---' }}s</p> + <p>Process: {{ process }}</p> + <p>OS: {{ os }}</p> </div> </template> <script lang="ts"> import Vue from 'vue'; +import formatUptime from '../../scripts/format-uptime'; export default Vue.extend({ props: ['connection'], @@ -25,8 +26,8 @@ export default Vue.extend({ }, methods: { onStats(stats) { - this.process = stats.process_uptime; - this.os = stats.os_uptime; + this.process = formatUptime(stats.process_uptime); + this.os = formatUptime(stats.os_uptime); } } }); @@ -39,7 +40,7 @@ export default Vue.extend({ > p margin 0 font-size 12px - color #505050 + color var(--text) &:first-child font-weight bold diff --git a/src/client/app/common/views/widgets/slideshow.vue b/src/client/app/common/views/widgets/slideshow.vue index a454a41cde..23ccb9da6b 100644 --- a/src/client/app/common/views/widgets/slideshow.vue +++ b/src/client/app/common/views/widgets/slideshow.vue @@ -13,7 +13,7 @@ </template> <script lang="ts"> -import * as anime from 'animejs'; +import anime from 'animejs'; import define from '../../../common/define-widget'; import i18n from '../../../i18n'; diff --git a/src/client/app/common/views/widgets/tips.vue b/src/client/app/common/views/widgets/tips.vue index 1a9c91ba8b..9e047ef47c 100644 --- a/src/client/app/common/views/widgets/tips.vue +++ b/src/client/app/common/views/widgets/tips.vue @@ -5,7 +5,7 @@ </template> <script lang="ts"> -import * as anime from 'animejs'; +import anime from 'animejs'; import define from '../../../common/define-widget'; import i18n from '../../../i18n'; @@ -84,6 +84,7 @@ export default define({ <style lang="stylus" scoped> .mkw-tips overflow visible !important + opacity 0.8 > p display block @@ -91,7 +92,7 @@ export default define({ padding 0 12px text-align center font-size 0.7em - color #999 + color var(--text) > [data-icon] margin-right 4px @@ -102,7 +103,7 @@ export default define({ margin 0 2px font-size 1em font-family inherit - border solid 1px #999 + border solid 1px var(--text) border-radius 2px </style> diff --git a/src/client/app/common/views/widgets/version.vue b/src/client/app/common/views/widgets/version.vue index eb2bb9972e..470eb1a84e 100644 --- a/src/client/app/common/views/widgets/version.vue +++ b/src/client/app/common/views/widgets/version.vue @@ -24,6 +24,7 @@ p padding 0 12px text-align center font-size 0.7em - color #aaa + color var(--text) + opacity 0.8 </style> diff --git a/src/client/app/config.ts b/src/client/app/config.ts index 0374ff0f95..c88214d3b3 100644 --- a/src/client/app/config.ts +++ b/src/client/app/config.ts @@ -16,7 +16,6 @@ export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss:// export const lang = window.lang; export const langs = _LANGS_; export const locale = JSON.parse(localStorage.getItem('locale')); -export const themeColor = _THEME_COLOR_; export const copyright = _COPYRIGHT_; export const version = _VERSION_; export const clientVersion = _CLIENT_VERSION_; diff --git a/src/client/app/desktop/api/update-avatar.ts b/src/client/app/desktop/api/update-avatar.ts index e0215aa34f..ff3d4dccfc 100644 --- a/src/client/app/desktop/api/update-avatar.ts +++ b/src/client/app/desktop/api/update-avatar.ts @@ -1,4 +1,4 @@ -import { apiUrl } from '../../config'; +import { apiUrl, locale } from '../../config'; import CropWindow from '../views/components/crop-window.vue'; import ProgressDialog from '../views/components/progress-dialog.vue'; @@ -8,8 +8,8 @@ export default ($root: any) => { const regex = RegExp('\.(jpg|jpeg|png|gif|webp|bmp|tiff)$'); if (!regex.test(file.name) ) { - $root.alert({ - title: '%fa:info-circle% %i18n:desktop.invalid-filetype%', + $root.dialog({ + title: locale['desktop']['invalid-filetype'], text: null }); return reject('invalid-filetype'); @@ -17,7 +17,7 @@ export default ($root: any) => { const w = $root.new(CropWindow, { image: file, - title: '%i18n:desktop.avatar-crop-title%', + title: locale['desktop']['avatar-crop-title'], aspectRatio: 1 / 1 }); @@ -27,11 +27,11 @@ export default ($root: any) => { data.append('file', blob, file.name + '.cropped.png'); $root.api('drive/folders/find', { - name: '%i18n:desktop.avatar%' + name: locale['desktop']['avatar'] }).then(avatarFolder => { if (avatarFolder.length === 0) { $root.api('drive/folders/create', { - name: '%i18n:desktop.avatar%' + name: locale['desktop']['avatar'] }).then(iconFolder => { resolve(upload(data, iconFolder)); }); @@ -52,7 +52,7 @@ export default ($root: any) => { const upload = (data, folder) => new Promise((resolve, reject) => { const dialog = $root.new(ProgressDialog, { - title: '%i18n:desktop.uploading-avatar%' + title: locale['desktop']['uploading-avatar'] }); document.body.appendChild(dialog.$el); @@ -87,8 +87,8 @@ export default ($root: any) => { value: i.avatarUrl }); - $root.alert({ - title: '%fa:info-circle% %i18n:desktop.avatar-updated%', + $root.dialog({ + title: locale['desktop']['avatar-updated'], text: null }); @@ -101,7 +101,7 @@ export default ($root: any) => { ? Promise.resolve(file) : $root.$chooseDriveFile({ multiple: false, - title: '%fa:image% %i18n:desktop.choose-avatar%' + title: locale['desktop']['choose-avatar'] }); return selectedFile diff --git a/src/client/app/desktop/api/update-banner.ts b/src/client/app/desktop/api/update-banner.ts index 36582684ec..3b2cf113be 100644 --- a/src/client/app/desktop/api/update-banner.ts +++ b/src/client/app/desktop/api/update-banner.ts @@ -1,4 +1,4 @@ -import { apiUrl } from '../../config'; +import { apiUrl, locale } from '../../config'; import CropWindow from '../views/components/crop-window.vue'; import ProgressDialog from '../views/components/progress-dialog.vue'; @@ -9,7 +9,7 @@ export default ($root: any) => { const regex = RegExp('\.(jpg|jpeg|png|gif|webp|bmp|tiff)$'); if (!regex.test(file.name) ) { $root.dialog({ - title: '%fa:info-circle% %i18n:desktop.invalid-filetype%', + title: locale['desktop']['invalid-filetype'], text: null }); return reject('invalid-filetype'); @@ -17,7 +17,7 @@ export default ($root: any) => { const w = $root.new(CropWindow, { image: file, - title: '%i18n:desktop.banner-crop-title%', + title: locale['desktop']['banner-crop-title'], aspectRatio: 16 / 9 }); @@ -27,11 +27,11 @@ export default ($root: any) => { data.append('file', blob, file.name + '.cropped.png'); $root.api('drive/folders/find', { - name: '%i18n:desktop.banner%' + name: locale['desktop']['banner'] }).then(bannerFolder => { if (bannerFolder.length === 0) { $root.api('drive/folders/create', { - name: '%i18n:desktop.banner%' + name: locale['desktop']['banner'] }).then(iconFolder => { resolve(upload(data, iconFolder)); }); @@ -52,7 +52,7 @@ export default ($root: any) => { const upload = (data, folder) => new Promise((resolve, reject) => { const dialog = $root.new(ProgressDialog, { - title: '%i18n:desktop.uploading-banner%' + title: locale['desktop']['uploading-banner'] }); document.body.appendChild(dialog.$el); @@ -87,8 +87,8 @@ export default ($root: any) => { value: i.bannerUrl }); - $root.alert({ - title: '%fa:info-circle% %i18n:desktop.banner-updated%', + $root.dialog({ + title: locale['desktop']['banner-updated'], text: null }); @@ -101,7 +101,7 @@ export default ($root: any) => { ? Promise.resolve(file) : $root.$chooseDriveFile({ multiple: false, - title: '%fa:image% %i18n:desktop.choose-banner%' + title: locale['desktop']['choose-banner'] }); return selectedFile diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts index cb23613164..05cd79f706 100644 --- a/src/client/app/desktop/script.ts +++ b/src/client/app/desktop/script.ts @@ -28,13 +28,14 @@ import MkTag from './views/pages/tag.vue'; import MkReversi from './views/pages/games/reversi.vue'; import MkShare from './views/pages/share.vue'; import MkFollow from '../common/views/pages/follow.vue'; +import MkNotFound from '../common/views/pages/not-found.vue'; +import MkSettings from './views/pages/settings.vue'; import Ctx from './views/components/context-menu.vue'; import PostFormWindow from './views/components/post-form-window.vue'; import RenoteFormWindow from './views/components/renote-form-window.vue'; import MkChooseFileFromDriveWindow from './views/components/choose-file-from-drive-window.vue'; import MkChooseFolderFromDriveWindow from './views/components/choose-folder-from-drive-window.vue'; -import InputDialog from './views/components/input-dialog.vue'; import Notification from './views/components/ui-notification.vue'; import { url } from '../config'; @@ -69,6 +70,7 @@ init(async (launch) => { } else { const vm = this.$root.new(PostFormWindow, { reply: o.reply, + mention: o.mention, animation: o.animation == null ? true : o.animation }); if (o.cb) vm.$once('closed', o.cb); @@ -113,22 +115,6 @@ init(async (launch) => { }); }, - $input(opts) { - return new Promise<string>((res, rej) => { - const o = opts || {}; - const d = this.$root.new(InputDialog, { - title: o.title, - placeholder: o.placeholder, - default: o.default, - type: o.type || 'text', - allowEmpty: o.allowEmpty - }); - d.$once('done', text => { - res(text); - }); - }); - }, - $notify(message) { this.$root.new(Notification, { message @@ -156,16 +142,18 @@ init(async (launch) => { { path: '/i/messaging/:user', component: MkMessagingRoom }, { path: '/i/drive', component: MkDrive }, { path: '/i/drive/folder/:folder', component: MkDrive }, + { path: '/i/settings', component: MkSettings }, { path: '/selectdrive', component: MkSelectDrive }, { path: '/search', component: MkSearch }, { path: '/tags/:tag', name: 'tag', component: MkTag }, { path: '/share', component: MkShare }, - { path: '/reversi/:game?', component: MkReversi }, + { path: '/games/reversi/:game?', component: MkReversi }, { path: '/@:user', name: 'user', component: MkUser }, { path: '/@:user/following', name: 'userFollowing', component: MkUserFollowingOrFollowers }, { path: '/@:user/followers', name: 'userFollowers', component: MkUserFollowingOrFollowers }, { path: '/notes/:note', name: 'note', component: MkNote }, - { path: '/authorize-follow', component: MkFollow } + { path: '/authorize-follow', component: MkFollow }, + { path: '*', component: MkNotFound } ] }); diff --git a/src/client/app/desktop/style.styl b/src/client/app/desktop/style.styl index 96481a9808..6ef9e329c1 100644 --- a/src/client/app/desktop/style.styl +++ b/src/client/app/desktop/style.styl @@ -12,6 +12,14 @@ html background var(--bg) &, * + scrollbar-color var(--scrollbarHandle) var(--scrollbarTrack) + + &:hover + scrollbar-color var(--scrollbarHandleHover) var(--scrollbarTrack) + + &:active + scrollbar-color var(--primary) var(--scrollbarTrack) + &::-webkit-scrollbar width 6px height 6px diff --git a/src/client/app/desktop/views/components/activity.calendar.vue b/src/client/app/desktop/views/components/activity.calendar.vue index 4306aa9282..9e3e6f0818 100644 --- a/src/client/app/desktop/views/components/activity.calendar.vue +++ b/src/client/app/desktop/views/components/activity.calendar.vue @@ -29,7 +29,9 @@ import Vue from 'vue'; export default Vue.extend({ props: ['data'], created() { - this.data.forEach(d => d.total = d.notes + d.replies + d.renotes); + for (const d of this.data) { + d.total = d.notes + d.replies + d.renotes; + } const peak = Math.max.apply(null, this.data.map(d => d.total)); const now = new Date(); diff --git a/src/client/app/desktop/views/components/activity.chart.vue b/src/client/app/desktop/views/components/activity.chart.vue index a6122ce8e8..7a5004e998 100644 --- a/src/client/app/desktop/views/components/activity.chart.vue +++ b/src/client/app/desktop/views/components/activity.chart.vue @@ -57,7 +57,10 @@ export default Vue.extend({ }; }, created() { - this.data.forEach(d => d.total = d.notes + d.replies + d.renotes); + for (const d of this.data) { + d.total = d.notes + d.replies + d.renotes; + } + this.render(); }, methods: { diff --git a/src/client/app/desktop/views/components/calendar.vue b/src/client/app/desktop/views/components/calendar.vue index fda984bd25..06517ee097 100644 --- a/src/client/app/desktop/views/components/calendar.vue +++ b/src/client/app/desktop/views/components/calendar.vue @@ -151,7 +151,7 @@ export default Vue.extend({ font-weight bold color var(--faceHeaderText) background var(--faceHeader) - box-shadow 0 1px rgba(#000, 0.07) + box-shadow 0 var(--lineWidth) rgba(#000, 0.07) > [data-icon] margin-right 4px @@ -199,11 +199,11 @@ export default Vue.extend({ color var(--calendarSaturdayOrSunday) &[data-today] - box-shadow 0 0 0 1px var(--calendarWeek) inset + box-shadow 0 0 0 var(--lineWidth) var(--calendarWeek) inset border-radius 6px &[data-is-donichi] - box-shadow 0 0 0 1px var(--calendarSaturdayOrSunday) inset + box-shadow 0 0 0 var(--lineWidth) var(--calendarSaturdayOrSunday) inset &.day cursor pointer diff --git a/src/client/app/desktop/views/components/context-menu.menu.vue b/src/client/app/desktop/views/components/context-menu.menu.vue index fae7811ea4..1ae3c85d57 100644 --- a/src/client/app/desktop/views/components/context-menu.menu.vue +++ b/src/client/app/desktop/views/components/context-menu.menu.vue @@ -46,7 +46,7 @@ export default Vue.extend({ &.divider margin-top $padding padding-top $padding - border-top solid 1px var(--faceDivider) + border-top solid var(--lineWidth) var(--faceDivider) &.nest > p diff --git a/src/client/app/desktop/views/components/context-menu.vue b/src/client/app/desktop/views/components/context-menu.vue index b0a34866cd..e79536fc0f 100644 --- a/src/client/app/desktop/views/components/context-menu.vue +++ b/src/client/app/desktop/views/components/context-menu.vue @@ -6,7 +6,7 @@ <script lang="ts"> import Vue from 'vue'; -import * as anime from 'animejs'; +import anime from 'animejs'; import contains from '../../../common/scripts/contains'; import XMenu from './context-menu.menu.vue'; @@ -34,9 +34,9 @@ export default Vue.extend({ this.$el.style.left = x + 'px'; this.$el.style.top = y + 'px'; - Array.from(document.querySelectorAll('body *')).forEach(el => { + for (const el of Array.from(document.querySelectorAll('body *'))) { el.addEventListener('mousedown', this.onMousedown); - }); + } this.$el.style.display = 'block'; @@ -59,9 +59,9 @@ export default Vue.extend({ this.close(); }, close() { - Array.from(document.querySelectorAll('body *')).forEach(el => { + for (const el of Array.from(document.querySelectorAll('body *'))) { el.removeEventListener('mousedown', this.onMousedown); - }); + } this.$emit('closed'); this.destroyDom(); diff --git a/src/client/app/desktop/views/components/drive.file.vue b/src/client/app/desktop/views/components/drive.file.vue index a643064078..fbd649e8f6 100644 --- a/src/client/app/desktop/views/components/drive.file.vue +++ b/src/client/app/desktop/views/components/drive.file.vue @@ -34,8 +34,10 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import * as anime from 'animejs'; +import anime from 'animejs'; import copyToClipboard from '../../../common/scripts/copy-to-clipboard'; +import updateAvatar from '../../api/update-avatar'; +import updateBanner from '../../api/update-banner'; export default Vue.extend({ i18n: i18n('desktop/views/components/drive.file.vue'), @@ -148,12 +150,15 @@ export default Vue.extend({ }, rename() { - this.$input({ + this.$root.dialog({ title: this.$t('contextmenu.rename-file'), - placeholder: this.$t('contextmenu.input-new-file-name'), - default: this.file.name, - allowEmpty: false - }).then(name => { + input: { + placeholder: this.$t('contextmenu.input-new-file-name'), + default: this.file.name, + allowEmpty: false + } + }).then(({ canceled, result: name }) => { + if (canceled) return; this.$root.api('drive/files/update', { fileId: this.file.id, name: name @@ -170,18 +175,18 @@ export default Vue.extend({ copyUrl() { copyToClipboard(this.file.url); - this.$root.alert({ + this.$root.dialog({ title: this.$t('contextmenu.copied'), text: this.$t('contextmenu.copied-url-to-clipboard') }); }, setAsAvatar() { - this.$updateAvatar(this.file); + updateAvatar(this.$root)(this.file); }, setAsBanner() { - this.$updateBanner(this.file); + updateBanner(this.$root)(this.file); }, addApp() { diff --git a/src/client/app/desktop/views/components/drive.folder.vue b/src/client/app/desktop/views/components/drive.folder.vue index 5558f65c3e..02f219a98e 100644 --- a/src/client/app/desktop/views/components/drive.folder.vue +++ b/src/client/app/desktop/views/components/drive.folder.vue @@ -120,9 +120,9 @@ export default Vue.extend({ // ファイルだったら if (e.dataTransfer.files.length > 0) { - Array.from(e.dataTransfer.files).forEach(file => { + for (const file of Array.from(e.dataTransfer.files)) { this.browser.upload(file, this.folder); - }); + } return; } @@ -155,7 +155,7 @@ export default Vue.extend({ }).catch(err => { switch (err) { case 'detected-circular-definition': - this.$root.alert({ + this.$root.dialog({ title: this.$t('unable-to-process'), text: this.$t('circular-reference-detected') }); @@ -192,11 +192,14 @@ export default Vue.extend({ }, rename() { - this.$input({ + this.$root.dialog({ title: this.$t('contextmenu.rename-folder'), - placeholder: this.$t('contextmenu.input-new-folder-name'), - default: this.folder.name - }).then(name => { + input: { + placeholder: this.$t('contextmenu.input-new-folder-name'), + default: this.folder.name + } + }).then(({ canceled, result: name }) => { + if (canceled) return; this.$root.api('drive/folders/update', { folderId: this.folder.id, name: name diff --git a/src/client/app/desktop/views/components/drive.nav-folder.vue b/src/client/app/desktop/views/components/drive.nav-folder.vue index 813c44ab42..14ab467642 100644 --- a/src/client/app/desktop/views/components/drive.nav-folder.vue +++ b/src/client/app/desktop/views/components/drive.nav-folder.vue @@ -68,9 +68,9 @@ export default Vue.extend({ // ファイルだったら if (e.dataTransfer.files.length > 0) { - Array.from(e.dataTransfer.files).forEach(file => { + for (const file of Array.from(e.dataTransfer.files)) { this.browser.upload(file, this.folder); - }); + } return; } diff --git a/src/client/app/desktop/views/components/drive.vue b/src/client/app/desktop/views/components/drive.vue index c4e9e102d5..144c4f88f6 100644 --- a/src/client/app/desktop/views/components/drive.vue +++ b/src/client/app/desktop/views/components/drive.vue @@ -277,9 +277,9 @@ export default Vue.extend({ // ドロップされてきたものがファイルだったら if (e.dataTransfer.files.length > 0) { - Array.from(e.dataTransfer.files).forEach(file => { + for (const file of Array.from(e.dataTransfer.files)) { this.upload(file, this.folder); - }); + } return; } @@ -313,7 +313,7 @@ export default Vue.extend({ }).catch(err => { switch (err) { case 'detected-circular-definition': - this.$root.alert({ + this.$root.dialog({ title: this.$t('unable-to-process'), text: this.$t('circular-reference-detected') }); @@ -331,16 +331,19 @@ export default Vue.extend({ }, urlUpload() { - this.$input({ + this.$root.dialog({ title: this.$t('url-upload'), - placeholder: this.$t('url-of-file') - }).then(url => { + input: { + placeholder: this.$t('url-of-file') + } + }).then(({ canceled, result: url }) => { + if (canceled) return; this.$root.api('drive/files/upload_from_url', { url: url, folderId: this.folder ? this.folder.id : undefined }); - this.$root.alert({ + this.$root.dialog({ title: this.$t('url-upload-requested'), text: this.$t('may-take-time') }); @@ -348,10 +351,13 @@ export default Vue.extend({ }, createFolder() { - this.$input({ + this.$root.dialog({ title: this.$t('create-folder'), - placeholder: this.$t('folder-name') - }).then(name => { + input: { + placeholder: this.$t('folder-name') + } + }).then(({ canceled, result: name }) => { + if (canceled) return; this.$root.api('drive/folders/create', { name: name, parentId: this.folder ? this.folder.id : undefined @@ -362,9 +368,9 @@ export default Vue.extend({ }, onChangeFileInput() { - Array.from((this.$refs.fileInput as any).files).forEach(file => { + for (const file of Array.from((this.$refs.fileInput as any).files)) { this.upload(file, this.folder); - }); + } }, upload(file, folder) { @@ -543,8 +549,8 @@ export default Vue.extend({ let flag = false; const complete = () => { if (flag) { - fetchedFolders.forEach(this.appendFolder); - fetchedFiles.forEach(this.appendFile); + for (const x of fetchedFolders) this.appendFolder(x); + for (const x of fetchedFiles) this.appendFile(x); this.fetching = false; } else { flag = true; @@ -569,7 +575,7 @@ export default Vue.extend({ } else { this.moreFiles = false; } - files.forEach(this.appendFile); + for (const x of files) this.appendFile(x); this.fetching = false; }); } diff --git a/src/client/app/desktop/views/components/emoji-picker-dialog.vue b/src/client/app/desktop/views/components/emoji-picker-dialog.vue index 06dbe75846..0b18a85000 100644 --- a/src/client/app/desktop/views/components/emoji-picker-dialog.vue +++ b/src/client/app/desktop/views/components/emoji-picker-dialog.vue @@ -43,9 +43,9 @@ export default Vue.extend({ this.$el.style.left = x + 'px'; this.$el.style.top = y + 'px'; - Array.from(document.querySelectorAll('body *')).forEach(el => { + for (const el of Array.from(document.querySelectorAll('body *'))) { el.addEventListener('mousedown', this.onMousedown); - }); + } }); }, @@ -62,9 +62,9 @@ export default Vue.extend({ }, close() { - Array.from(document.querySelectorAll('body *')).forEach(el => { + for (const el of Array.from(document.querySelectorAll('body *'))) { el.removeEventListener('mousedown', this.onMousedown); - }); + } this.$emit('closed'); this.destroyDom(); diff --git a/src/client/app/desktop/views/components/friends-maker.vue b/src/client/app/desktop/views/components/friends-maker.vue index 36526283e7..2cb04c3584 100644 --- a/src/client/app/desktop/views/components/friends-maker.vue +++ b/src/client/app/desktop/views/components/friends-maker.vue @@ -5,7 +5,9 @@ <div class="user" v-for="user in users" :key="user.id"> <mk-avatar class="avatar" :user="user" target="_blank"/> <div class="body"> - <router-link class="name" :to="user | userPage" v-user-preview="user.id">{{ user | userName }}</router-link> + <router-link class="name" :to="user | userPage" v-user-preview="user.id"> + <mk-user-name :user="user"/> + </router-link> <p class="username">@{{ user | acct }}</p> </div> </div> diff --git a/src/client/app/desktop/views/components/game-window.vue b/src/client/app/desktop/views/components/game-window.vue index 10db062362..9f8e0a2aa9 100644 --- a/src/client/app/desktop/views/components/game-window.vue +++ b/src/client/app/desktop/views/components/game-window.vue @@ -23,8 +23,8 @@ export default Vue.extend({ computed: { popout(): string { return this.game - ? `${url}/reversi/${this.game.id}` - : `${url}/reversi`; + ? `${url}/games/reversi/${this.game.id}` + : `${url}/games/reversi`; } } }); diff --git a/src/client/app/desktop/views/components/home.vue b/src/client/app/desktop/views/components/home.vue index 492edc67d6..18cb215202 100644 --- a/src/client/app/desktop/views/components/home.vue +++ b/src/client/app/desktop/views/components/home.vue @@ -26,7 +26,6 @@ <option value="hashtags">{{ $t('@.widgets.hashtags') }}</option> <option value="posts-monitor">{{ $t('@.widgets.posts-monitor') }}</option> <option value="server">{{ $t('@.widgets.server') }}</option> - <option value="donation">{{ $t('@.widgets.donation') }}</option> <option value="nav">{{ $t('@.widgets.nav') }}</option> <option value="tips">{{ $t('@.widgets.tips') }}</option> </select> @@ -95,7 +94,6 @@ const defaultDesktopHomeWidgets = { 'users', 'polls', 'server', - 'donation', 'nav', 'tips' ] @@ -104,23 +102,23 @@ const defaultDesktopHomeWidgets = { //#region Construct home data const _defaultDesktopHomeWidgets = []; -defaultDesktopHomeWidgets.left.forEach(widget => { +for (const widget of defaultDesktopHomeWidgets.left) { _defaultDesktopHomeWidgets.push({ name: widget, id: uuid(), place: 'left', data: {} }); -}); +} -defaultDesktopHomeWidgets.right.forEach(widget => { +for (const widget of defaultDesktopHomeWidgets.right) { _defaultDesktopHomeWidgets.push({ name: widget, id: uuid(), place: 'right', data: {} }); -}); +} //#endregion export default Vue.extend({ @@ -186,7 +184,7 @@ export default Vue.extend({ methods: { hint() { - this.$root.alert({ + this.$root.dialog({ title: this.$t('@.customization-tips.title'), text: this.$t('@.customization-tips.paragraph') }); @@ -222,8 +220,8 @@ export default Vue.extend({ const left = this.widgets.left; const right = this.widgets.right; this.$store.commit('settings/setHome', left.concat(right)); - left.forEach(w => w.place = 'left'); - right.forEach(w => w.place = 'right'); + for (const w of left) w.place = 'left'; + for (const w of right) w.place = 'right'; this.$root.api('i/update_home', { home: this.home }); diff --git a/src/client/app/desktop/views/components/input-dialog.vue b/src/client/app/desktop/views/components/input-dialog.vue deleted file mode 100644 index d08410e36d..0000000000 --- a/src/client/app/desktop/views/components/input-dialog.vue +++ /dev/null @@ -1,180 +0,0 @@ -<template> -<mk-window ref="window" is-modal width="500px" @before-close="beforeClose" @closed="destroyDom"> - <span slot="header" :class="$style.header"> - <fa icon="i-cursor"/>{{ title }} - </span> - - <div :class="$style.body"> - <input ref="text" v-model="text" :type="type" @keydown="onKeydown" :placeholder="placeholder"/> - </div> - <div :class="$style.actions"> - <button :class="$style.cancel" @click="cancel">{{ $t('cancel') }}</button> - <button :class="$style.ok" :disabled="!allowEmpty && text.length == 0" @click="ok">{{ $t('ok') }}</button> - </div> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -export default Vue.extend({ - i18n: i18n('desktop/views/input-dialog.vue'), - props: { - title: { - type: String - }, - placeholder: { - type: String - }, - default: { - type: String - }, - allowEmpty: { - default: true - }, - type: { - default: 'text' - } - }, - data() { - return { - done: false, - text: '' - }; - }, - mounted() { - if (this.default) this.text = this.default; - this.$nextTick(() => { - (this.$refs.text as any).focus(); - }); - }, - methods: { - ok() { - if (!this.allowEmpty && this.text == '') return; - this.done = true; - (this.$refs.window as any).close(); - }, - cancel() { - this.done = false; - (this.$refs.window as any).close(); - }, - beforeClose() { - if (this.done) { - this.$emit('done', this.text); - } else { - this.$emit('canceled'); - } - }, - onKeydown(e) { - if (e.which == 13) { // Enter - e.preventDefault(); - e.stopPropagation(); - this.ok(); - } - } - } -}); -</script> - - -<style lang="stylus" module> -.header - > [data-icon] - margin-right 4px - -.body - padding 16px - - > input - display block - padding 8px - margin 0 - width 100% - max-width 100% - min-width 100% - font-size 1em - color #333 - background #fff - outline none - border solid 1px var(--primaryAlpha01) - border-radius 4px - transition border-color .3s ease - - &:hover - border-color var(--primaryAlpha02) - transition border-color .1s ease - - &:focus - color var(--primary) - border-color var(--primaryAlpha05) - transition border-color 0s ease - - &::-webkit-input-placeholder - color var(--primaryAlpha03) - -.actions - height 72px - background var(--primaryLighten95) - -.ok -.cancel - display block - position absolute - bottom 16px - cursor pointer - padding 0 - margin 0 - width 120px - height 40px - font-size 1em - outline none - border-radius 4px - - &:focus - &:after - content "" - pointer-events none - position absolute - top -5px - right -5px - bottom -5px - left -5px - border 2px solid var(--primaryAlpha03) - border-radius 8px - - &:disabled - opacity 0.7 - cursor default - -.ok - right 16px - color var(--primaryForeground) - background linear-gradient(to bottom, var(--primaryLighten25) 0%, var(--primaryLighten10) 100%) - border solid 1px var(--primaryLighten15) - - &:not(:disabled) - font-weight bold - - &:hover:not(:disabled) - background linear-gradient(to bottom, var(--primaryLighten8) 0%, var(--primaryDarken8) 100%) - border-color var(--primary) - - &:active:not(:disabled) - background var(--primary) - border-color var(--primary) - -.cancel - right 148px - color #888 - background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) - border solid 1px #e2e2e2 - - &:hover - background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) - border-color #dcdcdc - - &:active - background #ececec - border-color #dcdcdc - -</style> diff --git a/src/client/app/desktop/views/components/media-video-dialog.vue b/src/client/app/desktop/views/components/media-video-dialog.vue index 03c93c8939..cb112e5266 100644 --- a/src/client/app/desktop/views/components/media-video-dialog.vue +++ b/src/client/app/desktop/views/components/media-video-dialog.vue @@ -7,7 +7,7 @@ <script lang="ts"> import Vue from 'vue'; -import * as anime from 'animejs'; +import anime from 'animejs'; export default Vue.extend({ props: ['video', 'start'], diff --git a/src/client/app/desktop/views/components/media-video.vue b/src/client/app/desktop/views/components/media-video.vue index 727af8ade2..4d73cfe3ab 100644 --- a/src/client/app/desktop/views/components/media-video.vue +++ b/src/client/app/desktop/views/components/media-video.vue @@ -1,5 +1,5 @@ <template> -<div class="uofhebxjdgksfmltszlxurtjnjjsvioh" v-if="video.isSensitive && hide" @click="hide = false"> +<div class="uofhebxjdgksfmltszlxurtjnjjsvioh" v-if="video.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false"> <div> <b><fa icon="exclamation-triangle"/> {{ $t('sensitive') }}</b> <span>{{ $t('click-to-show') }}</span> diff --git a/src/client/app/desktop/views/components/messaging-room-window.vue b/src/client/app/desktop/views/components/messaging-room-window.vue index c1d310b8dd..a7db1bca60 100644 --- a/src/client/app/desktop/views/components/messaging-room-window.vue +++ b/src/client/app/desktop/views/components/messaging-room-window.vue @@ -1,6 +1,6 @@ <template> <mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="destroyDom"> - <span slot="header" :class="$style.header"><fa icon="comments"/>{{ $t('title') }} {{ user | userName }}</span> + <span slot="header" :class="$style.header"><fa icon="comments"/>{{ $t('title') }} <mk-user-name :user="user"/></span> <x-messaging-room :user="user" :class="$style.content"/> </mk-window> </template> diff --git a/src/client/app/desktop/views/components/note-detail.vue b/src/client/app/desktop/views/components/note-detail.vue index eca9896877..96182a8198 100644 --- a/src/client/app/desktop/views/components/note-detail.vue +++ b/src/client/app/desktop/views/components/note-detail.vue @@ -1,8 +1,8 @@ <template> -<div class="mk-note-detail" :title="title"> +<div class="mk-note-detail" :title="title" tabindex="-1"> <button class="read-more" - v-if="p.reply && p.reply.replyId && conversation.length == 0" + v-if="appearNote.reply && appearNote.reply.replyId && conversation.length == 0" :title="$t('title')" @click="fetchConversation" :disabled="conversationFetching" @@ -13,67 +13,75 @@ <div class="conversation"> <x-sub v-for="note in conversation" :key="note.id" :note="note"/> </div> - <div class="reply-to" v-if="p.reply"> - <x-sub :note="p.reply"/> - </div> - <div class="renote" v-if="isRenote"> - <p> - <mk-avatar class="avatar" :user="note.user"/> - <fa icon="retweet"/> - <router-link class="name" :href="note.user | userPage">{{ note.user | userName }}</router-link> - <span>{{ this.$t('reposted-by').substr(0, this.$t('reposted-by').indexOf('{')) }}</span> - <a class="name" :href="note.user | userPage" v-user-preview="note.userId">{{ note.user | userName }}</a> - <span>{{ this.$t('reposted-by').substr(this.$t('reposted-by').indexOf('}') + 1) }}</span> - <mk-time :time="note.createdAt"/> - </p> + <div class="reply-to" v-if="appearNote.reply"> + <x-sub :note="appearNote.reply"/> </div> + <mk-renote class="renote" v-if="isRenote" :note="note"/> <article> - <mk-avatar class="avatar" :user="p.user"/> + <mk-avatar class="avatar" :user="appearNote.user"/> <header> - <router-link class="name" :to="p.user | userPage" v-user-preview="p.user.id">{{ p.user | userName }}</router-link> - <span class="username"><mk-acct :user="p.user"/></span> - <router-link class="time" :to="p | notePage"> - <mk-time :time="p.createdAt"/> + <router-link class="name" :to="appearNote.user | userPage" v-user-preview="appearNote.user.id"> + <mk-user-name :user="appearNote.user"/> </router-link> + <span class="username"><mk-acct :user="appearNote.user"/></span> + <div class="info"> + <router-link class="time" :to="appearNote | notePage"> + <mk-time :time="appearNote.createdAt"/> + </router-link> + <div class="visibility-info"> + <span class="visibility" v-if="appearNote.visibility != 'public'"> + <fa v-if="appearNote.visibility == 'home'" icon="home"/> + <fa v-if="appearNote.visibility == 'followers'" icon="unlock"/> + <fa v-if="appearNote.visibility == 'specified'" icon="envelope"/> + </span> + <span class="localOnly" v-if="appearNote.localOnly == true"><fa icon="heart"/></span> + </div> + </div> </header> <div class="body"> - <p v-if="p.cw != null" class="cw"> - <span class="text" v-if="p.cw != ''">{{ p.cw }}</span> - <mk-cw-button v-model="showContent"/> + <p v-if="appearNote.cw != null" class="cw"> + <mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" /> + <mk-cw-button v-model="showContent" :note="appearNote"/> </p> - <div class="content" v-show="p.cw == null || showContent"> + <div class="content" v-show="appearNote.cw == null || showContent"> <div class="text"> - <span v-if="p.isHidden" style="opacity: 0.5">{{ $t('private') }}</span> - <span v-if="p.deletedAt" style="opacity: 0.5">{{ $t('deleted') }}</span> - <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i" :customEmojis="p.emojis" /> + <span v-if="appearNote.isHidden" style="opacity: 0.5">{{ $t('private') }}</span> + <span v-if="appearNote.deletedAt" style="opacity: 0.5">{{ $t('deleted') }}</span> + <mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" /> </div> - <div class="files" v-if="p.files.length > 0"> - <mk-media-list :media-list="p.files" :raw="true"/> + <div class="files" v-if="appearNote.files.length > 0"> + <mk-media-list :media-list="appearNote.files" :raw="true"/> </div> - <mk-poll v-if="p.poll" :note="p"/> + <mk-poll v-if="appearNote.poll" :note="appearNote"/> <mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/> - <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank"><fa icon="map-marker-alt"/> {{ $t('location') }}</a> - <div class="map" v-if="p.geo" ref="map"></div> - <div class="renote" v-if="p.renote"> - <mk-note-preview :note="p.renote"/> + <a class="location" v-if="appearNote.geo" :href="`https://maps.google.com/maps?q=${appearNote.geo.coordinates[1]},${appearNote.geo.coordinates[0]}`" target="_blank"><fa icon="map-marker-alt"/> {{ $t('location') }}</a> + <div class="map" v-if="appearNote.geo" ref="map"></div> + <div class="renote" v-if="appearNote.renote"> + <mk-note-preview :note="appearNote.renote"/> </div> </div> </div> <footer> <span class="app" v-if="note.app && $store.state.settings.showVia">via <b>{{ note.app.name }}</b></span> - <mk-reactions-viewer :note="p"/> - <button class="replyButton" @click="reply" :title="$t('reply')"> - <template v-if="p.reply"><fa icon="reply-all"/></template> + <mk-reactions-viewer :note="appearNote"/> + <button class="replyButton" @click="reply()" :title="$t('reply')"> + <template v-if="appearNote.reply"><fa icon="reply-all"/></template> <template v-else><fa icon="reply"/></template> - <p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> + <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> </button> - <button class="renoteButton" @click="renote" :title="$t('renote')"> - <fa icon="retweet"/><p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p> + <button v-if="['public', 'home'].includes(appearNote.visibility)" class="renoteButton" @click="renote()" :title="$t('renote')"> + <fa icon="retweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> </button> - <button class="reactionButton" :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton" :title="$t('add-reaction')"> - <fa icon="plus"/><p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p> + <button v-else class="inhibitedButton"> + <fa icon="ban"/> </button> - <button @click="menu" ref="menuButton"> + <button v-if="!isMyNote && appearNote.myReaction == null" class="reactionButton" @click="react()" ref="reactButton" :title="$t('add-reaction')"> + <fa icon="plus"/> + </button> + <button v-if="!isMyNote && appearNote.myReaction != null" class="reactionButton reacted" @click="undoReact(appearNote)" ref="reactButton" :title="$t('undo-reaction')"> + <fa icon="minus"/> + </button> + <button @click="menu()" ref="menuButton"> <fa icon="ellipsis-h"/> </button> </footer> @@ -87,23 +95,18 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import parse from '../../../../../mfm/parse'; - -import MkPostFormWindow from './post-form-window.vue'; -import MkRenoteFormWindow from './renote-form-window.vue'; -import MkNoteMenu from '../../../common/views/components/note-menu.vue'; -import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; import XSub from './note.sub.vue'; -import { sum, unique } from '../../../../../prelude/array'; import noteSubscriber from '../../../common/scripts/note-subscriber'; +import noteMixin from '../../../common/scripts/note-mixin'; export default Vue.extend({ i18n: i18n('desktop/views/components/note-detail.vue'), + components: { XSub }, - mixins: [noteSubscriber('note')], + mixins: [noteMixin(), noteSubscriber('note')], props: { note: { @@ -117,75 +120,22 @@ export default Vue.extend({ data() { return { - showContent: false, conversation: [], conversationFetching: false, replies: [] }; }, - computed: { - isRenote(): boolean { - return (this.note.renote && - this.note.text == null && - this.note.fileIds.length == 0 && - this.note.poll == null); - }, - - p(): any { - return this.isRenote ? this.note.renote : this.note; - }, - - reactionsCount(): number { - return this.p.reactionCounts - ? sum(Object.values(this.p.reactionCounts)) - : 0; - }, - - title(): string { - return new Date(this.p.createdAt).toLocaleString(); - }, - - urls(): string[] { - if (this.p.text) { - const ast = parse(this.p.text); - return unique(ast - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => t.url)); - } else { - return null; - } - } - }, - mounted() { // Get replies if (!this.compact) { this.$root.api('notes/replies', { - noteId: this.p.id, + noteId: this.appearNote.id, limit: 8 }).then(replies => { this.replies = replies; }); } - - // Draw map - if (this.p.geo) { - const shouldShowMap = this.$store.getters.isSignedIn ? this.$store.state.settings.showMaps : true; - if (shouldShowMap) { - this.$root.os.getGoogleMaps().then(maps => { - const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]); - const map = new maps.Map(this.$refs.map, { - center: uluru, - zoom: 15 - }); - new maps.Marker({ - position: uluru, - map: map - }); - }); - } - } }, methods: { @@ -194,37 +144,11 @@ export default Vue.extend({ // Fetch conversation this.$root.api('notes/conversation', { - noteId: this.p.replyId + noteId: this.appearNote.replyId }).then(conversation => { this.conversationFetching = false; this.conversation = conversation.reverse(); }); - }, - - reply() { - this.$root.new(MkPostFormWindow, { - reply: this.p - }); - }, - - renote() { - this.$root.new(MkRenoteFormWindow, { - note: this.p - }); - }, - - react() { - this.$root.new(MkReactionPicker, { - source: this.$refs.reactButton, - note: this.p - }); - }, - - menu() { - this.$root.new(MkNoteMenu, { - source: this.$refs.menuButton, - note: this.p - }); } } }); @@ -266,29 +190,8 @@ export default Vue.extend({ > * border-bottom 1px solid var(--faceDivider) - > .renote - color var(--renoteText) - background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%) - - > p - margin 0 - padding 16px 32px - - .avatar - display inline-block - width 28px - height 28px - margin 0 8px 0 0 - border-radius 6px - - [data-icon] - margin-right 4px - - .name - font-weight bold - - & + article - padding-top 8px + > .renote + article + padding-top 8px > .reply-to border-bottom 1px solid var(--faceDivider) @@ -335,12 +238,21 @@ export default Vue.extend({ margin 0 color var(--noteHeaderAcct) - > .time + > .info position absolute top 0 right 32px font-size 1em - color var(--noteHeaderInfo) + + > .time + color var(--noteHeaderInfo) + + > .visibility-info + text-align: right + color var(--noteHeaderInfo) + + > .localOnly + margin-left 4px > .body padding 8px 0 @@ -419,10 +331,14 @@ export default Vue.extend({ &.reactionButton:hover color var(--noteActionsReactionHover) + &.inhibitedButton + cursor not-allowed + > .count display inline margin 0 0 0 8px - color #999 + color var(--text) + opacity 0.7 &.reacted, &.reacted:hover color var(--noteActionsReactionHover) diff --git a/src/client/app/desktop/views/components/note-preview.vue b/src/client/app/desktop/views/components/note-preview.vue index 4c1c7e7b2d..46402b7088 100644 --- a/src/client/app/desktop/views/components/note-preview.vue +++ b/src/client/app/desktop/views/components/note-preview.vue @@ -5,8 +5,8 @@ <mk-note-header class="header" :note="note" :mini="true"/> <div class="body"> <p v-if="note.cw != null" class="cw"> - <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> - <mk-cw-button v-model="showContent"/> + <mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis" /> + <mk-cw-button v-model="showContent" :note="note"/> </p> <div class="content" v-show="note.cw == null || showContent"> <mk-sub-note-content class="text" :note="note"/> diff --git a/src/client/app/desktop/views/components/note.sub.vue b/src/client/app/desktop/views/components/note.sub.vue index 5ba22fc76f..11b209da22 100644 --- a/src/client/app/desktop/views/components/note.sub.vue +++ b/src/client/app/desktop/views/components/note.sub.vue @@ -5,8 +5,8 @@ <mk-note-header class="header" :note="note"/> <div class="body"> <p v-if="note.cw != null" class="cw"> - <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> - <mk-cw-button v-model="showContent"/> + <mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis" /> + <mk-cw-button v-model="showContent" :note="note"/> </p> <div class="content" v-show="note.cw == null || showContent"> <mk-sub-note-content class="text" :note="note"/> diff --git a/src/client/app/desktop/views/components/note.vue b/src/client/app/desktop/views/components/note.vue index 71d7ae4203..3a6eab1cb2 100644 --- a/src/client/app/desktop/views/components/note.vue +++ b/src/client/app/desktop/views/components/note.vue @@ -13,35 +13,21 @@ <div class="reply-to" v-if="appearNote.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)"> <x-sub :note="appearNote.reply" :mini="mini"/> </div> - <div class="renote" v-if="isRenote"> - <mk-avatar class="avatar" :user="note.user"/> - <fa icon="retweet"/> - <span>{{ this.$t('reposted-by').substr(0, this.$t('reposted-by').indexOf('{')) }}</span> - <router-link class="name" :to="note.user | userPage" v-user-preview="note.userId">{{ note.user | userName }}</router-link> - <span>{{ this.$t('reposted-by').substr(this.$t('reposted-by').indexOf('}') + 1) }}</span> - <mk-time :time="note.createdAt"/> - <span class="visibility" v-if="note.visibility != 'public'"> - <fa v-if="note.visibility == 'home'" icon="home"/> - <fa v-if="note.visibility == 'followers'" icon="unlock"/> - <fa v-if="note.visibility == 'specified'" icon="envelope"/> - <fa v-if="note.visibility == 'private'" icon="lock"/> - </span> - <span class="localOnly" v-if="note.localOnly == true"><fa icon="heart"/></span> - </div> + <mk-renote class="renote" v-if="isRenote" :note="note"/> <article> <mk-avatar class="avatar" :user="appearNote.user"/> <div class="main"> <mk-note-header class="header" :note="appearNote" :mini="mini"/> <div class="body" v-if="appearNote.deletedAt == null"> <p v-if="appearNote.cw != null" class="cw"> - <span class="text" v-if="appearNote.cw != ''">{{ appearNote.cw }}</span> - <mk-cw-button v-model="showContent"/> + <mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" /> + <mk-cw-button v-model="showContent" :note="appearNote"/> </p> <div class="content" v-show="appearNote.cw == null || showContent"> <div class="text"> <span v-if="appearNote.isHidden" style="opacity: 0.5">{{ $t('private') }}</span> <a class="reply" v-if="appearNote.reply"><fa icon="reply"/></a> - <misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :i="$store.state.i" :class="$style.text" :customEmojis="appearNote.emojis"/> + <mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/> <a class="rp" v-if="appearNote.renote">RN:</a> </div> <div class="files" v-if="appearNote.files.length > 0"> @@ -50,7 +36,7 @@ <mk-poll v-if="appearNote.poll" :note="appearNote" ref="pollViewer"/> <a class="location" v-if="appearNote.geo" :href="`https://maps.google.com/maps?q=${appearNote.geo.coordinates[1]},${appearNote.geo.coordinates[0]}`" target="_blank"><fa icon="map-marker-alt"/> 位置情報</a> <div class="renote" v-if="appearNote.renote"><mk-note-preview :note="appearNote.renote" :mini="mini"/></div> - <mk-url-preview v-for="url in urls" :url="url" :key="url" :mini="mini"/> + <mk-url-preview v-for="url in urls" :url="url" :key="url" :mini="mini" :compact="compact"/> </div> </div> <footer v-if="appearNote.deletedAt == null"> @@ -61,11 +47,17 @@ <template v-else><fa icon="reply"/></template> <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> </button> - <button class="renoteButton" @click="renote()" :title="$t('renote')"> + <button v-if="['public', 'home'].includes(appearNote.visibility)" class="renoteButton" @click="renote()" :title="$t('renote')"> <fa icon="retweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> </button> - <button class="reactionButton" :class="{ reacted: appearNote.myReaction != null }" @click="react()" ref="reactButton" :title="$t('add-reaction')"> - <fa icon="plus"/><p class="count" v-if="appearNote.reactions_count > 0">{{ appearNote.reactions_count }}</p> + <button v-else class="inhibitedButton"> + <fa icon="ban"/> + </button> + <button v-if="!isMyNote && appearNote.myReaction == null" class="reactionButton" @click="react()" ref="reactButton" :title="$t('add-reaction')"> + <fa icon="plus"/> + </button> + <button v-if="!isMyNote && appearNote.myReaction != null" class="reactionButton reacted" @click="undoReact(appearNote)" ref="reactButton" :title="$t('undo-reaction')"> + <fa icon="minus"/> </button> <button @click="menu()" ref="menuButton"> <fa icon="ellipsis-h"/> @@ -110,6 +102,11 @@ export default Vue.extend({ required: false, default: false }, + compact: { + type: Boolean, + required: false, + default: false + }, mini: { type: Boolean, required: false, @@ -148,7 +145,7 @@ export default Vue.extend({ margin 0 padding 0 background var(--face) - border-bottom solid 1px var(--faceDivider) + border-bottom solid var(--lineWidth) var(--faceDivider) &.mini font-size 13px @@ -185,56 +182,8 @@ export default Vue.extend({ border 2px solid var(--primaryAlpha03) border-radius 4px - > .renote - display flex - align-items center - padding 16px 32px 8px 32px - line-height 28px - white-space pre - color var(--renoteText) - background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%) - - .avatar - flex-shrink 0 - display inline-block - width 28px - height 28px - margin 0 8px 0 0 - border-radius 6px - - [data-icon] - margin-right 4px - - > span - flex-shrink 0 - - .name - overflow hidden - flex-shrink 1 - text-overflow ellipsis - white-space nowrap - font-weight bold - - > .mk-time - display block - margin-left auto - flex-shrink 0 - font-size 0.9em - - > .visibility - margin-left 8px - - [data-icon] - margin-right 0 - - > .localOnly - margin-left 4px - - [data-icon] - margin-right 0 - - & + article - padding-top 8px + > .renote + article + padding-top 8px > article display flex @@ -285,24 +234,6 @@ export default Vue.extend({ overflow-wrap break-word color var(--noteText) - >>> .title - display block - margin-bottom 4px - padding 4px - font-size 90% - text-align center - background var(--mfmTitleBg) - border-radius 4px - - >>> .code - margin 8px 0 - - >>> .quote - margin 8px - padding 6px 12px - color var(--mfmQuote) - border-left solid 3px var(--mfmQuoteLine) - > .reply margin-right 8px color var(--text) @@ -335,7 +266,7 @@ export default Vue.extend({ > * padding 16px - border dashed 1px var(--quoteBorder) + border dashed var(--lineWidth) var(--quoteBorder) border-radius 8px > footer @@ -371,10 +302,14 @@ export default Vue.extend({ &.reactionButton:hover color var(--noteActionsReactionHover) + &.inhibitedButton + cursor not-allowed + > .count display inline margin 0 0 0 8px - color #999 + color var(--text) + opacity 0.7 &.reacted, &.reacted:hover color var(--noteActionsReactionHover) @@ -384,28 +319,3 @@ export default Vue.extend({ opacity 0.7 </style> - -<style lang="stylus" module> -.text - - code - padding 4px 8px - margin 0 0.5em - font-size 80% - color #525252 - background #f8f8f8 - border-radius 2px - - pre > code - padding 16px - margin 0 - - [data-is-me]:after - content "you" - padding 0 4px - margin-left 4px - font-size 80% - color var(--primaryForeground) - background var(--primary) - border-radius 4px -</style> diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue index 0e710feb69..5cf51d9cc4 100644 --- a/src/client/app/desktop/views/components/notes.vue +++ b/src/client/app/desktop/views/components/notes.vue @@ -15,7 +15,7 @@ <!-- トランジションを有効にするとなぜかメモリリークする --> <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="notes transition" tag="div" ref="notes"> <template v-for="(note, i) in _notes"> - <x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)" ref="note"/> + <x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)" :compact="true" ref="note"/> <p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date"> <span><fa icon="angle-up"/>{{ note._datetext }}</span> <span><fa icon="angle-down"/>{{ _notes[i + 1]._datetext }}</span> @@ -156,7 +156,9 @@ export default Vue.extend({ }, releaseQueue() { - this.queue.forEach(n => this.prepend(n, true)); + for (const n of this.queue) { + this.prepend(n, true); + } this.queue = []; }, @@ -207,7 +209,7 @@ export default Vue.extend({ text-align center color var(--dateDividerFg) background var(--dateDividerBg) - border-bottom solid 1px var(--faceDivider) + border-bottom solid var(--lineWidth) var(--faceDivider) span margin 0 16px @@ -231,7 +233,7 @@ export default Vue.extend({ text-align center color #ccc background var(--face) - border-top solid 1px var(--faceDivider) + border-top solid var(--lineWidth) var(--faceDivider) border-bottom-left-radius 6px border-bottom-right-radius 6px diff --git a/src/client/app/desktop/views/components/notifications.vue b/src/client/app/desktop/views/components/notifications.vue index b838a5a558..24b6fc3eba 100644 --- a/src/client/app/desktop/views/components/notifications.vue +++ b/src/client/app/desktop/views/components/notifications.vue @@ -18,10 +18,14 @@ <div class="text"> <p> <mk-reaction-icon :reaction="notification.reaction"/> - <router-link :to="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</router-link> + <router-link :to="notification.user | userPage" v-user-preview="notification.user.id"> + <mk-user-name :user="notification.user"/> + </router-link> </p> - <router-link class="note-ref" :to="notification.note | notePage"> - <fa icon="quote-left"/>{{ getNoteSummary(notification.note) }}<fa icon="quote-right"/> + <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <fa icon="quote-left"/> + <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + <fa icon="quote-right"/> </router-link> </div> </template> @@ -30,10 +34,14 @@ <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> <p><fa icon="retweet"/> - <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link> + <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId"> + <mk-user-name :user="notification.note.user"/> + </router-link> </p> - <router-link class="note-ref" :to="notification.note | notePage"> - <fa icon="quote-left"/>{{ getNoteSummary(notification.note.renote) }}<fa icon="quote-right"/> + <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)"> + <fa icon="quote-left"/> + <mfm :text="getNoteSummary(notification.note.renote)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.renote.emojis"/> + <fa icon="quote-right"/> </router-link> </div> </template> @@ -42,9 +50,13 @@ <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> <p><fa icon="quote-left"/> - <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link> + <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId"> + <mk-user-name :user="notification.note.user"/> + </router-link> </p> - <router-link class="note-preview" :to="notification.note | notePage">{{ getNoteSummary(notification.note) }}</router-link> + <router-link class="note-preview" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + </router-link> </div> </template> @@ -52,7 +64,9 @@ <mk-avatar class="avatar" :user="notification.user"/> <div class="text"> <p><fa icon="user-plus"/> - <router-link :to="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</router-link> + <router-link :to="notification.user | userPage" v-user-preview="notification.user.id"> + <mk-user-name :user="notification.user"/> + </router-link> </p> </div> </template> @@ -61,7 +75,9 @@ <mk-avatar class="avatar" :user="notification.user"/> <div class="text"> <p><fa icon="user-clock"/> - <router-link :to="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</router-link> + <router-link :to="notification.user | userPage" v-user-preview="notification.user.id"> + <mk-user-name :user="notification.user"/> + </router-link> </p> </div> </template> @@ -70,9 +86,13 @@ <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> <p><fa icon="reply"/> - <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link> + <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId"> + <mk-user-name :user="notification.note.user"/> + </router-link> </p> - <router-link class="note-preview" :to="notification.note | notePage">{{ getNoteSummary(notification.note) }}</router-link> + <router-link class="note-preview" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + </router-link> </div> </template> @@ -80,18 +100,26 @@ <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> <p><fa icon="at"/> - <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link> + <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId"> + <mk-user-name :user="notification.note.user"/> + </router-link> </p> - <a class="note-preview" :href="notification.note | notePage">{{ getNoteSummary(notification.note) }}</a> + <a class="note-preview" :href="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + </a> </div> </template> <template v-if="notification.type == 'poll_vote'"> <mk-avatar class="avatar" :user="notification.user"/> <div class="text"> - <p><fa icon="chart-pie"/><a :href="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</a></p> - <router-link class="note-ref" :to="notification.note | notePage"> - <fa icon="quote-left"/>{{ getNoteSummary(notification.note) }}<fa icon="quote-right"/> + <p><fa icon="chart-pie"/><a :href="notification.user | userPage" v-user-preview="notification.user.id"> + <mk-user-name :user="notification.user"/> + </a></p> + <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <fa icon="quote-left"/> + <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + <fa icon="quote-right"/> </router-link> </div> </template> @@ -219,8 +247,8 @@ export default Vue.extend({ margin 0 padding 16px overflow-wrap break-word - font-size 13px - border-bottom solid 1px var(--faceDivider) + font-size 12px + border-bottom solid var(--lineWidth) var(--faceDivider) &:last-child border-bottom none @@ -262,9 +290,16 @@ export default Vue.extend({ .note-preview color var(--noteText) + display inline-block + word-break break-word .note-ref color var(--noteText) + display inline-block + width: 100% + overflow hidden + white-space nowrap + text-overflow ellipsis [data-icon] font-size 1em @@ -297,7 +332,7 @@ export default Vue.extend({ font-size 0.8em color var(--dateDividerFg) background var(--dateDividerBg) - border-bottom solid 1px var(--faceDivider) + border-bottom solid var(--lineWidth) var(--faceDivider) span margin 0 16px @@ -310,7 +345,7 @@ export default Vue.extend({ width 100% padding 16px color var(--text) - border-top solid 1px rgba(#000, 0.05) + border-top solid var(--lineWidth) rgba(#000, 0.05) &:hover background rgba(#000, 0.025) diff --git a/src/client/app/desktop/views/components/post-form-window.vue b/src/client/app/desktop/views/components/post-form-window.vue index cebf480bf1..1ed88ec1d9 100644 --- a/src/client/app/desktop/views/components/post-form-window.vue +++ b/src/client/app/desktop/views/components/post-form-window.vue @@ -12,6 +12,7 @@ <mk-note-preview v-if="reply" class="notePreview" :note="reply"/> <mk-post-form ref="form" :reply="reply" + :mention="mention" @posted="onPosted" @change-uploadings="onChangeUploadings" @change-attached-files="onChangeFiles" @@ -32,6 +33,10 @@ export default Vue.extend({ type: Object, required: false }, + mention: { + type: Object, + required: false + }, animation: { type: Boolean, diff --git a/src/client/app/desktop/views/components/post-form.vue b/src/client/app/desktop/views/components/post-form.vue index 128470a0d6..9c2807663b 100644 --- a/src/client/app/desktop/views/components/post-form.vue +++ b/src/client/app/desktop/views/components/post-form.vue @@ -7,20 +7,22 @@ > <div class="content"> <div v-if="visibility == 'specified'" class="visibleUsers"> - <span v-for="u in visibleUsers">{{ u | userName }}<a @click="removeVisibleUser(u)">[x]</a></span> + <span v-for="u in visibleUsers"> + <mk-user-name :user="u"/><a @click="removeVisibleUser(u)">[x]</a> + </span> <a @click="addVisibleUser">{{ $t('add-visible-user') }}</a> </div> <div class="hashtags" v-if="recentHashtags.length > 0 && $store.state.settings.suggestRecentHashtags"> <b>{{ $t('recent-tags') }}:</b> <a v-for="tag in recentHashtags.slice(0, 5)" @click="addTag(tag)" :title="$t('click-to-tagging')">#{{ tag }}</a> </div> - <div class="local-only" v-if="this.localOnly == true">{{ $t('local-only-message') }}</div> - <input v-show="useCw" v-model="cw" :placeholder="$t('annotations')"> + <div class="local-only" v-if="localOnly == true">{{ $t('local-only-message') }}</div> + <input v-show="useCw" ref="cw" v-model="cw" :placeholder="$t('annotations')" v-autocomplete="{ model: 'cw' }"> <div class="textarea"> <textarea :class="{ with: (files.length != 0 || poll) }" ref="text" v-model="text" :disabled="posting" @keydown="onKeydown" @paste="onPaste" :placeholder="placeholder" - v-autocomplete="'text'" + v-autocomplete="{ model: 'text' }" ></textarea> <button class="emoji" @click="emoji" ref="emoji"> <fa :icon="['far', 'laugh']"/> @@ -34,7 +36,7 @@ </x-draggable> <p class="remain">{{ 4 - files.length }}/4</p> </div> - <mk-poll-editor v-if="poll" ref="poll" @destroyed="poll = false" @updated="saveDraft()"/> + <mk-poll-editor v-if="poll" ref="poll" @destroyed="poll = false" @updated="onPollUpdate()"/> </div> </div> <mk-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/> @@ -49,12 +51,11 @@ <span v-if="visibility === 'home'"><fa icon="home"/></span> <span v-if="visibility === 'followers'"><fa icon="unlock"/></span> <span v-if="visibility === 'specified'"><fa icon="envelope"/></span> - <span v-if="visibility === 'private'"><fa icon="lock"/></span> </button> <p class="text-count" :class="{ over: trimmedLength(text) > maxNoteTextLength }">{{ maxNoteTextLength - trimmedLength(text) }}</p> - <button :class="{ posting }" class="submit" :disabled="!canPost" @click="post"> + <ui-button primary :wait="posting" class="submit" :disabled="!canPost" @click="post"> {{ posting ? $t('posting') : submitText }}<mk-ellipsis v-if="posting"/> - </button> + </ui-button> <input ref="file" type="file" multiple="multiple" tabindex="-1" @change="onChangeFile"/> <div class="dropzone" v-if="draghover"></div> </div> @@ -71,11 +72,12 @@ import parse from '../../../../../mfm/parse'; import { host } from '../../../config'; import { erase, unique } from '../../../../../prelude/array'; import { length } from 'stringz'; -import parseAcct from '../../../../../misc/acct/parse'; import { toASCII } from 'punycode'; +import extractMentions from '../../../../../misc/extract-mentions'; export default Vue.extend({ i18n: i18n('desktop/views/components/post-form.vue'), + components: { XDraggable, MkVisibilityChooser @@ -90,6 +92,10 @@ export default Vue.extend({ type: Object, required: false }, + mention: { + type: Object, + required: false + }, initialText: { type: String, required: false @@ -108,6 +114,7 @@ export default Vue.extend({ files: [], uploadings: [], poll: false, + pollChoices: [], useCw: false, cw: null, geo: null, @@ -165,7 +172,8 @@ export default Vue.extend({ canPost(): boolean { return !this.posting && (1 <= this.text.length || 1 <= this.files.length || this.poll || this.renote) && - (length(this.text.trim()) <= this.maxNoteTextLength); + (length(this.text.trim()) <= this.maxNoteTextLength) && + (!this.poll || this.pollChoices.length >= 2); } }, @@ -174,6 +182,11 @@ export default Vue.extend({ this.text = this.initialText; } + if (this.mention) { + this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`; + this.text += ' '; + } + if (this.reply && this.reply.user.host != null) { this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `; } @@ -181,38 +194,37 @@ export default Vue.extend({ if (this.reply && this.reply.text != null) { const ast = parse(this.reply.text); - ast.filter(t => t.type == 'mention').forEach(x => { + for (const x of extractMentions(ast)) { const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`; // 自分は除外 - if (this.$store.state.i.username == x.username && x.host == null) return; - if (this.$store.state.i.username == x.username && x.host == host) return; + if (this.$store.state.i.username == x.username && x.host == null) continue; + if (this.$store.state.i.username == x.username && x.host == host) continue; // 重複は除外 - if (this.text.indexOf(`${mention} `) != -1) return; + if (this.text.indexOf(`${mention} `) != -1) continue; this.text += `${mention} `; - }); + } } // デフォルト公開範囲 this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility); // 公開以外へのリプライ時は元の公開範囲を引き継ぐ - if (this.reply && ['home', 'followers', 'specified', 'private'].includes(this.reply.visibility)) { + if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) { this.visibility = this.reply.visibility; } - // ダイレクトへのリプライはリプライ先ユーザーを初期設定 - if (this.reply && this.reply.visibility === 'specified') { - this.$root.api('users/show', { userId: this.reply.userId }).then(user => { + if (this.reply) { + this.$root.api('users/show', { userId: this.reply.userId }).then(user => { this.visibleUsers.push(user); }); } this.$nextTick(() => { // 書きかけの投稿を復元 - if (!this.instant) { + if (!this.instant && !this.mention) { const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId]; if (draft) { this.text = draft.data.text; @@ -232,7 +244,7 @@ export default Vue.extend({ }, methods: { - trimmedLength(text: string) { + trimmedLength(text: string) { return length(text.trim()); }, @@ -258,7 +270,7 @@ export default Vue.extend({ this.$chooseDriveFile({ multiple: true }).then(files => { - files.forEach(this.attachMedia); + for (const x of files) this.attachMedia(x); }); }, @@ -273,7 +285,12 @@ export default Vue.extend({ }, onChangeFile() { - Array.from((this.$refs.file as any).files).forEach(this.upload); + for (const x of Array.from((this.$refs.file as any).files)) this.upload(x); + }, + + onPollUpdate() { + this.pollChoices = this.$refs.poll.get().choices; + this.saveDraft(); }, upload(file) { @@ -296,11 +313,11 @@ export default Vue.extend({ }, onPaste(e) { - Array.from(e.clipboardData.items).forEach((item: any) => { + for (const item of Array.from(e.clipboardData.items)) { if (item.kind == 'file') { this.upload(item.getAsFile()); } - }); + } }, onDragover(e) { @@ -327,7 +344,7 @@ export default Vue.extend({ // ファイルだったら if (e.dataTransfer.files.length > 0) { e.preventDefault(); - Array.from(e.dataTransfer.files).forEach(this.upload); + for (const x of Array.from(e.dataTransfer.files)) this.upload(x); return; } @@ -365,7 +382,8 @@ export default Vue.extend({ setVisibility() { const w = this.$root.new(MkVisibilityChooser, { - source: this.$refs.visibilityButton + source: this.$refs.visibilityButton, + currentVisibility: this.visibility }); w.$once('chosen', v => { this.applyVisibility(v); @@ -384,13 +402,12 @@ export default Vue.extend({ }, addVisibleUser() { - this.$input({ - title: this.$t('enter-username') - }).then(acct => { - if (acct.startsWith('@')) acct = acct.substr(1); - this.$root.api('users/show', parseAcct(acct)).then(user => { - this.visibleUsers.push(user); - }); + this.$root.dialog({ + title: this.$t('enter-username'), + user: true + }).then(({ canceled, result: user }) => { + if (canceled) return; + this.visibleUsers.push(user); }); }, @@ -676,62 +693,8 @@ export default Vue.extend({ position absolute bottom 16px right 16px - cursor pointer - padding 0 - margin 0 width 110px height 40px - font-size 1em - color var(--primaryForeground) - background var(--primary) - outline none - border none - border-radius 4px - - &:not(:disabled) - font-weight bold - - &:hover:not(:disabled) - background var(--primaryLighten5) - - &:active:not(:disabled) - background var(--primaryDarken5) - - &:focus - &:after - content "" - pointer-events none - position absolute - top -5px - right -5px - bottom -5px - left -5px - border 2px solid var(--primaryAlpha03) - border-radius 8px - - &:disabled - opacity 0.7 - cursor default - - &.wait - background linear-gradient( - 45deg, - var(--primaryDarken10) 25%, - var(--primary) 25%, - var(--primary) 50%, - var(--primaryDarken10) 50%, - var(--primaryDarken10) 75%, - var(--primary) 75%, - var(--primary) - ) - background-size 32px 32px - animation stripe-bg 1.5s linear infinite - opacity 0.7 - cursor wait - - @keyframes stripe-bg - from {background-position: 0 0;} - to {background-position: -64px 32px;} > .text-count pointer-events none diff --git a/src/client/app/desktop/views/components/received-follow-requests-window.vue b/src/client/app/desktop/views/components/received-follow-requests-window.vue index cadf195d3f..c01a43a4f1 100644 --- a/src/client/app/desktop/views/components/received-follow-requests-window.vue +++ b/src/client/app/desktop/views/components/received-follow-requests-window.vue @@ -4,7 +4,9 @@ <div class="slpqaxdoxhvglersgjukmvizkqbmbokc"> <div v-for="req in requests"> - <router-link :key="req.id" :to="req.follower | userPage">{{ req.follower | userName }}</router-link> + <router-link :key="req.id" :to="req.follower | userPage"> + <mk-user-name :user="req.follower"/> + </router-link> <span> <a @click="accept(req.follower)">{{ $t('accept') }}</a>|<a @click="reject(req.follower)">{{ $t('reject') }}</a> </span> diff --git a/src/client/app/desktop/views/components/renote-form.vue b/src/client/app/desktop/views/components/renote-form.vue index e6a09c3eea..d8f38bedfc 100644 --- a/src/client/app/desktop/views/components/renote-form.vue +++ b/src/client/app/desktop/views/components/renote-form.vue @@ -21,7 +21,14 @@ import i18n from '../../../i18n'; export default Vue.extend({ i18n: i18n('desktop/views/components/renote-form.vue'), - props: ['note'], + + props: { + note: { + type: Object, + required: true + } + }, + data() { return { wait: false, @@ -29,6 +36,7 @@ export default Vue.extend({ visibility: this.$store.state.settings.defaultNoteVisibility }; }, + methods: { ok(v: string) { this.wait = true; @@ -44,9 +52,11 @@ export default Vue.extend({ this.wait = false; }); }, + cancel() { this.$emit('canceled'); }, + onQuote() { this.quote = true; @@ -54,6 +64,7 @@ export default Vue.extend({ (this.$refs.form as any).focus(); }); }, + onChildFormPosted() { this.$emit('posted'); } diff --git a/src/client/app/desktop/views/components/settings.2fa.vue b/src/client/app/desktop/views/components/settings.2fa.vue index e106038f03..636c3270ee 100644 --- a/src/client/app/desktop/views/components/settings.2fa.vue +++ b/src/client/app/desktop/views/components/settings.2fa.vue @@ -1,22 +1,22 @@ <template> <div class="2fa"> - <p style="margin-top:0;">{{ $t('intro') }}<a :href="$t('href')" target="_blank">{{ $t('detail') }}</a></p> + <p style="margin-top:0;">{{ $t('intro') }}<a :href="$t('url')" target="_blank">{{ $t('detail') }}</a></p> <ui-info warn>{{ $t('caution') }}</ui-info> <p v-if="!data && !$store.state.i.twoFactorEnabled"><ui-button @click="register">{{ $t('register') }}</ui-button></p> <template v-if="$store.state.i.twoFactorEnabled"> <p>{{ $t('already-registered') }}</p> <ui-button @click="unregister">{{ $t('unregister') }}</ui-button> </template> - <div v-if="data"> + <div v-if="data && !$store.state.i.twoFactorEnabled"> <ol> - <li>{{ $t('authenticator% <a href="https://support.google.com/accounts/answer/1066447" target="_blank">%i18n:@howtoinstall') }}</a></li> + <li>{{ $t('authenticator') }}<a href="https://support.google.com/accounts/answer/1066447" target="_blank">{{ $t('howtoinstall') }}</a></li> <li>{{ $t('scan') }}<br><img :src="data.qr"></li> <li>{{ $t('done') }}<br> - <input type="number" v-model="token" class="ui"> + <ui-input v-model="token">{{ $t('token') }}</ui-input> <ui-button primary @click="submit">{{ $t('submit') }}</ui-button> </li> </ol> - <div class="ui info"><p><fa icon="info-circle"/>{{ $t('info') }}</p></div> + <ui-info>{{ $t('info') }}</ui-info> </div> </div> </template> @@ -35,10 +35,13 @@ export default Vue.extend({ }, methods: { register() { - this.$input({ + this.$root.dialog({ title: this.$t('enter-password'), - type: 'password' - }).then(password => { + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; this.$root.api('i/2fa/register', { password: password }).then(data => { @@ -48,10 +51,13 @@ export default Vue.extend({ }, unregister() { - this.$input({ + this.$root.dialog({ title: this.$t('enter-password'), - type: 'password' - }).then(password => { + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; this.$root.api('i/2fa/unregister', { password: password }).then(() => { diff --git a/src/client/app/desktop/views/components/settings.tags.vue b/src/client/app/desktop/views/components/settings.tags.vue index 3edd5fdd99..3df4a6e64b 100644 --- a/src/client/app/desktop/views/components/settings.tags.vue +++ b/src/client/app/desktop/views/components/settings.tags.vue @@ -1,15 +1,15 @@ <template> <div class="vfcitkilproprqtbnpoertpsziierwzi"> - <div v-for="timeline in timelines" class="timeline"> + <div v-for="timeline in timelines" class="timeline" :key="timeline.id"> <ui-input v-model="timeline.title" @change="save"> <span>{{ $t('title') }}</span> </ui-input> - <ui-textarea :value="timeline.query ? timeline.query.map(tags => tags.join(' ')).join('\n') : ''" @input="onQueryChange(timeline, $event)"> + <ui-textarea :value="timeline.query ? timeline.query.map(tags => tags.join(' ')).join('\n') : ''" :pre="true" @input="onQueryChange(timeline, $event)"> <span>{{ $t('query') }}</span> </ui-textarea> - <ui-button class="save" @click="save">{{ $t('save') }}</ui-button> </div> <ui-button class="add" @click="add">{{ $t('add') }}</ui-button> + <ui-button class="save" @click="save">{{ $t('save') }}</ui-button> </div> </template> @@ -33,12 +33,19 @@ export default Vue.extend({ title: '', query: '' }); - - this.save(); }, save() { - this.$store.dispatch('settings/set', { key: 'tagTimelines', value: this.timelines }); + const timelines = this.timelines + .filter(timeline => timeline.title) + .map(timeline => { + if (!(timeline.query && timeline.query[0] && timeline.query[0][0])) { + timeline.query = timeline.title.split('\n').map(tags => tags.split(' ')); + } + return timeline; + }); + + this.$store.dispatch('settings/set', { key: 'tagTimelines', value: timelines }); }, onQueryChange(timeline, value) { diff --git a/src/client/app/desktop/views/components/settings.vue b/src/client/app/desktop/views/components/settings.vue index 62106768b5..bd17018a6f 100644 --- a/src/client/app/desktop/views/components/settings.vue +++ b/src/client/app/desktop/views/components/settings.vue @@ -1,6 +1,6 @@ <template> <div class="mk-settings"> - <div class="nav"> + <div class="nav" :class="{ inWindow }"> <p :class="{ active: page == 'profile' }" @mousedown="page = 'profile'"><fa icon="user" fixed-width/>{{ $t('profile') }}</p> <p :class="{ active: page == 'theme' }" @mousedown="page = 'theme'"><fa icon="palette" fixed-width/>{{ $t('theme') }}</p> <p :class="{ active: page == 'web' }" @mousedown="page = 'web'"><fa icon="desktop" fixed-width/>Web</p> @@ -16,36 +16,10 @@ <div class="pages"> <div class="profile" v-show="page == 'profile'"> <x-profile-editor/> - - <ui-card> - <div slot="title"><fa :icon="['fab', 'twitter']"/> {{ $t('twitter') }}</div> - <section> - <x-twitter-setting/> - </section> - </ui-card> - - <ui-card> - <div slot="title"><fa :icon="['fab', 'github']"/> {{ $t('github') }}</div> - <section> - <x-github-setting/> - </section> - </ui-card> - - <ui-card> - <div slot="title"><fa :icon="['fab', 'discord']"/> {{ $t('discord') }}</div> - <section> - <x-discord-setting/> - </section> - </ui-card> + <x-integration-settings/> </div> - <ui-card class="theme" v-show="page == 'theme'"> - <div slot="title"><fa icon="palette"/> {{ $t('theme') }}</div> - - <section> - <x-theme/> - </section> - </ui-card> + <x-theme class="theme" v-show="page == 'theme'"/> <ui-card class="web" v-show="page == 'web'"> <div slot="title"><fa icon="sliders-h"/> {{ $t('behaviour') }}</div> @@ -58,13 +32,6 @@ <span slot="desc">{{ $t('auto-popout-desc') }}</span> </ui-switch> <ui-switch v-model="deckNav">{{ $t('deck-nav') }}<span slot="desc">{{ $t('deck-nav-desc') }}</span></ui-switch> - - <details> - <summary>{{ $t('advanced') }}</summary> - <ui-switch v-model="apiViaStream">{{ $t('api-via-stream') }} - <span slot="desc">{{ $t('api-via-stream-desc') }}</span> - </ui-switch> - </details> </section> <section> @@ -84,13 +51,17 @@ <option value="home">{{ $t('@.note-visibility.home') }}</option> <option value="followers">{{ $t('@.note-visibility.followers') }}</option> <option value="specified">{{ $t('@.note-visibility.specified') }}</option> - <option value="private">{{ $t('@.note-visibility.private') }}</option> <option value="local-public">{{ $t('@.note-visibility.local-public') }}</option> <option value="local-home">{{ $t('@.note-visibility.local-home') }}</option> <option value="local-followers">{{ $t('@.note-visibility.local-followers') }}</option> </ui-select> </section> </section> + + <section> + <header>{{ $t('web-search-engine') }}</header> + <ui-input v-model="webSearchEngine">{{ $t('web-search-engine') }}<span slot="desc">{{ $t('web-search-engine-desc') }}</span></ui-input> + </section> </ui-card> <ui-card class="web" v-show="page == 'web'"> @@ -102,8 +73,10 @@ </section> <section> <header>{{ $t('wallpaper') }}</header> - <ui-button @click="updateWallpaper">{{ $t('choose-wallpaper') }}</ui-button> - <ui-button @click="deleteWallpaper">{{ $t('delete-wallpaper') }}</ui-button> + <ui-horizon-group class="fit-bottom"> + <ui-button @click="updateWallpaper">{{ $t('choose-wallpaper') }}</ui-button> + <ui-button @click="deleteWallpaper">{{ $t('delete-wallpaper') }}</ui-button> + </ui-horizon-group> </section> <section> <header>{{ $t('navbar-position') }}</header> @@ -119,6 +92,12 @@ <ui-switch v-model="useShadow">{{ $t('use-shadow') }}</ui-switch> <ui-switch v-model="roundedCorners">{{ $t('rounded-corners') }}</ui-switch> <ui-switch v-model="circleIcons">{{ $t('circle-icons') }}</ui-switch> + <section> + <header>{{ $t('@.line-width') }}</header> + <ui-radio v-model="lineWidth" :value="0.5">{{ $t('@.line-width-thin') }}</ui-radio> + <ui-radio v-model="lineWidth" :value="1">{{ $t('@.line-width-normal') }}</ui-radio> + <ui-radio v-model="lineWidth" :value="2">{{ $t('@.line-width-thick') }}</ui-radio> + </section> <ui-switch v-model="reduceMotion">{{ $t('@.reduce-motion') }}</ui-switch> <ui-switch v-model="contrastedAcct">{{ $t('contrasted-acct') }}</ui-switch> <ui-switch v-model="showFullAcct">{{ $t('@.show-full-acct') }}</ui-switch> @@ -127,7 +106,7 @@ <ui-switch v-model="iLikeSushi">{{ $t('@.i-like-sushi') }}</ui-switch> </section> <section> - <ui-switch v-model="suggestRecentHashtags">{{ $t('suggest-recent-hashtags') }}</ui-switch> + <ui-switch v-model="suggestRecentHashtags">{{ $t('@.suggest-recent-hashtags') }}</ui-switch> <ui-switch v-model="showClockOnHeader">{{ $t('show-clock-on-header') }}</ui-switch> <ui-switch v-model="alwaysShowNsfw">{{ $t('@.always-show-nsfw') }}</ui-switch> <ui-switch v-model="showReplyTarget">{{ $t('show-reply-target') }}</ui-switch> @@ -139,10 +118,19 @@ <header>{{ $t('deck-column-align') }}</header> <ui-radio v-model="deckColumnAlign" value="center">{{ $t('deck-column-align-center') }}</ui-radio> <ui-radio v-model="deckColumnAlign" value="left">{{ $t('deck-column-align-left') }}</ui-radio> + <ui-radio v-model="deckColumnAlign" value="flexible">{{ $t('deck-column-align-flexible') }}</ui-radio> + </section> + <section> + <header>{{ $t('deck-column-width') }}</header> + <ui-radio v-model="deckColumnWidth" value="narrow">{{ $t('deck-column-width-narrow') }}</ui-radio> + <ui-radio v-model="deckColumnWidth" value="narrower">{{ $t('deck-column-width-narrower') }}</ui-radio> + <ui-radio v-model="deckColumnWidth" value="normal">{{ $t('deck-column-width-normal') }}</ui-radio> + <ui-radio v-model="deckColumnWidth" value="wider">{{ $t('deck-column-width-wider') }}</ui-radio> + <ui-radio v-model="deckColumnWidth" value="wide">{{ $t('deck-column-width-wide') }}</ui-radio> </section> <section> <ui-switch v-model="games_reversi_showBoardLabels">{{ $t('@.show-reversi-board-labels') }}</ui-switch> - <ui-switch v-model="games_reversi_useContrastStones">{{ $t('@.use-contrast-reversi-stones') }}</ui-switch> + <ui-switch v-model="games_reversi_useAvatarStones">{{ $t('@.use-avatar-reversi-stones') }}</ui-switch> </section> </ui-card> @@ -164,23 +152,7 @@ </section> </ui-card> - <ui-card class="web" v-show="page == 'web'"> - <div slot="title"><fa icon="language"/> {{ $t('language') }}</div> - <section class="fit-top"> - <ui-select v-model="lang" :placeholder="$t('pick-language')"> - <optgroup :label="$t('recommended')"> - <option value="">{{ $t('auto') }}</option> - </optgroup> - - <optgroup :label="$t('specify-language')"> - <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> - </optgroup> - </ui-select> - <div class="none ui info"> - <p><fa icon="info-circle"/>{{ $t('language-desc') }}</p> - </div> - </section> - </ui-card> + <x-language-settings v-show="page == 'web'"/> <ui-card class="web" v-show="page == 'web'"> <div slot="title"><fa :icon="['far', 'trash-alt']"/> {{ $t('cache') }}</div> @@ -192,17 +164,7 @@ </section> </ui-card> - <ui-card class="notification" v-show="page == 'notification'"> - <div slot="title"><fa :icon="['far', 'bell']"/> {{ $t('notification') }}</div> - <section> - <ui-switch v-model="$store.state.i.settings.autoWatch" @change="onChangeAutoWatch"> - {{ $t('auto-watch') }}<span slot="desc">{{ $t('auto-watch-desc') }}</span> - </ui-switch> - <section> - <ui-button @click="readAllUnreadNotes">{{ $t('mark-as-read-all-unread-notes') }}</ui-button> - </section> - </section> - </ui-card> + <x-notification-settings v-show="page == 'notification'"/> <div class="drive" v-if="page == 'drive'"> <x-drive-settings/> @@ -234,7 +196,7 @@ </ui-card> <ui-card class="2fa" v-show="page == 'security'"> - <div slot="title"><fa icon="mobile-alt"/> {{ $t('2fa') }}</div> + <div slot="title"><fa icon="mobile-alt"/> {{ $t('@.2fa') }}</div> <section> <x-2fa/> </section> @@ -303,17 +265,17 @@ import X2fa from './settings.2fa.vue'; import XApps from './settings.apps.vue'; import XSignins from './settings.signins.vue'; import XTags from './settings.tags.vue'; -import XTwitterSetting from '../../../common/views/components/twitter-setting.vue'; -import XGithubSetting from '../../../common/views/components/github-setting.vue'; -import XDiscordSetting from '../../../common/views/components/discord-setting.vue'; +import XIntegrationSettings from '../../../common/views/components/integration-settings.vue'; import XTheme from '../../../common/views/components/theme.vue'; import XDriveSettings from '../../../common/views/components/drive-settings.vue'; import XMuteAndBlock from '../../../common/views/components/mute-and-block.vue'; import XPasswordSettings from '../../../common/views/components/password-settings.vue'; import XProfileEditor from '../../../common/views/components/profile-editor.vue'; import XApiSettings from '../../../common/views/components/api-settings.vue'; +import XLanguageSettings from '../../../common/views/components/language-settings.vue'; +import XNotificationSettings from '../../../common/views/components/notification-settings.vue'; -import { url, langs, clientVersion as version } from '../../../config'; +import { url, clientVersion as version } from '../../../config'; import checkForUpdate from '../../../common/scripts/check-for-update'; export default Vue.extend({ @@ -323,20 +285,25 @@ export default Vue.extend({ XApps, XSignins, XTags, - XTwitterSetting, - XGithubSetting, - XDiscordSetting, + XIntegrationSettings, XTheme, XDriveSettings, XMuteAndBlock, XPasswordSettings, XProfileEditor, XApiSettings, + XLanguageSettings, + XNotificationSettings, }, props: { initialPage: { type: String, required: false + }, + inWindow: { + type: Boolean, + required: false, + default: true } }, data() { @@ -344,7 +311,6 @@ export default Vue.extend({ page: this.initialPage || 'profile', meta: null, version, - langs, latestVersion: undefined, checkingForUpdate: false }; @@ -360,11 +326,6 @@ export default Vue.extend({ set(value) { this.$store.commit('device/set', { key: 'reduceMotion', value }); } }, - apiViaStream: { - get() { return this.$store.state.device.apiViaStream; }, - set(value) { this.$store.commit('device/set', { key: 'apiViaStream', value }); } - }, - autoPopout: { get() { return this.$store.state.device.autoPopout; }, set(value) { this.$store.commit('device/set', { key: 'autoPopout', value }); } @@ -390,6 +351,11 @@ export default Vue.extend({ set(value) { this.$store.commit('device/set', { key: 'deckColumnAlign', value }); } }, + deckColumnWidth: { + get() { return this.$store.state.device.deckColumnWidth; }, + set(value) { this.$store.commit('device/set', { key: 'deckColumnWidth', value }); } + }, + deckDefault: { get() { return this.$store.state.device.deckDefault; }, set(value) { this.$store.commit('device/set', { key: 'deckDefault', value }); } @@ -405,11 +371,6 @@ export default Vue.extend({ set(value) { this.$store.commit('device/set', { key: 'soundVolume', value }); } }, - lang: { - get() { return this.$store.state.device.lang; }, - set(value) { this.$store.commit('device/set', { key: 'lang', value }); } - }, - preventUpdate: { get() { return this.$store.state.device.preventUpdate; }, set(value) { this.$store.commit('device/set', { key: 'preventUpdate', value }); } @@ -440,6 +401,11 @@ export default Vue.extend({ set(value) { this.$store.dispatch('settings/set', { key: 'roundedCorners', value }); } }, + lineWidth: { + get() { return this.$store.state.device.lineWidth; }, + set(value) { this.$store.commit('device/set', { key: 'lineWidth', value }); } + }, + fetchOnScroll: { get() { return this.$store.state.settings.fetchOnScroll; }, set(value) { this.$store.dispatch('settings/set', { key: 'fetchOnScroll', value }); } @@ -455,6 +421,11 @@ export default Vue.extend({ set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); } }, + webSearchEngine: { + get() { return this.$store.state.settings.webSearchEngine; }, + set(value) { this.$store.dispatch('settings/set', { key: 'webSearchEngine', value }); } + }, + showReplyTarget: { get() { return this.$store.state.settings.showReplyTarget; }, set(value) { this.$store.dispatch('settings/set', { key: 'showReplyTarget', value }); } @@ -525,9 +496,9 @@ export default Vue.extend({ set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.showBoardLabels', value }); } }, - games_reversi_useContrastStones: { - get() { return this.$store.state.settings.games.reversi.useContrastStones; }, - set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.useContrastStones', value }); } + games_reversi_useAvatarStones: { + get() { return this.$store.state.settings.games.reversi.useAvatarStones; }, + set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.useAvatarStones', value }); } }, disableAnimatedMfm: { @@ -546,9 +517,6 @@ export default Vue.extend({ }); }, methods: { - readAllUnreadNotes() { - this.$root.api('i/read_all_unread_notes'); - }, customizeHome() { this.$router.push('/i/customize-home'); this.$emit('done'); @@ -567,23 +535,18 @@ export default Vue.extend({ wallpaperId: null }); }, - onChangeAutoWatch(v) { - this.$root.api('i/update', { - autoWatch: v - }); - }, checkForUpdate() { this.checkingForUpdate = true; checkForUpdate(this.$root, true, true).then(newer => { this.checkingForUpdate = false; this.latestVersion = newer; if (newer == null) { - this.$root.alert({ + this.$root.dialog({ title: this.$t('no-updates'), text: this.$t('no-updates-desc') }); } else { - this.$root.alert({ + this.$root.dialog({ title: this.$t('update-available'), text: this.$t('update-available-desc') }); @@ -592,7 +555,7 @@ export default Vue.extend({ }, clean() { localStorage.clear(); - this.$root.alert({ + this.$root.dialog({ title: this.$t('cache-cleared'), text: this.$t('cache-cleared-desc') }); @@ -618,9 +581,11 @@ export default Vue.extend({ height 100% padding 16px 0 0 0 overflow auto - box-shadow var(--shadowRight) z-index 1 + &.inWindow + box-shadow var(--shadowRight) + > p display block padding 10px 16px diff --git a/src/client/app/desktop/views/components/sub-note-content.vue b/src/client/app/desktop/views/components/sub-note-content.vue index 0007520e99..78f9a6034b 100644 --- a/src/client/app/desktop/views/components/sub-note-content.vue +++ b/src/client/app/desktop/views/components/sub-note-content.vue @@ -4,7 +4,7 @@ <span v-if="note.isHidden" style="opacity: 0.5">{{ $t('private') }}</span> <span v-if="note.deletedAt" style="opacity: 0.5">{{ $t('deleted') }}</span> <a class="reply" v-if="note.replyId"><fa icon="reply"/></a> - <misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i" :custom-emojis="note.emojis"/> + <mfm v-if="note.text" :text="note.text" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis"/> <a class="rp" v-if="note.renoteId" :href="`/notes/${note.renoteId}`">RN: ...</a> </div> <details v-if="note.files.length > 0"> diff --git a/src/client/app/desktop/views/components/timeline.core.vue b/src/client/app/desktop/views/components/timeline.core.vue index f22ab49000..63bc20dc28 100644 --- a/src/client/app/desktop/views/components/timeline.core.vue +++ b/src/client/app/desktop/views/components/timeline.core.vue @@ -149,7 +149,9 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) { + (this.$refs.timeline as any).append(n); + } this.moreFetching = false; }); @@ -171,7 +173,7 @@ export default Vue.extend({ <style lang="stylus" scoped> .mk-timeline-core > .mk-friends-maker - border-bottom solid 1px #eee + border-bottom solid var(--lineWidth) #eee </style> diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue index 410b4e25f7..edeb30d9cd 100644 --- a/src/client/app/desktop/views/components/timeline.vue +++ b/src/client/app/desktop/views/components/timeline.vue @@ -4,7 +4,7 @@ <span :data-active="src == 'home'" @click="src = 'home'"><fa icon="home"/> {{ $t('home') }}</span> <span :data-active="src == 'local'" @click="src = 'local'" v-if="enableLocalTimeline"><fa :icon="['far', 'comments']"/> {{ $t('local') }}</span> <span :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline"><fa icon="share-alt"/> {{ $t('hybrid') }}</span> - <span :data-active="src == 'global'" @click="src = 'global'"><fa icon="globe"/> {{ $t('global') }}</span> + <span :data-active="src == 'global'" @click="src = 'global'" v-if="enableGlobalTimeline"><fa icon="globe"/> {{ $t('global') }}</span> <span :data-active="src == 'tag'" @click="src = 'tag'" v-if="tagTl"><fa icon="hashtag"/> {{ tagTl.title }}</span> <span :data-active="src == 'list'" @click="src = 'list'" v-if="list"><fa icon="list"/> {{ list.title }}</span> <div class="buttons"> @@ -43,7 +43,8 @@ export default Vue.extend({ src: 'home', list: null, tagTl: null, - enableLocalTimeline: false + enableLocalTimeline: false, + enableGlobalTimeline: false, }; }, @@ -65,7 +66,8 @@ export default Vue.extend({ created() { this.$root.getMeta().then(meta => { - this.enableLocalTimeline = !meta.disableLocalTimeline; + this.enableLocalTimeline = !meta.disableLocalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin; + this.enableGlobalTimeline = !meta.disableGlobalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin; }); if (this.$store.state.device.tl) { @@ -109,9 +111,11 @@ export default Vue.extend({ icon: 'plus', text: this.$t('add-list'), action: () => { - this.$input({ + this.$root.dialog({ title: this.$t('list-name'), - }).then(async title => { + input: true + }).then(async ({ canceled, result: title }) => { + if (canceled) return; const list = await this.$root.api('users/lists/create', { title }); @@ -137,7 +141,6 @@ export default Vue.extend({ this.$root.new(Menu, { source: this.$refs.listButton, - compact: false, items: menu }); }, @@ -168,7 +171,6 @@ export default Vue.extend({ this.$root.new(Menu, { source: this.$refs.tagButton, - compact: false, items: menu }); } @@ -187,7 +189,7 @@ export default Vue.extend({ padding 0 8px z-index 10 background var(--faceHeader) - box-shadow 0 1px var(--desktopTimelineHeaderShadow) + box-shadow 0 var(--lineWidth) var(--desktopTimelineHeaderShadow) > .buttons position absolute @@ -207,7 +209,7 @@ export default Vue.extend({ top -4px right 4px font-size 10px - color var(--primary) + color var(--notificationIndicator) &:hover color var(--faceTextButtonHover) diff --git a/src/client/app/desktop/views/components/ui-notification.vue b/src/client/app/desktop/views/components/ui-notification.vue index dafede4c36..10611fdf02 100644 --- a/src/client/app/desktop/views/components/ui-notification.vue +++ b/src/client/app/desktop/views/components/ui-notification.vue @@ -6,7 +6,7 @@ <script lang="ts"> import Vue from 'vue'; -import * as anime from 'animejs'; +import anime from 'animejs'; export default Vue.extend({ props: ['message'], diff --git a/src/client/app/desktop/views/components/ui.header.account.vue b/src/client/app/desktop/views/components/ui.header.account.vue index a16f164556..bc7a8b2231 100644 --- a/src/client/app/desktop/views/components/ui.header.account.vue +++ b/src/client/app/desktop/views/components/ui.header.account.vue @@ -51,12 +51,12 @@ <i><fa icon="angle-right"/></i> </router-link> </li> - <li @click="settings"> - <p> + <li> + <router-link to="/i/settings"> <i><fa icon="cog"/></i> <span>{{ $t('settings') }}</span> <i><fa icon="angle-right"/></i> - </p> + </router-link> </li> <li v-if="$store.state.i.isAdmin || $store.state.i.isModerator"> <a href="/admin"> @@ -92,6 +92,7 @@ import Vue from 'vue'; import i18n from '../../../i18n'; import MkUserListsWindow from './user-lists-window.vue'; +import MkUserListWindow from './user-list-window.vue'; import MkFollowRequestsWindow from './received-follow-requests-window.vue'; import MkSettingsWindow from './settings-window.vue'; import MkDriveWindow from './drive-window.vue'; @@ -120,15 +121,15 @@ export default Vue.extend({ }, open() { this.isOpen = true; - Array.from(document.querySelectorAll('body *')).forEach(el => { + for (const el of Array.from(document.querySelectorAll('body *'))) { el.addEventListener('mousedown', this.onMousedown); - }); + } }, close() { this.isOpen = false; - Array.from(document.querySelectorAll('body *')).forEach(el => { + for (const el of Array.from(document.querySelectorAll('body *'))) { el.removeEventListener('mousedown', this.onMousedown); - }); + } }, onMousedown(e) { e.preventDefault(); @@ -143,17 +144,15 @@ export default Vue.extend({ this.close(); const w = this.$root.new(MkUserListsWindow); w.$once('choosen', list => { - this.$router.push(`i/lists/${ list.id }`); + this.$root.new(MkUserListWindow, { + list + }); }); }, followRequests() { this.close(); this.$root.new(MkFollowRequestsWindow); }, - settings() { - this.close(); - this.$root.new(MkSettingsWindow); - }, signout() { this.$root.signout(); }, @@ -228,7 +227,7 @@ export default Vue.extend({ font-size 0.8em background $bgcolor border-radius 4px - box-shadow 0 1px 4px rgba(#000, 0.25) + box-shadow 0 var(--lineWidth) 4px rgba(#000, 0.25) &:before content "" @@ -262,7 +261,7 @@ export default Vue.extend({ & + ul padding-top 10px - border-top solid 1px var(--faceDivider) + border-top solid var(--lineWidth) var(--faceDivider) > li display block diff --git a/src/client/app/desktop/views/components/ui.header.nav.vue b/src/client/app/desktop/views/components/ui.header.nav.vue index aed0c89be0..8e78829de3 100644 --- a/src/client/app/desktop/views/components/ui.header.nav.vue +++ b/src/client/app/desktop/views/components/ui.header.nav.vue @@ -61,7 +61,7 @@ export default Vue.extend({ this.connection = this.$root.stream.useSharedConnection('main'); this.connection.on('reversiInvited', this.onReversiInvited); - this.connection.on('reversi_no_invites', this.onReversiNoInvites); + this.connection.on('reversiNoInvites', this.onReversiNoInvites); } }, beforeDestroy() { @@ -147,7 +147,7 @@ export default Vue.extend({ > [data-icon]:last-child margin-left 5px font-size 10px - color var(--primary) + color var(--notificationIndicator) @media (max-width 1100px) margin-left -5px diff --git a/src/client/app/desktop/views/components/ui.header.notifications.vue b/src/client/app/desktop/views/components/ui.header.notifications.vue index b83a5da04c..a02078e4ec 100644 --- a/src/client/app/desktop/views/components/ui.header.notifications.vue +++ b/src/client/app/desktop/views/components/ui.header.notifications.vue @@ -42,16 +42,16 @@ export default Vue.extend({ open() { this.isOpen = true; - Array.from(document.querySelectorAll('body *')).forEach(el => { + for (const el of Array.from(document.querySelectorAll('body *'))) { el.addEventListener('mousedown', this.onMousedown); - }); + } }, close() { this.isOpen = false; - Array.from(document.querySelectorAll('body *')).forEach(el => { + for (const el of Array.from(document.querySelectorAll('body *'))) { el.removeEventListener('mousedown', this.onMousedown); - }); + } }, onMousedown(e) { @@ -90,7 +90,7 @@ export default Vue.extend({ margin-left -5px vertical-align super font-size 10px - color var(--primary) + color var(--notificationIndicator) > .pop $bgcolor = var(--face) diff --git a/src/client/app/desktop/views/components/ui.header.search.vue b/src/client/app/desktop/views/components/ui.header.search.vue index ede9f9da0f..4ade74bc64 100644 --- a/src/client/app/desktop/views/components/ui.header.search.vue +++ b/src/client/app/desktop/views/components/ui.header.search.vue @@ -1,7 +1,7 @@ <template> -<form class="search" @submit.prevent="onSubmit"> +<form class="wlvfdpkp" @submit.prevent="onSubmit"> <i><fa icon="search"/></i> - <input v-model="q" type="search" :placeholder="$t('placeholder')"/> + <input v-model="q" type="search" :placeholder="$t('placeholder')" v-autocomplete="{ model: 'q' }"/> <div class="result"></div> </form> </template> @@ -14,15 +14,36 @@ export default Vue.extend({ i18n: i18n('desktop/views/components/ui.header.search.vue'), data() { return { - q: '' + q: '', + wait: false }; }, methods: { - onSubmit() { - if (this.q.startsWith('#')) { - this.$router.push(`/tags/${encodeURIComponent(this.q.substr(1))}`); + async onSubmit() { + if (this.wait) return; + + const q = this.q.trim(); + if (q.startsWith('@')) { + this.$router.push(`/${q}`); + } else if (q.startsWith('#')) { + this.$router.push(`/tags/${encodeURIComponent(q.substr(1))}`); + } else if (q.startsWith('https://')) { + this.wait = true; + try { + const res = await this.$root.api('ap/show', { + uri: q + }); + if (res.type == 'User') { + this.$router.push(`/@${res.object.username}@${res.object.host}`); + } else if (res.type == 'Note') { + this.$router.push(`/notes/${res.object.id}`); + } + } catch (e) { + // TODO + } + this.wait = false; } else { - this.$router.push(`/search?q=${encodeURIComponent(this.q)}`); + this.$router.push(`/search?q=${encodeURIComponent(q)}`); } } } @@ -30,7 +51,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.search +.wlvfdpkp @media (max-width 800px) display none !important diff --git a/src/client/app/desktop/views/components/ui.header.vue b/src/client/app/desktop/views/components/ui.header.vue index 1c89a3ea81..37f97a73a4 100644 --- a/src/client/app/desktop/views/components/ui.header.vue +++ b/src/client/app/desktop/views/components/ui.header.vue @@ -29,7 +29,6 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import * as anime from 'animejs'; import { env } from '../../../config'; import XNav from './ui.header.nav.vue'; diff --git a/src/client/app/desktop/views/components/ui.sidebar.vue b/src/client/app/desktop/views/components/ui.sidebar.vue index 5b46460114..8b5f14290c 100644 --- a/src/client/app/desktop/views/components/ui.sidebar.vue +++ b/src/client/app/desktop/views/components/ui.sidebar.vue @@ -111,7 +111,7 @@ export default Vue.extend({ this.connection = this.$root.stream.useSharedConnection('main'); this.connection.on('reversiInvited', this.onReversiInvited); - this.connection.on('reversi_no_invites', this.onReversiNoInvites); + this.connection.on('reversiNoInvites', this.onReversiNoInvites); } }, @@ -171,16 +171,16 @@ export default Vue.extend({ openNotifications() { this.showNotifications = true; - Array.from(document.querySelectorAll('body *')).forEach(el => { + for (const el of Array.from(document.querySelectorAll('body *'))) { el.addEventListener('mousedown', this.onMousedown); - }); + } }, closeNotifications() { this.showNotifications = false; - Array.from(document.querySelectorAll('body *')).forEach(el => { + for (const el of Array.from(document.querySelectorAll('body *'))) { el.removeEventListener('mousedown', this.onMousedown); - }); + } }, onMousedown(e) { diff --git a/src/client/app/desktop/views/components/user-card.vue b/src/client/app/desktop/views/components/user-card.vue index 54fa15a190..049cb36f2d 100644 --- a/src/client/app/desktop/views/components/user-card.vue +++ b/src/client/app/desktop/views/components/user-card.vue @@ -4,10 +4,13 @@ <mk-avatar class="avatar" :user="user" :disable-preview="true"/> <mk-follow-button :user="user" class="follow" mini/> <div class="body"> - <router-link :to="user | userPage" class="name">{{ user | userName }}</router-link> - <span class="username">@{{ user | acct }}</span> + <router-link :to="user | userPage" class="name"> + <mk-user-name :user="user"/> + </router-link> + <span class="username">@{{ user | acct }} <fa v-if="user.isLocked == true" class="locked" icon="lock" fixed-width/></span> + <div class="description"> - <misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/> + <mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> </div> </div> </div> @@ -73,6 +76,9 @@ export default Vue.extend({ display block opacity 0.7 + > .locked + opacity 0.8 + > .description margin 8px 0 16px 0 diff --git a/src/client/app/desktop/views/components/user-list-timeline.vue b/src/client/app/desktop/views/components/user-list-timeline.vue index 7bdc016c6c..8afd95a68e 100644 --- a/src/client/app/desktop/views/components/user-list-timeline.vue +++ b/src/client/app/desktop/views/components/user-list-timeline.vue @@ -79,7 +79,7 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) (this.$refs.timeline as any).append(n); this.moreFetching = false; }); diff --git a/src/client/app/desktop/views/components/user-list-window.vue b/src/client/app/desktop/views/components/user-list-window.vue new file mode 100644 index 0000000000..054a133a4c --- /dev/null +++ b/src/client/app/desktop/views/components/user-list-window.vue @@ -0,0 +1,24 @@ +<template> +<mk-window ref="window" width="450px" height="500px" @closed="destroyDom"> + <span slot="header"><fa icon="list"/> {{ list.title }}</span> + + <x-editor :list="list"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XEditor from '../../../common/views/components/user-list-editor.vue'; + +export default Vue.extend({ + components: { + XEditor + }, + + props: { + list: { + required: true + } + } +}); +</script> diff --git a/src/client/app/desktop/views/components/user-lists-window.vue b/src/client/app/desktop/views/components/user-lists-window.vue index 8c52ea4af2..4ecbc760e5 100644 --- a/src/client/app/desktop/views/components/user-lists-window.vue +++ b/src/client/app/desktop/views/components/user-lists-window.vue @@ -1,5 +1,5 @@ <template> -<mk-window ref="window" is-modal width="450px" height="500px" @closed="destroyDom"> +<mk-window ref="window" width="450px" height="500px" @closed="destroyDom"> <span slot="header"><fa icon="list"/> {{ $t('title') }}</span> <div class="xkxvokkjlptzyewouewmceqcxhpgzprp"> @@ -29,9 +29,11 @@ export default Vue.extend({ }, methods: { add() { - this.$input({ + this.$root.dialog({ title: this.$t('list-name'), - }).then(async title => { + input: true + }).then(async ({ canceled, result: title }) => { + if (canceled) return; const list = await this.$root.api('users/lists/create', { title }); @@ -54,7 +56,25 @@ export default Vue.extend({ padding 16px > button + display block margin-bottom 16px + color var(--primaryForeground) + background var(--primary) + width 100% + border-radius 38px + user-select none + cursor pointer + padding 0 16px + min-width 100px + line-height 38px + font-size 14px + font-weight 700 + + &:hover + background var(--primaryLighten10) + + &:active + background var(--primaryDarken10) > a display block diff --git a/src/client/app/desktop/views/components/user-preview.vue b/src/client/app/desktop/views/components/user-preview.vue index 64884f6f52..b8ca2e6966 100644 --- a/src/client/app/desktop/views/components/user-preview.vue +++ b/src/client/app/desktop/views/components/user-preview.vue @@ -4,10 +4,12 @@ <div class="banner" :style="u.bannerUrl ? `background-image: url(${u.bannerUrl})` : ''"></div> <mk-avatar class="avatar" :user="u" :disable-preview="true"/> <div class="title"> - <router-link class="name" :to="u | userPage">{{ u | userName }}</router-link> + <router-link class="name" :to="u | userPage"><mk-user-name :user="u"/></router-link> <p class="username"><mk-acct :user="u"/></p> </div> - <div class="description">{{ u.description }}</div> + <div class="description"> + <mfm v-if="u.description" :text="u.description" :author="u" :i="$store.state.i" :custom-emojis="u.emojis"/> + </div> <div class="status"> <div> <p>{{ $t('notes') }}</p><span>{{ u.notesCount }}</span> @@ -27,7 +29,7 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import * as anime from 'animejs'; +import anime from 'animejs'; import parseAcct from '../../../../../misc/acct/parse'; export default Vue.extend({ @@ -156,7 +158,7 @@ export default Vue.extend({ > .follow-button position absolute - top 92px + top 8px right 8px </style> diff --git a/src/client/app/desktop/views/components/widget-container.vue b/src/client/app/desktop/views/components/widget-container.vue index c14f5f5511..13a460c921 100644 --- a/src/client/app/desktop/views/components/widget-container.vue +++ b/src/client/app/desktop/views/components/widget-container.vue @@ -46,7 +46,7 @@ export default Vue.extend({ font-size 0.9em font-weight bold color var(--faceHeaderText) - box-shadow 0 1px rgba(#000, 0.07) + box-shadow 0 var(--lineWidth) rgba(#000, 0.07) > [data-icon] margin-right 6px diff --git a/src/client/app/desktop/views/components/window.vue b/src/client/app/desktop/views/components/window.vue index 1fac38842a..da5fe44ac9 100644 --- a/src/client/app/desktop/views/components/window.vue +++ b/src/client/app/desktop/views/components/window.vue @@ -37,7 +37,7 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import * as anime from 'animejs'; +import anime from 'animejs'; import contains from '../../../common/scripts/contains'; const minHeight = 40; @@ -196,7 +196,7 @@ export default Vue.extend({ opacity: 0, scale: 0.8, duration: this.animation ? 300 : 0, - easing: [0.5, -0.5, 1, 0.5] + easing: 'cubicBezier(0.5, -0.5, 1, 0.5)' }); setTimeout(() => { @@ -234,12 +234,12 @@ export default Vue.extend({ top() { let z = 0; - this.$root.os.windows.getAll().forEach(w => { - if (w == this) return; + const ws = Array.from(this.$root.os.windows.getAll()).filter(w => w != this); + for (const w of ws) { const m = w.$refs.main; const mz = Number(document.defaultView.getComputedStyle(m, null).zIndex); if (mz > z) z = mz; - }); + } if (z > 0) { (this.$refs.main as any).style.zIndex = z + 1; @@ -627,6 +627,7 @@ export default Vue.extend({ > .content height 100% + overflow auto &:not([flexible]) > .main > .body > .content diff --git a/src/client/app/desktop/views/pages/deck/deck.column.vue b/src/client/app/desktop/views/pages/deck/deck.column.vue index ead5ee2bdb..2acd2d0eda 100644 --- a/src/client/app/desktop/views/pages/deck/deck.column.vue +++ b/src/client/app/desktop/views/pages/deck/deck.column.vue @@ -167,11 +167,14 @@ export default Vue.extend({ icon: 'pencil-alt', text: this.$t('rename'), action: () => { - this.$input({ + this.$root.dialog({ title: this.$t('rename'), - default: this.name, - allowEmpty: false - }).then(name => { + input: { + default: this.name, + allowEmpty: false + } + }).then(({ canceled, result: name }) => { + if (canceled) return; this.$store.dispatch('settings/renameDeckColumn', { id: this.column.id, name }); }); } @@ -221,7 +224,9 @@ export default Vue.extend({ if (this.menu) { items.unshift(null); - this.menu.reverse().forEach(i => items.unshift(i)); + for (const i of this.menu.reverse()) { + items.unshift(i); + } } return items; @@ -235,7 +240,6 @@ export default Vue.extend({ showMenu() { this.$root.new(Menu, { source: this.$refs.menu, - compact: false, items: this.getMenu() }); }, @@ -315,8 +319,6 @@ export default Vue.extend({ .dnpfarvgbnfmyzbdquhhzyxcmstpdqzs $header-height = 42px - width 330px - min-width 330px height 100% background var(--face) border-radius var(--round) @@ -351,6 +353,7 @@ export default Vue.extend({ &:not(.isStacked).narrow width 285px min-width 285px + flex-grow 0 !important &.naked background var(--deckAcrylicColumnBg) @@ -370,7 +373,7 @@ export default Vue.extend({ font-size 14px color var(--faceHeaderText) background var(--faceHeader) - box-shadow 0 1px rgba(#000, 0.15) + box-shadow 0 var(--lineWidth) rgba(#000, 0.15) cursor pointer &, * diff --git a/src/client/app/desktop/views/pages/deck/deck.direct.vue b/src/client/app/desktop/views/pages/deck/deck.direct.vue index 2ceb82ca17..c6c2b99233 100644 --- a/src/client/app/desktop/views/pages/deck/deck.direct.vue +++ b/src/client/app/desktop/views/pages/deck/deck.direct.vue @@ -77,7 +77,9 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) { + (this.$refs.timeline as any).append(n); + } this.moreFetching = false; }); diff --git a/src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue b/src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue index 611bcce4e6..9a70733fda 100644 --- a/src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue +++ b/src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue @@ -102,7 +102,9 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) { + (this.$refs.timeline as any).append(n); + } this.moreFetching = false; }); diff --git a/src/client/app/desktop/views/pages/deck/deck.list-tl.vue b/src/client/app/desktop/views/pages/deck/deck.list-tl.vue index d65745f06f..68fbbb3ff9 100644 --- a/src/client/app/desktop/views/pages/deck/deck.list-tl.vue +++ b/src/client/app/desktop/views/pages/deck/deck.list-tl.vue @@ -104,7 +104,9 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) { + (this.$refs.timeline as any).append(n); + } this.moreFetching = false; }); diff --git a/src/client/app/desktop/views/pages/deck/deck.mentions.vue b/src/client/app/desktop/views/pages/deck/deck.mentions.vue index 9921b3ca04..5fcabde5d6 100644 --- a/src/client/app/desktop/views/pages/deck/deck.mentions.vue +++ b/src/client/app/desktop/views/pages/deck/deck.mentions.vue @@ -75,7 +75,9 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) { + (this.$refs.timeline as any).append(n); + } this.moreFetching = false; }); diff --git a/src/client/app/desktop/views/pages/deck/deck.note-column.vue b/src/client/app/desktop/views/pages/deck/deck.note-column.vue index 43db32c854..74da48bffc 100644 --- a/src/client/app/desktop/views/pages/deck/deck.note-column.vue +++ b/src/client/app/desktop/views/pages/deck/deck.note-column.vue @@ -1,7 +1,7 @@ <template> <x-column> <span slot="header"> - <fa :icon="['far', 'comment-alt']"/><span>{{ title }}</span> + <fa :icon="['far', 'comment-alt']"/><mk-user-name :user="note.user" v-if="note"/> </span> <div class="rvtscbadixhhbsczoorqoaygovdeecsx" v-if="note"> @@ -45,12 +45,6 @@ export default Vue.extend({ }; }, - computed: { - title(): string { - return this.note ? Vue.filter('userName')(this.note.user) : ''; - } - }, - created() { this.$root.api('notes/show', { noteId: this.noteId }).then(note => { this.note = note; diff --git a/src/client/app/desktop/views/pages/deck/deck.notes.vue b/src/client/app/desktop/views/pages/deck/deck.notes.vue index 6c2822bae9..54e01a0012 100644 --- a/src/client/app/desktop/views/pages/deck/deck.notes.vue +++ b/src/client/app/desktop/views/pages/deck/deck.notes.vue @@ -11,22 +11,21 @@ <mk-error v-if="!fetching && requestInitPromise != null" @retry="resolveInitPromise"/> <!-- トランジションを有効にするとなぜかメモリリークする --> - <!--<transition-group name="mk-notes" class="transition" ref="notes">--> - <div class="notes" ref="notes"> + <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="transition notes" ref="notes" tag="div"> <template v-for="(note, i) in _notes"> <x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)" :media-view="mediaView" + :compact="true" :mini="true"/> <p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date"> <span><fa icon="angle-up"/>{{ note._datetext }}</span> <span><fa icon="angle-down"/>{{ _notes[i + 1]._datetext }}</span> </p> </template> - </div> - <!--</transition-group>--> + </component> <footer v-if="more"> <button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> @@ -166,7 +165,9 @@ export default Vue.extend({ }, releaseQueue() { - this.queue.forEach(n => this.prepend(n, true)); + for (const n of this.queue) { + this.prepend(n, true); + } this.queue = []; }, @@ -214,7 +215,7 @@ export default Vue.extend({ text-align center color var(--dateDividerFg) background var(--dateDividerBg) - border-bottom solid 1px var(--faceDivider) + border-bottom solid var(--lineWidth) var(--faceDivider) span margin 0 16px @@ -231,7 +232,7 @@ export default Vue.extend({ text-align center color #ccc background var(--face) - border-top solid 1px var(--faceDivider) + border-top solid var(--lineWidth) var(--faceDivider) border-bottom-left-radius 6px border-bottom-right-radius 6px diff --git a/src/client/app/desktop/views/pages/deck/deck.notification.vue b/src/client/app/desktop/views/pages/deck/deck.notification.vue index 9418007f2b..c20fe87a4f 100644 --- a/src/client/app/desktop/views/pages/deck/deck.notification.vue +++ b/src/client/app/desktop/views/pages/deck/deck.notification.vue @@ -5,11 +5,14 @@ <div> <header> <mk-reaction-icon :reaction="notification.reaction"/> - <router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link> + <router-link :to="notification.user | userPage"> + <mk-user-name :user="notification.user"/> + </router-link> <mk-time :time="notification.createdAt"/> </header> - <router-link class="note-ref" :to="notification.note | notePage"> - <fa icon="quote-left"/>{{ getNoteSummary(notification.note) }} + <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <fa icon="quote-left"/> + <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> <fa icon="quote-right"/> </router-link> </div> @@ -20,11 +23,15 @@ <div> <header> <fa icon="retweet"/> - <router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link> + <router-link :to="notification.user | userPage"> + <mk-user-name :user="notification.user"/> + </router-link> <mk-time :time="notification.createdAt"/> </header> - <router-link class="note-ref" :to="notification.note | notePage"> - <fa icon="quote-left"/>{{ getNoteSummary(notification.note.renote) }}<fa icon="quote-right"/> + <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)"> + <fa icon="quote-left"/> + <mfm :text="getNoteSummary(notification.note.renote)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.renote.emojis"/> + <fa icon="quote-right"/> </router-link> </div> </div> @@ -34,7 +41,9 @@ <div> <header> <fa icon="user-plus"/> - <router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link> + <router-link :to="notification.user | userPage"> + <mk-user-name :user="notification.user"/> + </router-link> <mk-time :time="notification.createdAt"/> </header> </div> @@ -45,7 +54,9 @@ <div> <header> <fa icon="user-clock"/> - <router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link> + <router-link :to="notification.user | userPage"> + <mk-user-name :user="notification.user"/> + </router-link> <mk-time :time="notification.createdAt"/> </header> </div> @@ -56,11 +67,15 @@ <div> <header> <fa icon="chart-pie"/> - <router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link> + <router-link :to="notification.user | userPage"> + <mk-user-name :user="notification.user"/> + </router-link> <mk-time :time="notification.createdAt"/> </header> - <router-link class="note-ref" :to="notification.note | notePage"> - <fa icon="quote-left"/>{{ getNoteSummary(notification.note) }}<fa icon="quote-right"/> + <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <fa icon="quote-left"/> + <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + <fa icon="quote-right"/> </router-link> </div> </div> @@ -112,7 +127,7 @@ export default Vue.extend({ .dsfykdcjpuwfvpefwufddclpjhzktmpw > .notification padding 16px - font-size 13px + font-size 12px overflow-wrap break-word &:after @@ -150,6 +165,11 @@ export default Vue.extend({ > .note-ref color var(--noteText) + display inline-block + width: 100% + overflow hidden + white-space nowrap + text-overflow ellipsis [data-icon] font-size 1em diff --git a/src/client/app/desktop/views/pages/deck/deck.notifications.vue b/src/client/app/desktop/views/pages/deck/deck.notifications.vue index 430d7d9b2f..4eec65778b 100644 --- a/src/client/app/desktop/views/pages/deck/deck.notifications.vue +++ b/src/client/app/desktop/views/pages/deck/deck.notifications.vue @@ -7,7 +7,7 @@ </div> <!-- トランジションを有効にするとなぜかメモリリークする --> - <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications"> + <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications" tag="div"> <template v-for="(notification, i) in _notifications"> <x-notification class="notification" :notification="notification" :key="notification.id"/> <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'"> @@ -142,7 +142,9 @@ export default Vue.extend({ }, releaseQueue() { - this.queue.forEach(n => this.prepend(n)); + for (const n of this.queue) { + this.prepend(n); + } this.queue = []; }, @@ -175,7 +177,7 @@ export default Vue.extend({ > .notifications > .notification:not(:last-child) - border-bottom solid 1px var(--faceDivider) + border-bottom solid var(--lineWidth) var(--faceDivider) > .date display block @@ -185,7 +187,7 @@ export default Vue.extend({ font-size 12px color var(--dateDividerFg) background var(--dateDividerBg) - border-bottom solid 1px var(--faceDivider) + border-bottom solid var(--lineWidth) var(--faceDivider) span margin 0 16px @@ -198,7 +200,7 @@ export default Vue.extend({ width 100% padding 16px color #555 - border-top solid 1px rgba(#000, 0.05) + border-top solid var(--lineWidth) rgba(#000, 0.05) &:hover background rgba(#000, 0.025) diff --git a/src/client/app/desktop/views/pages/deck/deck.tl-column.vue b/src/client/app/desktop/views/pages/deck/deck.tl-column.vue index 97d69ae888..bc5e045373 100644 --- a/src/client/app/desktop/views/pages/deck/deck.tl-column.vue +++ b/src/client/app/desktop/views/pages/deck/deck.tl-column.vue @@ -10,7 +10,7 @@ <span>{{ name }}</span> </span> - <div class="editor" style="padding:0 12px" v-if="edit"> + <div class="editor" style="padding:12px" v-if="edit"> <ui-switch v-model="column.isMediaOnly" @change="onChangeSettings">{{ $t('is-media-only') }}</ui-switch> <ui-switch v-model="column.isMediaView" @change="onChangeSettings">{{ $t('is-media-view') }}</ui-switch> </div> diff --git a/src/client/app/desktop/views/pages/deck/deck.tl.vue b/src/client/app/desktop/views/pages/deck/deck.tl.vue index 3e87670827..4f5e3af197 100644 --- a/src/client/app/desktop/views/pages/deck/deck.tl.vue +++ b/src/client/app/desktop/views/pages/deck/deck.tl.vue @@ -123,7 +123,9 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) { + (this.$refs.timeline as any).append(n); + } this.moreFetching = false; }); diff --git a/src/client/app/desktop/views/pages/deck/deck.user-column.vue b/src/client/app/desktop/views/pages/deck/deck.user-column.vue index 90f7e2aaaa..e640caa586 100644 --- a/src/client/app/desktop/views/pages/deck/deck.user-column.vue +++ b/src/client/app/desktop/views/pages/deck/deck.user-column.vue @@ -1,7 +1,7 @@ <template> <x-column> <span slot="header"> - <fa icon="user"/><span>{{ title }}</span> + <fa icon="user"/><mk-user-name :user="user" v-if="user"/> </span> <div class="zubukjlciycdsyynicqrnlsmdwmymzqu" v-if="user"> @@ -16,13 +16,25 @@ <button class="menu" @click="menu" ref="menu"><fa icon="ellipsis-h"/></button> <mk-follow-button v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" class="follow" mini/> <mk-avatar class="avatar" :user="user" :disable-preview="true"/> - <span class="name">{{ user | userName }}</span> - <span class="acct">@{{ user | acct }}</span> + <span class="name"> + <mk-user-name :user="user"/> + </span> + <span class="acct">@{{ user | acct }} <fa v-if="user.isLocked == true" class="locked" icon="lock" fixed-width/></span> </div> </header> <div class="info"> <div class="description"> - <misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/> + <mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> + </div> + <div class="fields" v-if="user.fields"> + <dl class="field" v-for="(field, i) in user.fields" :key="i"> + <dt class="name"> + <mfm :text="field.name" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/> + </dt> + <dd class="value"> + <mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> + </dd> + </dl> </div> <div class="counts"> <div> @@ -85,8 +97,7 @@ import parseAcct from '../../../../../../misc/acct/parse'; import XColumn from './deck.column.vue'; import XNotes from './deck.notes.vue'; import XNote from '../../components/note.vue'; -import Menu from '../../../../common/views/components/menu.vue'; -import MkUserListsWindow from '../../components/user-lists-window.vue'; +import XUserMenu from '../../../../common/views/components/user-menu.vue'; import { concat } from '../../../../../../prelude/array'; import * as ApexCharts from 'apexcharts'; @@ -122,10 +133,6 @@ export default Vue.extend({ }, computed: { - title(): string { - return this.user ? Vue.filter('userName')(this.user) : ''; - }, - bannerStyle(): any { if (this.user == null) return {}; if (this.user.bannerUrl == null) return {}; @@ -154,14 +161,15 @@ export default Vue.extend({ this.$root.api('users/notes', { userId: this.user.id, fileType: image, + excludeNsfw: !this.$store.state.device.alwaysShowNsfw, limit: 9, untilDate: new Date().getTime() + 1000 * 86400 * 365 }).then(notes => { - notes.forEach(note => { - note.files.forEach(file => { + for (const note of notes) { + for (const file of note.files) { file._note = note; - }); - }); + } + } const files = concat(notes.map((n: any): any[] => n.files)); this.images = files.filter(f => image.includes(f.type)).slice(0, 9); }); @@ -207,8 +215,7 @@ export default Vue.extend({ }, plotOptions: { bar: { - columnWidth: '90%', - endingShape: 'rounded' + columnWidth: '90%' } }, grid: { @@ -288,7 +295,7 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) (this.$refs.timeline as any).append(n); this.moreFetching = false; }); @@ -296,29 +303,9 @@ export default Vue.extend({ }, menu() { - let menu = [{ - icon: 'list', - text: this.$t('push-to-a-list'), - action: () => { - const w = this.$root.new(MkUserListsWindow); - w.$once('choosen', async list => { - w.close(); - await this.$root.api('users/lists/push', { - listId: list.id, - userId: this.user.id - }); - this.$root.alert({ - type: 'success', - splash: true - }); - }); - } - }]; - - this.$root.new(Menu, { + this.$root.new(XUserMenu, { source: this.$refs.menu, - compact: false, - items: menu + user: this.user }); }, @@ -393,6 +380,9 @@ export default Vue.extend({ opacity 0.7 text-shadow 0 0 8px #000 + > .locked + opacity 0.8 + > .info padding 16px font-size 12px @@ -414,11 +404,37 @@ export default Vue.extend({ border-right solid 16px transparent border-bottom solid 16px var(--face) + > .fields + margin-top 8px + + > .field + display flex + padding 0 + margin 0 + align-items center + + > .name + padding 4px + margin 4px + width 30% + overflow hidden + white-space nowrap + text-overflow ellipsis + font-weight bold + + > .value + padding 4px + margin 4px + width 70% + overflow hidden + white-space nowrap + text-overflow ellipsis + > .counts display grid - grid-template-columns 1fr 1fr 1fr + grid-template-columns 2fr 2fr 2fr margin-top 8px - border-top solid 1px var(--faceDivider) + border-top solid var(--lineWidth) var(--faceDivider) > div padding 8px 8px 0 8px diff --git a/src/client/app/desktop/views/pages/deck/deck.vue b/src/client/app/desktop/views/pages/deck/deck.vue index 533cec8130..30b985e7e4 100644 --- a/src/client/app/desktop/views/pages/deck/deck.vue +++ b/src/client/app/desktop/views/pages/deck/deck.vue @@ -1,6 +1,6 @@ <template> <mk-ui :class="$style.root"> - <div class="qlvquzbjribqcaozciifydkngcwtyzje" ref="body" :style="style" :class="{ center: $store.state.device.deckColumnAlign == 'center' }" v-hotkey.global="keymap"> + <div class="qlvquzbjribqcaozciifydkngcwtyzje" ref="body" :style="style" :class="`${$store.state.device.deckColumnAlign} ${$store.state.device.deckColumnWidth}`" v-hotkey.global="keymap"> <template v-for="ids in layout"> <div v-if="ids.length > 1" class="folder"> <template v-for="id, i in ids"> @@ -179,7 +179,6 @@ export default Vue.extend({ add() { this.$root.new(Menu, { source: this.$refs.add, - compact: true, items: [{ icon: 'home', text: this.$t('@deck.home'), @@ -252,9 +251,11 @@ export default Vue.extend({ icon: 'hashtag', text: this.$t('@deck.hashtag'), action: () => { - this.$input({ - title: this.$t('enter-hashtag-tl-title') - }).then(title => { + this.$root.dialog({ + title: this.$t('enter-hashtag-tl-title'), + input: true + }).then(({ canceled, result: title }) => { + if (canceled) return; this.$store.dispatch('settings/addDeckColumn', { id: uuid(), type: 'hashtag', @@ -367,6 +368,8 @@ export default Vue.extend({ > div margin-right 8px + width 330px + min-width 330px &:last-of-type margin-right 0 @@ -378,6 +381,26 @@ export default Vue.extend({ > *:not(:last-child) margin-bottom 8px + &.narrow + > div + width 303px + min-width 303px + + &.narrower + > div + width 316.5px + min-width 316.5px + + &.wider + > div + width 343.5px + min-width 343.5px + + &.wide + > div + width 357px + min-width 357px + &.center > * &:first-child @@ -386,9 +409,20 @@ export default Vue.extend({ &:last-child margin-right auto + &.:not(.flexible) + > * + flex-grow 0 + flex-shrink 0 + + &.flexible + > * + flex-grow 1 + flex-shrink 0 + > button padding 0 16px color var(--faceTextButton) + flex-grow 0 !important &:hover color var(--faceTextButtonHover) diff --git a/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue b/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue index 2cdef50991..0798e2ccc7 100644 --- a/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue +++ b/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue @@ -26,7 +26,6 @@ <option value="hashtags">{{ $t('@.widgets.hashtags') }}</option> <option value="posts-monitor">{{ $t('@.widgets.posts-monitor') }}</option> <option value="server">{{ $t('@.widgets.server') }}</option> - <option value="donation">{{ $t('@.widgets.donation') }}</option> <option value="nav">{{ $t('@.widgets.nav') }}</option> <option value="tips">{{ $t('@.widgets.tips') }}</option> </select> diff --git a/src/client/app/desktop/views/pages/favorites.vue b/src/client/app/desktop/views/pages/favorites.vue index e7e02c8483..066ce3f53c 100644 --- a/src/client/app/desktop/views/pages/favorites.vue +++ b/src/client/app/desktop/views/pages/favorites.vue @@ -1,9 +1,11 @@ <template> <mk-ui> <main v-if="!fetching"> - <template v-for="favorite in favorites"> - <mk-note-detail class="post" :note="favorite.note" :key="favorite.note.id"/> - </template> + <sequential-entrance animation="entranceFromTop" delay="25"> + <template v-for="favorite in favorites"> + <mk-note-detail class="post" :note="favorite.note" :key="favorite.note.id"/> + </template> + </sequential-entrance> <div class="more" v-if="existMore"> <ui-button inline @click="more">{{ $t('@.load-more') }}</ui-button> </div> @@ -75,7 +77,7 @@ main padding 16px max-width 700px - > .post + > * > .post margin-bottom 16px > .more diff --git a/src/client/app/desktop/views/pages/games/reversi.vue b/src/client/app/desktop/views/pages/games/reversi.vue index d281e0b674..b859b95d7f 100644 --- a/src/client/app/desktop/views/pages/games/reversi.vue +++ b/src/client/app/desktop/views/pages/games/reversi.vue @@ -19,10 +19,10 @@ export default Vue.extend({ methods: { nav(game, actualNav) { if (actualNav) { - this.$router.push(`/reversi/${game.id}`); + this.$router.push(`/games/reversi/${game.id}`); } else { // TODO: https://github.com/vuejs/vue-router/issues/703 - this.$router.push(`/reversi/${game.id}`); + this.$router.push(`/games/reversi/${game.id}`); } } } diff --git a/src/client/app/desktop/views/pages/search.vue b/src/client/app/desktop/views/pages/search.vue index ff644a62dc..fc31673ba8 100644 --- a/src/client/app/desktop/views/pages/search.vue +++ b/src/client/app/desktop/views/pages/search.vue @@ -94,7 +94,9 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) { + (this.$refs.timeline as any).append(n); + } this.moreFetching = false; }); diff --git a/src/client/app/desktop/views/pages/settings.vue b/src/client/app/desktop/views/pages/settings.vue new file mode 100644 index 0000000000..7aba863b7c --- /dev/null +++ b/src/client/app/desktop/views/pages/settings.vue @@ -0,0 +1,24 @@ +<template> +<mk-ui> + <main> + <x-settings :in-window="false"/> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + components: { + XSettings: () => import('../components/settings.vue').then(m => m.default) + }, +}); +</script> + +<style lang="stylus" scoped> +main + margin 0 auto + max-width 900px + +</style> diff --git a/src/client/app/desktop/views/pages/tag.vue b/src/client/app/desktop/views/pages/tag.vue index 9245f69c99..6e5db18af0 100644 --- a/src/client/app/desktop/views/pages/tag.vue +++ b/src/client/app/desktop/views/pages/tag.vue @@ -3,7 +3,7 @@ <header :class="$style.header"> <h1>#{{ $route.params.tag }}</h1> </header> - <p :class="$style.empty" v-if="!fetching && empty"><fa icon="search"/> {{ $t('no-posts-found', { q }) }}</p> + <p :class="$style.empty" v-if="!fetching && empty"><fa icon="search"/> {{ $t('no-posts-found', { q: $route.params.tag }) }}</p> <mk-notes ref="timeline" :class="$style.notes" :more="existMore ? more : null"/> </mk-ui> </template> @@ -83,7 +83,9 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) { + (this.$refs.timeline as any).append(n); + } this.moreFetching = false; }); diff --git a/src/client/app/desktop/views/pages/user-following-or-followers.vue b/src/client/app/desktop/views/pages/user-following-or-followers.vue index 6cb6340089..fd842cbcd4 100644 --- a/src/client/app/desktop/views/pages/user-following-or-followers.vue +++ b/src/client/app/desktop/views/pages/user-following-or-followers.vue @@ -4,7 +4,9 @@ <header> <mk-avatar class="avatar" :user="user"/> <i18n :path="isFollowing ? 'following' : 'followers'" tag="p"> - <router-link :to="user | userPage" place="user">{{ user | userName }}</router-link> + <router-link :to="user | userPage" place="user"> + <mk-user-name :user="user"/> + </router-link> </i18n> </header> <div class="users"> diff --git a/src/client/app/desktop/views/pages/user/user.discord.vue b/src/client/app/desktop/views/pages/user/user.discord.vue deleted file mode 100644 index 30db57855a..0000000000 --- a/src/client/app/desktop/views/pages/user/user.discord.vue +++ /dev/null @@ -1,26 +0,0 @@ -<template> -<div class="lkafjvabenanajk17kwqpsatoushincb"> - <span><fa :icon="['fab', 'discord']"/><a :href="`https://discordapp.com/users/${user.discord.id}`" target="_blank">@{{ user.discord.username }}#{{ user.discord.discriminator }}</a></span> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: ['user'] -}); -</script> - -<style lang="stylus" scoped> -.lkafjvabenanajk17kwqpsatoushincb - padding 32px - background #7289da - border-radius 6px - color #fff - - a - margin-left 8px - color #fff - -</style> diff --git a/src/client/app/desktop/views/pages/user/user.friends.vue b/src/client/app/desktop/views/pages/user/user.friends.vue index e1cb6dc3f6..b247bca60d 100644 --- a/src/client/app/desktop/views/pages/user/user.friends.vue +++ b/src/client/app/desktop/views/pages/user/user.friends.vue @@ -9,7 +9,6 @@ <router-link class="name" :to="friend | userPage" v-user-preview="friend.id">{{ friend.name }}</router-link> <p class="username">@{{ friend | acct }}</p> </div> - <mk-follow-button class="follow-button" :user="friend"/> </div> </template> <p class="empty" v-if="!fetching && users.length == 0">{{ $t('no-users') }}</p> @@ -110,9 +109,4 @@ export default Vue.extend({ color var(--text) opacity 0.7 - > .follow-button - position absolute - top 16px - right 16px - </style> diff --git a/src/client/app/desktop/views/pages/user/user.github.vue b/src/client/app/desktop/views/pages/user/user.github.vue deleted file mode 100644 index a4cb1dac0a..0000000000 --- a/src/client/app/desktop/views/pages/user/user.github.vue +++ /dev/null @@ -1,26 +0,0 @@ -<template> -<div class="aqooishiizumijmihokohinatamihoaz"> - <span><fa :icon="['fab', 'github']"/><a :href="`https://github.com/${user.github.login}`" target="_blank">@{{ user.github.login }}</a></span> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: ['user'] -}); -</script> - -<style lang="stylus" scoped> -.aqooishiizumijmihokohinatamihoaz - padding 32px - background #171515 - border-radius 6px - color #fff - - a - margin-left 8px - color #fff - -</style> diff --git a/src/client/app/desktop/views/pages/user/user.header.vue b/src/client/app/desktop/views/pages/user/user.header.vue index 48b5a487f4..c33ca84ebc 100644 --- a/src/client/app/desktop/views/pages/user/user.header.vue +++ b/src/client/app/desktop/views/pages/user/user.header.vue @@ -4,7 +4,9 @@ <div class="banner" ref="banner" :style="style" @click="onBannerClick"></div> <div class="fade"></div> <div class="title"> - <p class="name">{{ user | userName }}</p> + <p class="name"> + <mk-user-name :user="user"/> + </p> <div> <span class="username"><mk-acct :user="user" :detail="true" /></span> <span v-if="user.isBot" :title="$t('title')"><fa icon="robot"/></span> @@ -14,7 +16,17 @@ <mk-avatar class="avatar" :user="user" :disable-preview="true"/> <div class="body"> <div class="description"> - <misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/> + <mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> + </div> + <div class="fields" v-if="user.fields"> + <dl class="field" v-for="(field, i) in user.fields" :key="i"> + <dt class="name"> + <mfm :text="field.name" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/> + </dt> + <dd class="value"> + <mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> + </dd> + </dl> </div> <div class="info"> <span class="location" v-if="user.host === null && user.profile.location"><fa icon="map-marker"/> {{ user.profile.location }}</span> @@ -65,6 +77,9 @@ export default Vue.extend({ } }, methods: { + mention() { + this.$post({ mention: this.user }); + }, onScroll() { const banner = this.$refs.banner as any; @@ -172,6 +187,34 @@ export default Vue.extend({ padding 16px 16px 16px 154px color var(--text) + > .fields + margin-top 16px + + > .field + display flex + padding 0 + margin 0 + align-items center + + > .name + border-right solid 1px var(--faceDivider) + padding 4px + margin 4px + width 30% + overflow hidden + white-space nowrap + text-overflow ellipsis + font-weight bold + text-align center + + > .value + padding 4px + margin 4px + width 70% + overflow hidden + white-space nowrap + text-overflow ellipsis + > .info margin-top 16px padding-top 16px diff --git a/src/client/app/desktop/views/pages/user/user.integrations.integration.vue b/src/client/app/desktop/views/pages/user/user.integrations.integration.vue new file mode 100644 index 0000000000..4791226881 --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.integrations.integration.vue @@ -0,0 +1,14 @@ +<template> +<a :href="url" :class="service" target="_blank"> + <fa :icon="icon" size="lg" fixed-width /> + <div>{{ text }}</div> +</a> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['url', 'text', 'icon', 'service'] +}); +</script> diff --git a/src/client/app/desktop/views/pages/user/user.integrations.vue b/src/client/app/desktop/views/pages/user/user.integrations.vue new file mode 100644 index 0000000000..d796ff9321 --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.integrations.vue @@ -0,0 +1,63 @@ +<template> +<div class="usertwitxxxgithxxdiscxxxintegrat" :v-if="user.twitter || user.github || user.discord"> + <x-integration v-if="user.twitter" service="twitter" :url="`https://twitter.com/${user.twitter.screenName}`" :text="user.twitter.screenName" :icon="['fab', 'twitter']"/> + <x-integration v-if="user.github" service="github" :url="`https://github.com/${user.github.login}`" :text="user.github.login" :icon="['fab', 'github']"/> + <x-integration v-if="user.discord" service="discord" :url="`https://discordapp.com/users/${user.discord.id}`" :text="`${user.discord.username}#${user.discord.discriminator}`" :icon="['fab', 'discord']"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XIntegration from './user.integrations.integration.vue'; + +export default Vue.extend({ + components: { + XIntegration + }, + props: ['user'] +}); +</script> + +<style lang="stylus" scoped> +.usertwitxxxgithxxdiscxxxintegrat + > a + display flex + align-items center + padding 32px 38px + box-shadow var(--shadow) + border-radius var(--round) + + &:not(:last-child) + margin-bottom 16px + + &:hover + text-decoration none + + > div + padding-left .2em + line-height 1.3em + flex 1 0 + word-wrap anywhere + + &.twitter + color #fff + background #1da1f3 + + &:hover + background #0c87cf + + &.github + color #fff + background #171515 + + &:hover + background #000 + + &.discord + color #fff + background #7289da + + &:hover + background #4968ce + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.photos.vue b/src/client/app/desktop/views/pages/user/user.photos.vue index 42436ea476..cd8853e0b0 100644 --- a/src/client/app/desktop/views/pages/user/user.photos.vue +++ b/src/client/app/desktop/views/pages/user/user.photos.vue @@ -24,17 +24,24 @@ export default Vue.extend({ }; }, mounted() { + const image = [ + 'image/jpeg', + 'image/png', + 'image/gif' + ]; + this.$root.api('users/notes', { userId: this.user.id, - withFiles: true, + fileType: image, + excludeNsfw: !this.$store.state.device.alwaysShowNsfw, limit: 9, untilDate: new Date().getTime() + 1000 * 86400 * 365 }).then(notes => { - notes.forEach(note => { - note.files.forEach(file => { + for (const note of notes) { + for (const file of note.files) { if (this.images.length < 9) this.images.push(file); - }); - }); + } + } this.fetching = false; }); } diff --git a/src/client/app/desktop/views/pages/user/user.profile.vue b/src/client/app/desktop/views/pages/user/user.profile.vue index fcd4aebdac..026f84dc8a 100644 --- a/src/client/app/desktop/views/pages/user/user.profile.vue +++ b/src/client/app/desktop/views/pages/user/user.profile.vue @@ -3,21 +3,9 @@ <div class="friend-form" v-if="$store.state.i.id != user.id"> <mk-follow-button :user="user" block/> <p class="followed" v-if="user.isFollowed">{{ $t('follows-you') }}</p> - <p class="stalk" v-if="user.isFollowing"> - <span v-if="user.isStalking">{{ $t('stalking') }} <a @click="unstalk"><fa icon="meh"/> {{ $t('unstalk') }}</a></span> - <span v-if="!user.isStalking"><a @click="stalk"><fa icon="user-secret"/> {{ $t('stalk') }}</a></span> - </p> </div> <div class="action-form"> - <ui-button @click="user.isMuted ? unmute() : mute()" v-if="$store.state.i.id != user.id"> - <span v-if="user.isMuted"><fa icon="eye"/> {{ $t('unmute') }}</span> - <span v-else><fa :icon="['far', 'eye-slash']"/> {{ $t('mute') }}</span> - </ui-button> - <ui-button @click="user.isBlocking ? unblock() : block()" v-if="$store.state.i.id != user.id"> - <span v-if="user.isBlocking"><fa icon="ban"/> {{ $t('unblock') }}</span> - <span v-else><fa icon="ban"/> {{ $t('block') }}</span> - </ui-button> - <ui-button @click="list"><fa icon="list"/> {{ $t('push-to-a-list') }}</ui-button> + <ui-button @click="menu" ref="menu">{{ $t('menu') }}</ui-button> </div> </div> </template> @@ -25,95 +13,19 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../../i18n'; -import MkUserListsWindow from '../../components/user-lists-window.vue'; +import XUserMenu from '../../../../common/views/components/user-menu.vue'; export default Vue.extend({ i18n: i18n('desktop/views/pages/user/user.profile.vue'), props: ['user'], methods: { - stalk() { - this.$root.api('following/stalk', { - userId: this.user.id - }).then(() => { - this.user.isStalking = true; - }, () => { - alert('error'); + menu() { + this.$root.new(XUserMenu, { + source: this.$refs.menu.$el, + user: this.user }); }, - - unstalk() { - this.$root.api('following/unstalk', { - userId: this.user.id - }).then(() => { - this.user.isStalking = false; - }, () => { - alert('error'); - }); - }, - - mute() { - this.$root.api('mute/create', { - userId: this.user.id - }).then(() => { - this.user.isMuted = true; - }, () => { - alert('error'); - }); - }, - - unmute() { - this.$root.api('mute/delete', { - userId: this.user.id - }).then(() => { - this.user.isMuted = false; - }, () => { - alert('error'); - }); - }, - - block() { - this.$root.alert({ - type: 'warning', - text: this.$t('block-confirm'), - showCancelButton: true - }).then(res => { - if (!res) return; - - this.$root.api('blocking/create', { - userId: this.user.id - }).then(() => { - this.user.isBlocking = true; - }, () => { - alert('error'); - }); - }); - }, - - unblock() { - this.$root.api('blocking/delete', { - userId: this.user.id - }).then(() => { - this.user.isBlocking = false; - }, () => { - alert('error'); - }); - }, - - list() { - const w = this.$root.new(MkUserListsWindow); - w.$once('choosen', async list => { - w.close(); - await this.$root.api('users/lists/push', { - listId: list.id, - userId: this.user.id - }); - this.$root.alert({ - title: 'Done!', - text: this.$t('list-pushed').replace('{user}', this.user.name).replace('{list}', list.title) - }); - }); - } } }); </script> @@ -138,13 +50,9 @@ export default Vue.extend({ text-align center line-height 24px font-size 0.8em - color #71afc7 - background #eefaff + color var(--text) border-radius 4px - > .stalk - margin 12px 0 0 0 - > .action-form padding 16px text-align center diff --git a/src/client/app/desktop/views/pages/user/user.timeline.vue b/src/client/app/desktop/views/pages/user/user.timeline.vue index 07e22648b9..0571ce76f1 100644 --- a/src/client/app/desktop/views/pages/user/user.timeline.vue +++ b/src/client/app/desktop/views/pages/user/user.timeline.vue @@ -4,6 +4,7 @@ <span :data-active="mode == 'default'" @click="mode = 'default'"><fa :icon="['far', 'comment-alt']"/> {{ $t('default') }}</span> <span :data-active="mode == 'with-replies'" @click="mode = 'with-replies'"><fa icon="comments"/> {{ $t('with-replies') }}</span> <span :data-active="mode == 'with-media'" @click="mode = 'with-media'"><fa :icon="['far', 'images']"/> {{ $t('with-media') }}</span> + <span :data-active="mode == 'my-posts'" @click="mode = 'my-posts'"><fa icon="user"/> {{ $t('my-posts') }}</span> </header> <mk-notes ref="timeline" :more="existMore ? more : null"> <p class="empty" slot="empty"><fa :icon="['far', 'comments']"/>{{ $t('empty') }}</p> @@ -65,6 +66,7 @@ export default Vue.extend({ limit: fetchLimit + 1, untilDate: this.date ? this.date.getTime() : new Date().getTime() + 1000 * 86400 * 365, includeReplies: this.mode == 'with-replies', + includeMyRenotes: this.mode != 'my-posts', withFiles: this.mode == 'with-media' }).then(notes => { if (notes.length == fetchLimit + 1) { @@ -85,6 +87,7 @@ export default Vue.extend({ userId: this.user.id, limit: fetchLimit + 1, includeReplies: this.mode == 'with-replies', + includeMyRenotes: this.mode != 'my-posts', withFiles: this.mode == 'with-media', untilDate: new Date((this.$refs.timeline as any).tail().createdAt).getTime() }); @@ -95,7 +98,9 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) { + (this.$refs.timeline as any).append(n); + } this.moreFetching = false; }); @@ -151,18 +156,20 @@ export default Vue.extend({ &:hover color var(--desktopTimelineSrcHover) - > .empty - display block - margin 0 auto - padding 32px - max-width 400px - text-align center - color #999 + > .mk-notes - > [data-icon] + > .empty display block - margin-bottom 16px - font-size 3em - color #ccc + margin 0 auto + padding 32px + max-width 400px + text-align center + color var(--text) + + > [data-icon] + display block + margin-bottom 16px + font-size 3em + color var(--faceHeaderText); </style> diff --git a/src/client/app/desktop/views/pages/user/user.twitter.vue b/src/client/app/desktop/views/pages/user/user.twitter.vue deleted file mode 100644 index 13cea10a99..0000000000 --- a/src/client/app/desktop/views/pages/user/user.twitter.vue +++ /dev/null @@ -1,26 +0,0 @@ -<template> -<div class="adsvaidqfznoartcbplullnejvxjphcn"> - <span><fa :icon="['fab', 'twitter']"/><a :href="`https://twitter.com/${user.twitter.screenName}`" target="_blank">@{{ user.twitter.screenName }}</a></span> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: ['user'] -}); -</script> - -<style lang="stylus" scoped> -.adsvaidqfznoartcbplullnejvxjphcn - padding 32px - background #1a94f2 - border-radius 6px - color #fff - - a - margin-left 8px - color #fff - -</style> diff --git a/src/client/app/desktop/views/pages/user/user.vue b/src/client/app/desktop/views/pages/user/user.vue index 1333d313fe..0350151136 100644 --- a/src/client/app/desktop/views/pages/user/user.vue +++ b/src/client/app/desktop/views/pages/user/user.vue @@ -1,7 +1,7 @@ <template> <mk-ui> <div class="xygkxeaeontfaokvqmiblezmhvhostak" v-if="!fetching"> - <div class="is-suspended" v-if="user.isSuspended"><fa icon="exclamation-triangle"/> {{ $t('@.is-suspended') }}</div> + <div class="is-suspended" v-if="user.isSuspended"><fa icon="exclamation-triangle"/> {{ $t('@.user-suspended') }}</div> <div class="is-remote" v-if="user.host != null"><fa icon="exclamation-triangle"/> {{ $t('@.is-remote-user') }}<a :href="user.url || user.uri" target="_blank">{{ $t('@.view-on-remote') }}</a></div> <main> <div class="main"> @@ -12,9 +12,7 @@ <div class="side"> <div class="instance" v-if="!$store.getters.isSignedIn"><mk-instance/></div> <x-profile :user="user"/> - <x-twitter :user="user" v-if="!user.host && user.twitter"/> - <x-github :user="user" v-if="!user.host && user.github"/> - <x-discord :user="user" v-if="!user.host && user.discord"/> + <x-integrations :user="user" v-if="!user.host"/> <mk-calendar @chosen="warp" :start="new Date(user.createdAt)"/> <mk-activity :user="user"/> <x-photos :user="user"/> @@ -38,9 +36,7 @@ import XProfile from './user.profile.vue'; import XPhotos from './user.photos.vue'; import XFollowersYouKnow from './user.followers-you-know.vue'; import XFriends from './user.friends.vue'; -import XTwitter from './user.twitter.vue'; -import XGithub from './user.github.vue'; // ?MEM: Don't fix the intentional typo. (XGitHub -> `<x-git-hub>`) -import XDiscord from './user.discord.vue'; +import XIntegrations from './user.integrations.vue'; export default Vue.extend({ i18n: i18n(), @@ -51,9 +47,7 @@ export default Vue.extend({ XPhotos, XFollowersYouKnow, XFriends, - XTwitter, - XGithub, // ?MEM: Don't fix the intentional typo. (see L41) - XDiscord + XIntegrations }, data() { return { @@ -87,7 +81,8 @@ export default Vue.extend({ <style lang="stylus" scoped> .xygkxeaeontfaokvqmiblezmhvhostak - width 980px + max-width 980px + min-width 720px padding 16px margin 0 auto diff --git a/src/client/app/desktop/views/pages/welcome.vue b/src/client/app/desktop/views/pages/welcome.vue index de26035171..88a11eafa6 100644 --- a/src/client/app/desktop/views/pages/welcome.vue +++ b/src/client/app/desktop/views/pages/welcome.vue @@ -35,7 +35,7 @@ <span class="signin" @click="signin">{{ $t('signin') }}</span> </p> - <img src="/assets/ai.png" alt="" title="藍" class="char"> + <img :src="meta.mascotImageUrl" alt="" title="藍" class="char"> </div> </div> @@ -371,7 +371,6 @@ export default Vue.extend({ > .main grid-row 1 grid-column 1 / 3 - border-top solid 5px var(--primary) > div padding 32px diff --git a/src/client/app/desktop/views/widgets/polls.vue b/src/client/app/desktop/views/widgets/polls.vue index 5d7bc9c009..fb2e019e37 100644 --- a/src/client/app/desktop/views/widgets/polls.vue +++ b/src/client/app/desktop/views/widgets/polls.vue @@ -2,7 +2,10 @@ <div class="mkw-polls"> <mk-widget-container :show-header="!props.compact"> <template slot="header"><fa icon="chart-pie"/>{{ $t('title') }}</template> - <button slot="func" :title="$t('title')" @click="fetch"><fa icon="sync"/></button> + <button slot="func" :title="$t('title')" @click="fetch"> + <fa v-if="!fetching && more" icon="arrow-right"/> + <fa v-if="!fetching && !more" icon="sync"/> + </button> <div class="mkw-polls--body"> <div class="poll" v-if="!fetching && poll != null"> @@ -32,6 +35,7 @@ export default define({ return { poll: null, fetching: true, + more: true, offset: 0 }; }, @@ -53,12 +57,18 @@ export default define({ }).then(notes => { const poll = notes ? notes[0] : null; if (poll == null) { + this.more = false; this.offset = 0; } else { + this.more = true; this.offset++; } this.poll = poll; this.fetching = false; + }).catch(() => { + this.poll = null; + this.fetching = false; + this.more = false; }); } } diff --git a/src/client/app/desktop/views/widgets/post-form.vue b/src/client/app/desktop/views/widgets/post-form.vue index 5c8d25ac61..e409760aaf 100644 --- a/src/client/app/desktop/views/widgets/post-form.vue +++ b/src/client/app/desktop/views/widgets/post-form.vue @@ -15,7 +15,7 @@ @paste="onPaste" :placeholder="placeholder" ref="text" - v-autocomplete="'text'" + v-autocomplete="{ model: 'text' }" ></textarea> <button class="emoji" @click="emoji" ref="emoji"> <fa :icon="['far', 'laugh']"/> @@ -99,7 +99,7 @@ export default define({ this.$chooseDriveFile({ multiple: true }).then(files => { - files.forEach(this.attachMedia); + for (const x of files) this.attachMedia(x); }); }, @@ -118,15 +118,15 @@ export default define({ }, onPaste(e) { - Array.from(e.clipboardData.items).forEach((item: any) => { + for (const item of Array.from(e.clipboardData.items)) { if (item.kind == 'file') { this.upload(item.getAsFile()); } - }); + } }, onChangeFile() { - Array.from((this.$refs.file as any).files).forEach(this.upload); + for (const x of Array.from((this.$refs.file as any).files)) this.upload(x); }, upload(file) { @@ -146,7 +146,7 @@ export default define({ // ファイルだったら if (e.dataTransfer.files.length > 0) { e.preventDefault(); - Array.from(e.dataTransfer.files).forEach(this.upload); + for (const x of Array.from(e.dataTransfer.files)) this.upload(x); return; } diff --git a/src/client/app/desktop/views/widgets/profile.vue b/src/client/app/desktop/views/widgets/profile.vue index 47429cd586..16990dc41b 100644 --- a/src/client/app/desktop/views/widgets/profile.vue +++ b/src/client/app/desktop/views/widgets/profile.vue @@ -8,14 +8,14 @@ <div class="banner" :style="$store.state.i.bannerUrl ? `background-image: url(${$store.state.i.bannerUrl})` : ''" :title="$t('update-banner')" - @click="() => os.apis.updateBanner()" + @click="updateBanner()" ></div> <mk-avatar class="avatar" :user="$store.state.i" :disable-link="true" - @click="() => os.apis.updateAvatar()" + @click="updateAvatar()" :title="$t('update-avatar')" /> - <router-link class="name" :to="$store.state.i | userPage">{{ $store.state.i | userName }}</router-link> + <router-link class="name" :to="$store.state.i | userPage"><mk-user-name :user="$store.state.i"/></router-link> <p class="username">@{{ $store.state.i | acct }}</p> </div> </mk-widget-container> @@ -25,6 +25,8 @@ <script lang="ts"> import define from '../../../common/define-widget'; import i18n from '../../../i18n'; +import updateAvatar from '../../api/update-avatar'; +import updateBanner from '../../api/update-banner'; export default define({ name: 'profile', @@ -41,6 +43,12 @@ export default define({ this.props.design++; } this.save(); + }, + updateAvatar() { + updateAvatar(this.$root)(); + }, + updateBanner() { + updateBanner(this.$root)(); } } }); diff --git a/src/client/app/desktop/views/widgets/users.vue b/src/client/app/desktop/views/widgets/users.vue index 2c42e35a8b..0530b89d3b 100644 --- a/src/client/app/desktop/views/widgets/users.vue +++ b/src/client/app/desktop/views/widgets/users.vue @@ -2,7 +2,10 @@ <div class="mkw-users"> <mk-widget-container :show-header="!props.compact"> <template slot="header"><fa icon="users"/>{{ $t('title') }}</template> - <button slot="func" :title="$t('title')" @click="refresh"><fa icon="sync"/></button> + <button slot="func" :title="$t('title')" @click="refresh"> + <fa v-if="!fetching && more" icon="arrow-right"/> + <fa v-if="!fetching && !more" icon="sync"/> + </button> <div class="mkw-users--body"> <p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> @@ -10,7 +13,7 @@ <div class="user" v-for="_user in users"> <mk-avatar class="avatar" :user="_user"/> <div class="body"> - <router-link class="name" :to="_user | userPage" v-user-preview="_user.id">{{ _user | userName }}</router-link> + <router-link class="name" :to="_user | userPage" v-user-preview="_user.id"><mk-user-name :user="_user"/></router-link> <p class="username">@{{ _user | acct }}</p> </div> </div> @@ -38,6 +41,7 @@ export default define({ return { users: [], fetching: true, + more: true, page: 0 }; }, @@ -59,12 +63,19 @@ export default define({ }).then(users => { this.users = users; this.fetching = false; + }).catch(() => { + this.users = []; + this.fetching = false; + this.more = false; + this.page = 0; }); }, refresh() { if (this.users.length < limit) { + this.more = false; this.page = 0; } else { + this.more = true; this.page++; } this.fetch(); diff --git a/src/client/app/dev/script.ts b/src/client/app/dev/script.ts index c043813b40..9adcb84d7c 100644 --- a/src/client/app/dev/script.ts +++ b/src/client/app/dev/script.ts @@ -18,6 +18,7 @@ import Apps from './views/apps.vue'; import AppNew from './views/new-app.vue'; import App from './views/app.vue'; import ui from './views/ui.vue'; +import NotFound from '../common/views/pages/not-found.vue'; Vue.use(BootstrapVue); @@ -36,6 +37,7 @@ init(launch => { { path: '/apps', component: Apps }, { path: '/app/new', component: AppNew }, { path: '/app/:id', component: App }, + { path: '*', component: NotFound } ] }); diff --git a/src/client/app/init.ts b/src/client/app/init.ts index fef03e86f1..7eb53eb7a5 100644 --- a/src/client/app/init.ts +++ b/src/client/app/init.ts @@ -8,14 +8,15 @@ import VueRouter from 'vue-router'; import VAnimateCss from 'v-animate-css'; import VModal from 'vue-js-modal'; import VueI18n from 'vue-i18n'; +import SequentialEntrance from 'vue-sequential-entrance'; import VueHotkey from './common/hotkey'; import App from './app.vue'; import checkForUpdate from './common/scripts/check-for-update'; import MiOS from './mios'; -import { clientVersion as version, codename, lang } from './config'; +import { clientVersion as version, codename, lang, locale } from './config'; import { builtinThemes, lightTheme, applyTheme } from './theme'; -import Alert from './common/views/components/alert.vue'; +import Dialog from './common/views/components/dialog.vue'; if (localStorage.getItem('theme') == null) { applyTheme(lightTheme); @@ -122,6 +123,9 @@ import { faArrowLeft, faMapMarker, faRobot, + faHourglassHalf, + faAlignLeft, + faGavel } from '@fortawesome/free-solid-svg-icons'; import { @@ -143,6 +147,9 @@ import { faCalendarAlt as farCalendarAlt, faHdd as farHdd, faMoon as farMoon, + faPlayCircle as farPlayCircle, + faLightbulb as farLightbulb, + faStickyNote as farStickyNote, } from '@fortawesome/free-regular-svg-icons'; import { @@ -248,7 +255,10 @@ library.add( faSync, faArrowLeft, faMapMarker, - faRobot, + faRobot, + faHourglassHalf, + faAlignLeft, + faGavel, farBell, farEnvelope, @@ -268,6 +278,9 @@ library.add( farCalendarAlt, farHdd, farMoon, + farPlayCircle, + farLightbulb, + farStickyNote, fabTwitter, fabGithub, @@ -281,6 +294,7 @@ Vue.use(VAnimateCss); Vue.use(VModal); Vue.use(VueHotkey); Vue.use(VueI18n); +Vue.use(SequentialEntrance); Vue.component('fa', FontAwesomeIcon); @@ -312,7 +326,7 @@ Vue.mixin({ console.info(`Misskey v${version} (${codename})`); console.info( - '%c%i18n:common.do-not-copy-paste%', + `%c${locale['common']['do-not-copy-paste']}`, 'color: red; background: yellow; font-size: 16px; font-weight: bold;'); // BootTimer解除 @@ -379,9 +393,11 @@ export default (callback: (launch: (router: VueRouter) => [Vue, MiOS]) => void, const shadow = '0 3px 8px rgba(0, 0, 0, 0.2)'; const shadowRight = '4px 0 4px rgba(0, 0, 0, 0.1)'; const shadowLeft = '-4px 0 4px rgba(0, 0, 0, 0.1)'; - if (os.store.state.settings.useShadow) document.documentElement.style.setProperty('--shadow', shadow); - if (os.store.state.settings.useShadow) document.documentElement.style.setProperty('--shadowRight', shadowRight); - if (os.store.state.settings.useShadow) document.documentElement.style.setProperty('--shadowLeft', shadowLeft); + if (os.store.state.settings.useShadow) { + document.documentElement.style.setProperty('--shadow', shadow); + document.documentElement.style.setProperty('--shadowRight', shadowRight); + document.documentElement.style.setProperty('--shadowLeft', shadowLeft); + } os.store.watch(s => { return s.settings.useShadow; }, v => { @@ -401,17 +417,18 @@ export default (callback: (launch: (router: VueRouter) => [Vue, MiOS]) => void, }); //#endregion + //#region line width + document.documentElement.style.setProperty('--lineWidth', `${os.store.state.device.lineWidth}px`); + os.store.watch(s => { + return s.device.lineWidth; + }, v => { + document.documentElement.style.setProperty('--lineWidth', `${os.store.state.device.lineWidth}px`); + }); + //#endregion + // Navigation hook router.beforeEach((to, from, next) => { - if (os.store.state.navHook) { - if (os.store.state.navHook(to)) { - next(false); - } else { - next(); - } - } else { - next(); - } + next(os.store.state.navHook && os.store.state.navHook(to) ? false : undefined); }); document.addEventListener('visibilitychange', () => { @@ -451,11 +468,11 @@ export default (callback: (launch: (router: VueRouter) => [Vue, MiOS]) => void, document.body.appendChild(x.$el); return x; }, - alert(opts) { + dialog(opts) { + const vm = this.new(Dialog, opts); return new Promise((res) => { - const vm = this.new(Alert, opts); - vm.$once('ok', () => res(true)); - vm.$once('cancel', () => res(false)); + vm.$once('ok', result => res({ canceled: false, result })); + vm.$once('cancel', () => res({ canceled: true })); }); } }, diff --git a/src/client/app/mios.ts b/src/client/app/mios.ts index 5ed4dfd4db..dc550b6f70 100644 --- a/src/client/app/mios.ts +++ b/src/client/app/mios.ts @@ -190,8 +190,8 @@ export default class MiOS extends EventEmitter { this.store.dispatch('mergeMe', freshData); }); } else { - // Get token from cookie - const i = (document.cookie.match(/i=(!\w+)/) || [null, null])[1]; + // Get token from cookie or localStorage + const i = (document.cookie.match(/i=(!\w+)/) || [null, null])[1] || localStorage.getItem('i'); fetchme(i, me => { if (me) { @@ -385,7 +385,7 @@ export default class MiOS extends EventEmitter { * @param data パラメータ */ @autobind - public api(endpoint: string, data: { [x: string]: any } = {}, forceFetch = false, silent = false): Promise<{ [x: string]: any }> { + public api(endpoint: string, data: { [x: string]: any } = {}, silent = false): Promise<{ [x: string]: any }> { if (!silent) { if (++pending === 1) { spinner = document.createElement('div'); @@ -401,66 +401,44 @@ export default class MiOS extends EventEmitter { }; const promise = new Promise((resolve, reject) => { - const viaStream = this.stream && this.stream.state == 'connected' && this.store.state.device.apiViaStream && !forceFetch; + // Append a credential + if (this.store.getters.isSignedIn) (data as any).i = this.store.state.i.token; - if (viaStream) { - const id = Math.random().toString().substr(2, 8); - - this.stream.once(`api:${id}`, res => { - if (res == null || Object.keys(res).length == 0) { - resolve(null); - } else if (res.res) { - resolve(res.res); - } else { - reject(res.e); - } - }); + const req = { + id: uuid(), + date: new Date(), + name: endpoint, + data, + res: null, + status: null + }; - this.stream.send('api', { - id: id, - ep: endpoint, - data: data - }); - } else { - // Append a credential - if (this.store.getters.isSignedIn) (data as any).i = this.store.state.i.token; + if (this.debug) { + this.requests.push(req); + } - const req = { - id: uuid(), - date: new Date(), - name: endpoint, - data, - res: null, - status: null - }; + // Send request + fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: endpoint === 'signin' ? 'include' : 'omit', + cache: 'no-cache' + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); if (this.debug) { - this.requests.push(req); + req.status = res.status; + req.res = body; } - // Send request - fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, { - method: 'POST', - body: JSON.stringify(data), - credentials: endpoint === 'signin' ? 'include' : 'omit', - cache: 'no-cache' - }).then(async (res) => { - const body = res.status === 204 ? null : await res.json(); - - if (this.debug) { - req.status = res.status; - req.res = body; - } - - if (res.status === 200) { - resolve(body); - } else if (res.status === 204) { - resolve(); - } else { - reject(body.error); - } - }).catch(reject); - } + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); }); promise.then(onFinally, onFinally); diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts index 116ffce1dc..bbbdc0ebb0 100644 --- a/src/client/app/mobile/script.ts +++ b/src/client/app/mobile/script.ts @@ -31,6 +31,7 @@ import MkReversi from './views/pages/games/reversi.vue'; import MkTag from './views/pages/tag.vue'; import MkShare from './views/pages/share.vue'; import MkFollow from '../common/views/pages/follow.vue'; +import MkNotFound from '../common/views/pages/not-found.vue'; import PostForm from './views/components/post-form-dialog.vue'; import FileChooser from './views/components/drive-file-chooser.vue'; @@ -41,6 +42,12 @@ import FolderChooser from './views/components/drive-folder-chooser.vue'; */ init((launch) => { Vue.mixin({ + data() { + return { + isMobile: true + }; + }, + methods: { $post(opts) { const o = opts || {}; @@ -53,6 +60,7 @@ init((launch) => { const vm = this.$root.new(PostForm, { reply: o.reply, + mention: o.mention, renote: o.renote }); @@ -89,15 +97,6 @@ init((launch) => { }); }, - $input(opts) { - return new Promise<string>((res, rej) => { - const x = window.prompt(opts.title); - if (x) { - res(x); - } - }); - }, - $notify(message) { alert(message); } @@ -136,12 +135,13 @@ init((launch) => { { path: '/search', component: MkSearch }, { path: '/tags/:tag', component: MkTag }, { path: '/share', component: MkShare }, - { path: '/reversi/:game?', name: 'reversi', component: MkReversi }, + { path: '/games/reversi/:game?', name: 'reversi', component: MkReversi }, { path: '/@:user', component: () => import('./views/pages/user.vue').then(m => m.default) }, { path: '/@:user/followers', component: MkFollowers }, { path: '/@:user/following', component: MkFollowing }, { path: '/notes/:note', component: MkNote }, - { path: '/authorize-follow', component: MkFollow } + { path: '/authorize-follow', component: MkFollow }, + { path: '*', component: MkNotFound } ] }); diff --git a/src/client/app/mobile/views/components/activity.vue b/src/client/app/mobile/views/components/activity.vue index 18bff253de..7a574dc684 100644 --- a/src/client/app/mobile/views/components/activity.vue +++ b/src/client/app/mobile/views/components/activity.vue @@ -59,8 +59,7 @@ export default Vue.extend({ }, plotOptions: { bar: { - columnWidth: '90%', - endingShape: 'rounded' + columnWidth: '90%' } }, grid: { diff --git a/src/client/app/mobile/views/components/drive.file-detail.vue b/src/client/app/mobile/views/components/drive.file-detail.vue index 8b093bceed..4d0a747fcb 100644 --- a/src/client/app/mobile/views/components/drive.file-detail.vue +++ b/src/client/app/mobile/views/components/drive.file-detail.vue @@ -37,6 +37,7 @@ </div> <div class="menu"> <div> + <ui-input readonly :value="file.url">URL</ui-input> <ui-button link :href="`${file.url}?download`" :download="file.name"><fa icon="download"/> {{ $t('download') }}</ui-button> <ui-button @click="rename"><fa icon="pencil-alt"/> {{ $t('rename') }}</ui-button> <ui-button @click="move"><fa :icon="['far', 'folder-open']"/> {{ $t('move') }}</ui-button> @@ -200,7 +201,7 @@ export default Vue.extend({ color #bf4633 > .menu - padding 14px + padding 0 14px 14px 14px border-top solid 1px var(--faceDivider) > div diff --git a/src/client/app/mobile/views/components/drive.vue b/src/client/app/mobile/views/components/drive.vue index e7a5d99832..5fb160f916 100644 --- a/src/client/app/mobile/views/components/drive.vue +++ b/src/client/app/mobile/views/components/drive.vue @@ -297,8 +297,8 @@ export default Vue.extend({ let flag = false; const complete = () => { if (flag) { - fetchedFolders.forEach(this.appendFolder); - fetchedFiles.forEach(this.appendFile); + for (const x of fetchedFolders) this.appendFolder(x); + for (const x of fetchedFiles) this.appendFile(x); this.fetching = false; // 一連の読み込みが完了したイベントを発行 @@ -336,7 +336,7 @@ export default Vue.extend({ } else { this.moreFiles = false; } - files.forEach(this.appendFile); + for (const x of files) this.appendFile(x); this.fetching = false; this.fetchingMoreFiles = false; }); @@ -460,8 +460,9 @@ export default Vue.extend({ }, onChangeLocalFile() { - Array.from((this.$refs.file as any).files) - .forEach(f => (this.$refs.uploader as any).upload(f, this.folder)); + for (const f of Array.from((this.$refs.file as any).files)) { + (this.$refs.uploader as any).upload(f, this.folder); + } } } }); diff --git a/src/client/app/mobile/views/components/media-video.vue b/src/client/app/mobile/views/components/media-video.vue index 8cd9971b77..8c4e9e153a 100644 --- a/src/client/app/mobile/views/components/media-video.vue +++ b/src/client/app/mobile/views/components/media-video.vue @@ -1,5 +1,5 @@ <template> -<div class="icozogqfvdetwohsdglrbswgrejoxbdj" v-if="video.isSensitive && hide" @click="hide = false"> +<div class="icozogqfvdetwohsdglrbswgrejoxbdj" v-if="video.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false"> <div> <b><fa icon="exclamation-triangle"/> {{ $t('sensitive') }}</b> <span>{{ $t('click-to-show') }}</span> diff --git a/src/client/app/mobile/views/components/note-card.vue b/src/client/app/mobile/views/components/note-card.vue index de9c9c1450..43f8210855 100644 --- a/src/client/app/mobile/views/components/note-card.vue +++ b/src/client/app/mobile/views/components/note-card.vue @@ -2,7 +2,8 @@ <div class="mk-note-card"> <a :href="note | notePage"> <header> - <img :src="note.user.avatarUrl" alt="avatar"/><h3>{{ note.user | userName }}</h3> + <img :src="note.user.avatarUrl" alt="avatar"/> + <h3><mk-user-name :user="note.user"/></h3> </header> <div> {{ text }} diff --git a/src/client/app/mobile/views/components/note-detail.vue b/src/client/app/mobile/views/components/note-detail.vue index 001ffc5da8..e00ecd5ffa 100644 --- a/src/client/app/mobile/views/components/note-detail.vue +++ b/src/client/app/mobile/views/components/note-detail.vue @@ -1,8 +1,8 @@ <template> -<div class="mk-note-detail"> +<div class="mk-note-detail" tabindex="-1"> <button class="more" - v-if="p.reply && p.reply.replyId && conversation.length == 0" + v-if="appearNote.reply && appearNote.reply.replyId && conversation.length == 0" @click="fetchConversation" :disabled="conversationFetching" > @@ -12,68 +12,72 @@ <div class="conversation"> <x-sub v-for="note in conversation" :key="note.id" :note="note"/> </div> - <div class="reply-to" v-if="p.reply"> - <x-sub :note="p.reply"/> - </div> - <div class="renote" v-if="isRenote"> - <p> - <mk-avatar class="avatar" :user="note.user"/> - <fa icon="retweet"/> - <router-link class="name" :href="note.user | userPage">{{ note.user | userName }}</router-link> - <span>{{ this.$t('reposted-by').substr(0, this.$t('reposted-by').indexOf('{')) }}</span> - <a class="name" :href="note.user | userPage" v-user-preview="note.userId">{{ note.user | userName }}</a> - <span>{{ this.$t('reposted-by').substr(this.$t('reposted-by').indexOf('}') + 1) }}</span> - <mk-time :time="note.createdAt"/> - </p> + <div class="reply-to" v-if="appearNote.reply"> + <x-sub :note="appearNote.reply"/> </div> + <mk-renote class="renote" v-if="isRenote" :note="note" mini/> <article> <header> - <mk-avatar class="avatar" :user="p.user"/> + <mk-avatar class="avatar" :user="appearNote.user"/> <div> - <router-link class="name" :to="p.user | userPage">{{ p.user | userName }}</router-link> - <span class="username"><mk-acct :user="p.user"/></span> + <router-link class="name" :to="appearNote.user | userPage"><mk-user-name :user="appearNote.user"/></router-link> + <span class="username"><mk-acct :user="appearNote.user"/></span> </div> </header> <div class="body"> - <p v-if="p.cw != null" class="cw"> - <span class="text" v-if="p.cw != ''">{{ p.cw }}</span> - <mk-cw-button v-model="showContent"/> + <p v-if="appearNote.cw != null" class="cw"> + <mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" /> + <mk-cw-button v-model="showContent" :note="appearNote"/> </p> - <div class="content" v-show="p.cw == null || showContent"> + <div class="content" v-show="appearNote.cw == null || showContent"> <div class="text"> - <span v-if="p.isHidden" style="opacity: 0.5">({{ $t('private') }})</span> - <span v-if="p.deletedAt" style="opacity: 0.5">({{ $t('deleted') }})</span> - <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i" :customEmojis="p.emojis"/> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</span> + <span v-if="appearNote.deletedAt" style="opacity: 0.5">({{ $t('deleted') }})</span> + <mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/> </div> - <div class="files" v-if="p.files.length > 0"> - <mk-media-list :media-list="p.files" :raw="true"/> + <div class="files" v-if="appearNote.files.length > 0"> + <mk-media-list :media-list="appearNote.files" :raw="true"/> </div> - <mk-poll v-if="p.poll" :note="p"/> + <mk-poll v-if="appearNote.poll" :note="appearNote"/> <mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/> - <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank"><fa icon="map-marker-alt"/> {{ $t('location') }}</a> - <div class="map" v-if="p.geo" ref="map"></div> - <div class="renote" v-if="p.renote"> - <mk-note-preview :note="p.renote"/> + <a class="location" v-if="appearNote.geo" :href="`https://maps.google.com/maps?q=${appearNote.geo.coordinates[1]},${appearNote.geo.coordinates[0]}`" target="_blank"><fa icon="map-marker-alt"/> {{ $t('location') }}</a> + <div class="map" v-if="appearNote.geo" ref="map"></div> + <div class="renote" v-if="appearNote.renote"> + <mk-note-preview :note="appearNote.renote"/> </div> </div> </div> - <router-link class="time" :to="p | notePage"> - <mk-time :time="p.createdAt" mode="detail"/> + <router-link class="time" :to="appearNote | notePage"> + <mk-time :time="appearNote.createdAt" mode="detail"/> </router-link> + <div class="visibility-info"> + <span class="visibility" v-if="appearNote.visibility != 'public'"> + <fa v-if="appearNote.visibility == 'home'" icon="home"/> + <fa v-if="appearNote.visibility == 'followers'" icon="unlock"/> + <fa v-if="appearNote.visibility == 'specified'" icon="envelope"/> + </span> + <span class="localOnly" v-if="appearNote.localOnly == true"><fa icon="heart"/></span> + </div> <footer> - <mk-reactions-viewer :note="p"/> - <button @click="reply" :title="$t('title')"> - <template v-if="p.reply"><fa icon="reply-all"/></template> + <mk-reactions-viewer :note="appearNote"/> + <button @click="reply()" :title="$t('title')"> + <template v-if="appearNote.reply"><fa icon="reply-all"/></template> <template v-else><fa icon="reply"/></template> - <p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> + <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> + </button> + <button v-if="['public', 'home'].includes(appearNote.visibility)" @click="renote()" title="Renote"> + <fa icon="retweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> </button> - <button @click="renote" title="Renote"> - <fa icon="retweet"/><p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p> + <button v-else> + <fa icon="ban"/> </button> - <button :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton" :title="$t('title')"> - <fa icon="plus"/><p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p> + <button v-if="!isMyNote && appearNote.myReaction == null" class="reactionButton" @click="react()" ref="reactButton"> + <fa icon="plus"/> </button> - <button @click="menu" ref="menuButton"> + <button v-if="!isMyNote && appearNote.myReaction != null" class="reactionButton reacted" @click="undoReact(appearNote)" ref="reactButton"> + <fa icon="minus"/> + </button> + <button @click="menu()" ref="menuButton"> <fa icon="ellipsis-h"/> </button> </footer> @@ -87,21 +91,18 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import parse from '../../../../../mfm/parse'; - -import MkNoteMenu from '../../../common/views/components/note-menu.vue'; -import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; import XSub from './note.sub.vue'; -import { sum, unique } from '../../../../../prelude/array'; import noteSubscriber from '../../../common/scripts/note-subscriber'; +import noteMixin from '../../../common/scripts/note-mixin'; export default Vue.extend({ i18n: i18n('mobile/views/components/note-detail.vue'), + components: { XSub }, - mixins: [noteSubscriber('note')], + mixins: [noteMixin(), noteSubscriber('note')], props: { note: { @@ -115,71 +116,22 @@ export default Vue.extend({ data() { return { - showContent: false, conversation: [], conversationFetching: false, replies: [] }; }, - computed: { - isRenote(): boolean { - return (this.note.renote && - this.note.text == null && - this.note.fileIds.length == 0 && - this.note.poll == null); - }, - - p(): any { - return this.isRenote ? this.note.renote : this.note; - }, - - reactionsCount(): number { - return this.p.reactionCounts - ? sum(Object.values(this.p.reactionCounts)) - : 0; - }, - - urls(): string[] { - if (this.p.text) { - const ast = parse(this.p.text); - return unique(ast - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => t.url)); - } else { - return null; - } - } - }, - mounted() { // Get replies if (!this.compact) { this.$root.api('notes/replies', { - noteId: this.p.id, + noteId: this.appearNote.id, limit: 8 }).then(replies => { this.replies = replies; }); } - - // Draw map - if (this.p.geo) { - const shouldShowMap = this.$store.getters.isSignedIn ? this.$store.state.settings.showMaps : true; - if (shouldShowMap) { - this.$root.os.getGoogleMaps().then(maps => { - const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]); - const map = new maps.Map(this.$refs.map, { - center: uluru, - zoom: 15 - }); - new maps.Marker({ - position: uluru, - map: map - }); - }); - } - } }, methods: { @@ -188,40 +140,11 @@ export default Vue.extend({ // Fetch conversation this.$root.api('notes/conversation', { - noteId: this.p.replyId + noteId: this.appearNote.replyId }).then(conversation => { this.conversationFetching = false; this.conversation = conversation.reverse(); }); - }, - - reply() { - this.$post({ - reply: this.p - }); - }, - - renote() { - this.$post({ - renote: this.p - }); - }, - - react() { - this.$root.new(MkReactionPicker, { - source: this.$refs.reactButton, - note: this.p, - compact: true, - big: true - }); - }, - - menu() { - this.$root.new(MkNoteMenu, { - source: this.$refs.menuButton, - note: this.p, - compact: true - }); } } }); @@ -234,7 +157,7 @@ export default Vue.extend({ text-align left background var(--face) border-radius 8px - box-shadow 0 0 2px rgba(#000, 0.1) + box-shadow 0 4px 16px rgba(#000, 0.1) @media (min-width 500px) box-shadow 0 8px 32px rgba(#000, 0.1) @@ -268,29 +191,8 @@ export default Vue.extend({ > * border-bottom 1px solid var(--faceDivider) - > .renote - color var(--renoteText) - background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%) - - > p - margin 0 - padding 16px 32px - - .avatar - display inline-block - width 28px - height 28px - margin 0 8px 0 0 - border-radius 6px - - [data-icon] - margin-right 4px - - .name - font-weight bold - - & + article - padding-top 8px + > .renote + article + padding-top 8px > .reply-to border-bottom 1px solid var(--faceDivider) @@ -400,6 +302,12 @@ export default Vue.extend({ font-size 16px color var(--noteHeaderInfo) + > .visibility-info + color var(--noteHeaderInfo) + + > .localOnly + margin-left 4px + > footer font-size 1.2em @@ -422,7 +330,8 @@ export default Vue.extend({ > .count display inline margin 0 0 0 8px - color #999 + color var(--text) + opacity 0.7 &.reacted color var(--primary) diff --git a/src/client/app/mobile/views/components/note-preview.vue b/src/client/app/mobile/views/components/note-preview.vue index 525f54998e..448d9bc592 100644 --- a/src/client/app/mobile/views/components/note-preview.vue +++ b/src/client/app/mobile/views/components/note-preview.vue @@ -6,7 +6,7 @@ <div class="body"> <p v-if="note.cw != null" class="cw"> <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> - <mk-cw-button v-model="showContent"/> + <mk-cw-button v-model="showContent" :note="note"/> </p> <div class="content" v-show="note.cw == null || showContent"> <mk-sub-note-content class="text" :note="note"/> diff --git a/src/client/app/mobile/views/components/note.sub.vue b/src/client/app/mobile/views/components/note.sub.vue index 24f5be160c..2d4bdbf420 100644 --- a/src/client/app/mobile/views/components/note.sub.vue +++ b/src/client/app/mobile/views/components/note.sub.vue @@ -5,8 +5,8 @@ <mk-note-header class="header" :note="note" :mini="true"/> <div class="body"> <p v-if="note.cw != null" class="cw"> - <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> - <mk-cw-button v-model="showContent"/> + <mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis" /> + <mk-cw-button v-model="showContent" :note="note"/> </p> <div class="content" v-show="note.cw == null || showContent"> <mk-sub-note-content class="text" :note="note"/> diff --git a/src/client/app/mobile/views/components/note.vue b/src/client/app/mobile/views/components/note.vue index 5b34cfb070..1c7db5dc6c 100644 --- a/src/client/app/mobile/views/components/note.vue +++ b/src/client/app/mobile/views/components/note.vue @@ -9,42 +9,28 @@ <div class="reply-to" v-if="appearNote.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)"> <x-sub :note="appearNote.reply"/> </div> - <div class="renote" v-if="isRenote"> - <mk-avatar class="avatar" :user="note.user"/> - <fa icon="retweet"/> - <span>{{ this.$t('reposted-by').substr(0, this.$t('reposted-by').indexOf('{')) }}</span> - <router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link> - <span>{{ this.$t('reposted-by').substr(this.$t('reposted-by').indexOf('}') + 1) }}</span> - <mk-time :time="note.createdAt"/> - <span class="visibility" v-if="note.visibility != 'public'"> - <fa v-if="note.visibility == 'home'" icon="home"/> - <fa v-if="note.visibility == 'followers'" icon="unlock"/> - <fa v-if="note.visibility == 'specified'" icon="envelope"/> - <fa v-if="note.visibility == 'private'" icon="lock"/> - </span> - <span class="localOnly" v-if="note.localOnly == true"><fa icon="heart"/></span> - </div> + <mk-renote class="renote" v-if="isRenote" :note="note" mini/> <article> <mk-avatar class="avatar" :user="appearNote.user" v-if="$store.state.device.postStyle != 'smart'"/> <div class="main"> <mk-note-header class="header" :note="appearNote" :mini="true"/> <div class="body" v-if="appearNote.deletedAt == null"> <p v-if="appearNote.cw != null" class="cw"> - <span class="text" v-if="appearNote.cw != ''">{{ appearNote.cw }}</span> - <mk-cw-button v-model="showContent"/> + <mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" /> + <mk-cw-button v-model="showContent" :note="appearNote"/> </p> <div class="content" v-show="appearNote.cw == null || showContent"> <div class="text"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</span> <a class="reply" v-if="appearNote.reply"><fa icon="reply"/></a> - <misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :i="$store.state.i" :class="$style.text" :custom-emojis="appearNote.emojis"/> + <mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/> <a class="rp" v-if="appearNote.renote != null">RN:</a> </div> <div class="files" v-if="appearNote.files.length > 0"> <mk-media-list :media-list="appearNote.files"/> </div> <mk-poll v-if="appearNote.poll" :note="appearNote" ref="pollViewer"/> - <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + <mk-url-preview v-for="url in urls" :url="url" :key="url" :compact="compact"/> <a class="location" v-if="appearNote.geo" :href="`https://maps.google.com/maps?q=${appearNote.geo.coordinates[1]},${appearNote.geo.coordinates[0]}`" target="_blank"><fa icon="map-marker-alt"/> {{ $t('location') }}</a> <div class="renote" v-if="appearNote.renote"><mk-note-preview :note="appearNote.renote"/></div> </div> @@ -57,11 +43,17 @@ <template v-else><fa icon="reply"/></template> <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> </button> - <button @click="renote()" title="Renote"> + <button v-if="['public', 'home'].includes(appearNote.visibility)" @click="renote()" title="Renote"> <fa icon="retweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> </button> - <button :class="{ reacted: appearNote.myReaction != null }" @click="react()" ref="reactButton"> - <fa icon="plus"/><p class="count" v-if="appearNote.reactions_count > 0">{{ appearNote.reactions_count }}</p> + <button v-else> + <fa icon="ban"/> + </button> + <button v-if="!isMyNote && appearNote.myReaction == null" class="reactionButton" @click="react()" ref="reactButton"> + <fa icon="plus"/> + </button> + <button v-if="!isMyNote && appearNote.myReaction != null" class="reactionButton reacted" @click="undoReact(appearNote)" ref="reactButton"> + <fa icon="minus"/> </button> <button class="menu" @click="menu()" ref="menuButton"> <fa icon="ellipsis-h"/> @@ -98,6 +90,11 @@ export default Vue.extend({ note: { type: Object, required: true + }, + compact: { + type: Boolean, + required: false, + default: false } } }); @@ -106,7 +103,7 @@ export default Vue.extend({ <style lang="stylus" scoped> .note font-size 12px - border-bottom solid 1px var(--faceDivider) + border-bottom solid var(--lineWidth) var(--faceDivider) &:focus z-index 1 @@ -138,66 +135,8 @@ export default Vue.extend({ align-items center margin-bottom 4px - > .renote - display flex - align-items center - padding 8px 16px - line-height 28px - white-space pre - color var(--renoteText) - background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%) - - @media (min-width 500px) - padding 16px - - @media (min-width 600px) - padding 16px 32px - - .avatar - flex-shrink 0 - display inline-block - width 20px - height 20px - margin 0 8px 0 0 - border-radius 6px - - @media (min-width 500px) - width 28px - height 28px - - [data-icon] - margin-right 4px - - > span - flex-shrink 0 - - .name - overflow hidden - flex-shrink 1 - text-overflow ellipsis - white-space nowrap - font-weight bold - - > .mk-time - display block - margin-left auto - flex-shrink 0 - font-size 0.9em - - > .visibility - margin-left 8px - - [data-icon] - margin-right 0 - - > .localOnly - margin-left 4px - - [data-icon] - margin-right 0 - - & + article - padding-top 8px + > .renote + article + padding-top 8px > article display flex @@ -260,24 +199,6 @@ export default Vue.extend({ overflow-wrap break-word color var(--noteText) - >>> .title - display block - margin-bottom 4px - padding 4px - font-size 90% - text-align center - background var(--mfmTitleBg) - border-radius 4px - - >>> .code - margin 8px 0 - - >>> .quote - margin 8px - padding 6px 12px - color var(--mfmQuote) - border-left solid 3px var(--mfmQuoteLine) - > .reply margin-right 8px color var(--noteText) @@ -287,15 +208,6 @@ export default Vue.extend({ font-style oblique color var(--renoteText) - [data-is-me]:after - content "you" - padding 0 4px - margin-left 4px - font-size 80% - color var(--primaryForeground) - background var(--primary) - border-radius 4px - .mk-url-preview margin-top 8px @@ -324,7 +236,7 @@ export default Vue.extend({ > * padding 16px - border dashed 1px var(--quoteBorder) + border dashed var(--lineWidth) var(--quoteBorder) border-radius 8px > .app @@ -351,7 +263,8 @@ export default Vue.extend({ > .count display inline margin 0 0 0 8px - color #999 + color var(--text) + opacity 0.7 &.reacted color var(--primary) @@ -361,18 +274,3 @@ export default Vue.extend({ opacity 0.7 </style> - -<style lang="stylus" module> -.text - code - padding 4px 8px - margin 0 0.5em - font-size 80% - color #525252 - background #f8f8f8 - border-radius 2px - - pre > code - padding 16px - margin 0 -</style> diff --git a/src/client/app/mobile/views/components/notes.vue b/src/client/app/mobile/views/components/notes.vue index 1ee4a568b6..1d0375cfa9 100644 --- a/src/client/app/mobile/views/components/notes.vue +++ b/src/client/app/mobile/views/components/notes.vue @@ -15,7 +15,7 @@ <!-- トランジションを有効にするとなぜかメモリリークする --> <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="transition" tag="div"> <template v-for="(note, i) in _notes"> - <mk-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/> + <mk-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)" :compact="true"/> <p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date"> <span><fa icon="angle-up"/>{{ note._datetext }}</span> <span><fa icon="angle-down"/>{{ _notes[i + 1]._datetext }}</span> @@ -149,7 +149,9 @@ export default Vue.extend({ }, releaseQueue() { - this.queue.forEach(n => this.prepend(n, true)); + for (const n of this.queue) { + this.prepend(n, true); + } this.queue = []; }, @@ -186,7 +188,7 @@ export default Vue.extend({ overflow hidden background var(--face) border-radius 8px - box-shadow 0 0 2px rgba(#000, 0.1) + box-shadow 0 4px 16px rgba(#000, 0.1) @media (min-width 500px) box-shadow 0 8px 32px rgba(#000, 0.1) @@ -208,7 +210,7 @@ export default Vue.extend({ font-size 0.9em color var(--dateDividerFg) background var(--dateDividerBg) - border-bottom solid 1px var(--faceDivider) + border-bottom solid var(--lineWidth) var(--faceDivider) span margin 0 16px @@ -238,7 +240,7 @@ export default Vue.extend({ > footer text-align center - border-top solid 1px var(--faceDivider) + border-top solid var(--lineWidth) var(--faceDivider) &:empty display none diff --git a/src/client/app/mobile/views/components/notification-preview.vue b/src/client/app/mobile/views/components/notification-preview.vue index 45a2d3b064..1b8eceaa6c 100644 --- a/src/client/app/mobile/views/components/notification-preview.vue +++ b/src/client/app/mobile/views/components/notification-preview.vue @@ -3,7 +3,7 @@ <template v-if="notification.type == 'reaction'"> <mk-avatar class="avatar" :user="notification.user"/> <div class="text"> - <p><mk-reaction-icon :reaction="notification.reaction"/>{{ notification.user | userName }}</p> + <p><mk-reaction-icon :reaction="notification.reaction"/><mk-user-name :user="notification.user"/></p> <p class="note-ref"><fa icon="quote-left"/>{{ getNoteSummary(notification.note) }}<fa icon="quote-right"/></p> </div> </template> @@ -11,7 +11,7 @@ <template v-if="notification.type == 'renote'"> <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> - <p><fa icon="retweet"/>{{ notification.note.user | userName }}</p> + <p><fa icon="retweet"/><mk-user-name :user="notification.note.user"/></p> <p class="note-ref"><fa icon="quote-left"/>{{ getNoteSummary(notification.note.renote) }}<fa icon="quote-right"/></p> </div> </template> @@ -19,7 +19,7 @@ <template v-if="notification.type == 'quote'"> <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> - <p><fa icon="quote-left"/>{{ notification.note.user | userName }}</p> + <p><fa icon="quote-left"/><mk-user-name :user="notification.note.user"/></p> <p class="note-preview">{{ getNoteSummary(notification.note) }}</p> </div> </template> @@ -27,21 +27,21 @@ <template v-if="notification.type == 'follow'"> <mk-avatar class="avatar" :user="notification.user"/> <div class="text"> - <p><fa icon="user-plus"/>{{ notification.user | userName }}</p> + <p><fa icon="user-plus"/><mk-user-name :user="notification.user"/></p> </div> </template> <template v-if="notification.type == 'receiveFollowRequest'"> <mk-avatar class="avatar" :user="notification.user"/> <div class="text"> - <p><fa icon="user-clock"/>{{ notification.user | userName }}</p> + <p><fa icon="user-clock"/><mk-user-name :user="notification.user"/></p> </div> </template> <template v-if="notification.type == 'reply'"> <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> - <p><fa icon="reply"/>{{ notification.note.user | userName }}</p> + <p><fa icon="reply"/><mk-user-name :user="notification.note.user"/></p> <p class="note-preview">{{ getNoteSummary(notification.note) }}</p> </div> </template> @@ -49,7 +49,7 @@ <template v-if="notification.type == 'mention'"> <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> - <p><fa icon="at"/>{{ notification.note.user | userName }}</p> + <p><fa icon="at"/><mk-user-name :user="notification.note.user"/></p> <p class="note-preview">{{ getNoteSummary(notification.note) }}</p> </div> </template> @@ -57,7 +57,7 @@ <template v-if="notification.type == 'poll_vote'"> <mk-avatar class="avatar" :user="notification.user"/> <div class="text"> - <p><fa icon="chart-pie"/>{{ notification.user | userName }}</p> + <p><fa icon="chart-pie"/><mk-user-name :user="notification.user"/></p> <p class="note-ref"><fa icon="quote-left"/>{{ getNoteSummary(notification.note) }}<fa icon="quote-right"/></p> </div> </template> diff --git a/src/client/app/mobile/views/components/notification.vue b/src/client/app/mobile/views/components/notification.vue index c99b291ff6..3f9cefd00c 100644 --- a/src/client/app/mobile/views/components/notification.vue +++ b/src/client/app/mobile/views/components/notification.vue @@ -5,11 +5,12 @@ <div> <header> <mk-reaction-icon :reaction="notification.reaction"/> - <router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link> + <router-link :to="notification.user | userPage"><mk-user-name :user="notification.user"/></router-link> <mk-time :time="notification.createdAt"/> </header> - <router-link class="note-ref" :to="notification.note | notePage"> - <fa icon="quote-left"/>{{ getNoteSummary(notification.note) }} + <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <fa icon="quote-left"/> + <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> <fa icon="quote-right"/> </router-link> </div> @@ -20,11 +21,13 @@ <div> <header> <fa icon="retweet"/> - <router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link> + <router-link :to="notification.user | userPage"><mk-user-name :user="notification.user"/></router-link> <mk-time :time="notification.createdAt"/> </header> - <router-link class="note-ref" :to="notification.note | notePage"> - <fa icon="quote-left"/>{{ getNoteSummary(notification.note.renote) }}<fa icon="quote-right"/> + <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)"> + <fa icon="quote-left"/> + <mfm :text="getNoteSummary(notification.note.renote)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.renote.emojis"/> + <fa icon="quote-right"/> </router-link> </div> </div> @@ -34,7 +37,7 @@ <div> <header> <fa icon="user-plus"/> - <router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link> + <router-link :to="notification.user | userPage"><mk-user-name :user="notification.user"/></router-link> <mk-time :time="notification.createdAt"/> </header> </div> @@ -45,7 +48,7 @@ <div> <header> <fa icon="user-clock"/> - <router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link> + <router-link :to="notification.user | userPage"><mk-user-name :user="notification.user"/></router-link> <mk-time :time="notification.createdAt"/> </header> </div> @@ -56,11 +59,13 @@ <div> <header> <fa icon="chart-pie"/> - <router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link> + <router-link :to="notification.user | userPage"><mk-user-name :user="notification.user"/></router-link> <mk-time :time="notification.createdAt"/> </header> - <router-link class="note-ref" :to="notification.note | notePage"> - <fa icon="quote-left"/>{{ getNoteSummary(notification.note) }}<fa icon="quote-right"/> + <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <fa icon="quote-left"/> + <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + <fa icon="quote-right"/> </router-link> </div> </div> @@ -162,6 +167,11 @@ export default Vue.extend({ > .note-ref color var(--noteText) + display inline-block + width: 100% + overflow hidden + white-space nowrap + text-overflow ellipsis [data-icon] font-size 1em diff --git a/src/client/app/mobile/views/components/notifications.vue b/src/client/app/mobile/views/components/notifications.vue index 207b2fa50b..fa247caeab 100644 --- a/src/client/app/mobile/views/components/notifications.vue +++ b/src/client/app/mobile/views/components/notifications.vue @@ -7,7 +7,7 @@ </div> <!-- トランジションを有効にするとなぜかメモリリークする --> - <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications"> + <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications" tag="div"> <template v-for="(notification, i) in _notifications"> <mk-notification :notification="notification" :key="notification.id"/> <p class="date" :key="notification.id + '_date'" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date"> @@ -152,7 +152,7 @@ export default Vue.extend({ > .notifications > .mk-notification:not(:last-child) - border-bottom solid 1px var(--faceDivider) + border-bottom solid var(--lineWidth) var(--faceDivider) > .date display block @@ -162,7 +162,7 @@ export default Vue.extend({ font-size 0.8em color var(--dateDividerFg) background var(--dateDividerBg) - border-bottom solid 1px var(--faceDivider) + border-bottom solid var(--lineWidth) var(--faceDivider) span margin 0 16px @@ -175,7 +175,7 @@ export default Vue.extend({ width 100% padding 16px color var(--text) - border-top solid 1px rgba(#000, 0.05) + border-top solid var(--lineWidth) rgba(#000, 0.05) > [data-icon] margin-right 4px diff --git a/src/client/app/mobile/views/components/notify.vue b/src/client/app/mobile/views/components/notify.vue index 5f94b91ddd..c6e1df0fde 100644 --- a/src/client/app/mobile/views/components/notify.vue +++ b/src/client/app/mobile/views/components/notify.vue @@ -8,7 +8,7 @@ <script lang="ts"> import Vue from 'vue'; -import * as anime from 'animejs'; +import anime from 'animejs'; export default Vue.extend({ props: ['notification'], diff --git a/src/client/app/mobile/views/components/post-form-dialog.vue b/src/client/app/mobile/views/components/post-form-dialog.vue index 15b36db945..672c76b289 100644 --- a/src/client/app/mobile/views/components/post-form-dialog.vue +++ b/src/client/app/mobile/views/components/post-form-dialog.vue @@ -5,6 +5,7 @@ <mk-post-form ref="form" :reply="reply" :renote="renote" + :mention="mention" :initial-text="initialText" :instant="instant" @posted="onPosted" @@ -15,7 +16,7 @@ <script lang="ts"> import Vue from 'vue'; -import * as anime from 'animejs'; +import anime from 'animejs'; export default Vue.extend({ props: { @@ -27,6 +28,10 @@ export default Vue.extend({ type: Object, required: false }, + mention: { + type: Object, + required: false + }, initialText: { type: String, required: false diff --git a/src/client/app/mobile/views/components/post-form.vue b/src/client/app/mobile/views/components/post-form.vue index de389baf69..58718e2a98 100644 --- a/src/client/app/mobile/views/components/post-form.vue +++ b/src/client/app/mobile/views/components/post-form.vue @@ -13,11 +13,14 @@ <mk-note-preview class="preview" v-if="reply" :note="reply"/> <mk-note-preview class="preview" v-if="renote" :note="renote"/> <div v-if="visibility == 'specified'" class="visibleUsers"> - <span v-for="u in visibleUsers">{{ u | userName }}<a @click="removeVisibleUser(u)">[x]</a></span> + <span v-for="u in visibleUsers"> + <mk-user-name :user="u"/> + <a @click="removeVisibleUser(u)">[x]</a> + </span> <a @click="addVisibleUser">+{{ $t('add-visible-user') }}</a> </div> - <input v-show="useCw" v-model="cw" :placeholder="$t('annotations')"> - <textarea v-model="text" ref="text" :disabled="posting" :placeholder="placeholder" v-autocomplete="'text'"></textarea> + <input v-show="useCw" ref="cw" v-model="cw" :placeholder="$t('annotations')" v-autocomplete="{ model: 'cw' }"> + <textarea v-model="text" ref="text" :disabled="posting" :placeholder="placeholder" v-autocomplete="{ model: 'text' }"></textarea> <div class="attaches" v-show="files.length != 0"> <x-draggable class="files" :list="files" :options="{ animation: 150 }"> <div class="file" v-for="file in files" :key="file.id"> @@ -25,7 +28,7 @@ </div> </x-draggable> </div> - <mk-poll-editor v-if="poll" ref="poll" @destroyed="poll = false"/> + <mk-poll-editor v-if="poll" ref="poll" @destroyed="poll = false" @updated="onPollUpdate()"/> <mk-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/> <footer> <button class="upload" @click="chooseFile"><fa icon="upload"/></button> @@ -39,7 +42,6 @@ <span v-if="visibility === 'home'"><fa icon="home"/></span> <span v-if="visibility === 'followers'"><fa icon="unlock"/></span> <span v-if="visibility === 'specified'"><fa icon="envelope"/></span> - <span v-if="visibility === 'private'"><fa icon="lock"/></span> </button> </footer> <input ref="file" class="file" type="file" multiple="multiple" @change="onChangeFile"/> @@ -62,8 +64,8 @@ import parse from '../../../../../mfm/parse'; import { host } from '../../../config'; import { erase, unique } from '../../../../../prelude/array'; import { length } from 'stringz'; -import parseAcct from '../../../../../misc/acct/parse'; import { toASCII } from 'punycode'; +import extractMentions from '../../../../../misc/extract-mentions'; export default Vue.extend({ i18n: i18n('mobile/views/components/post-form.vue'), @@ -81,6 +83,10 @@ export default Vue.extend({ type: Object, required: false }, + mention: { + type: Object, + required: false + }, initialText: { type: String, required: false @@ -99,6 +105,7 @@ export default Vue.extend({ uploadings: [], files: [], poll: false, + pollChoices: [], geo: null, visibility: 'public', visibleUsers: [], @@ -154,7 +161,8 @@ export default Vue.extend({ canPost(): boolean { return !this.posting && (1 <= this.text.length || 1 <= this.files.length || this.poll || this.renote) && - (this.text.trim().length <= this.maxNoteTextLength); + (this.text.trim().length <= this.maxNoteTextLength) && + (!this.poll || this.pollChoices.length >= 2); } }, @@ -167,34 +175,38 @@ export default Vue.extend({ this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `; } + if (this.mention) { + this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`; + this.text += ' '; + } + if (this.reply && this.reply.text != null) { const ast = parse(this.reply.text); - ast.filter(t => t.type == 'mention').forEach(x => { + for (const x of extractMentions(ast)) { const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`; // 自分は除外 - if (this.$store.state.i.username == x.username && x.host == null) return; - if (this.$store.state.i.username == x.username && x.host == host) return; + if (this.$store.state.i.username == x.username && x.host == null) continue; + if (this.$store.state.i.username == x.username && x.host == host) continue; // 重複は除外 - if (this.text.indexOf(`${mention} `) != -1) return; + if (this.text.indexOf(`${mention} `) != -1) continue; this.text += `${mention} `; - }); + } } // デフォルト公開範囲 this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility); // 公開以外へのリプライ時は元の公開範囲を引き継ぐ - if (this.reply && ['home', 'followers', 'specified', 'private'].includes(this.reply.visibility)) { + if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) { this.visibility = this.reply.visibility; } - // ダイレクトへのリプライはリプライ先ユーザーを初期設定 - if (this.reply && this.reply.visibility === 'specified') { - this.$root.api('users/show', { userId: this.reply.userId }).then(user => { + if (this.reply) { + this.$root.api('users/show', { userId: this.reply.userId }).then(user => { this.visibleUsers.push(user); }); } @@ -219,6 +231,16 @@ export default Vue.extend({ (this.$refs.text as any).focus(); }, + addVisibleUser() { + this.$root.dialog({ + title: this.$t('enter-username'), + user: true + }).then(({ canceled, result: user }) => { + if (canceled) return; + this.visibleUsers.push(user); + }); + }, + chooseFile() { (this.$refs.file as any).click(); }, @@ -227,7 +249,7 @@ export default Vue.extend({ this.$chooseDriveFile({ multiple: true }).then(files => { - files.forEach(this.attachMedia); + for (const x of files) this.attachMedia(x); }); }, @@ -242,7 +264,11 @@ export default Vue.extend({ }, onChangeFile() { - Array.from((this.$refs.file as any).files).forEach(this.upload); + for (const x of Array.from((this.$refs.file as any).files)) this.upload(x); + }, + + onPollUpdate() { + this.pollChoices = this.$refs.poll.get().choices; }, upload(file) { @@ -275,7 +301,7 @@ export default Vue.extend({ setVisibility() { const w = this.$root.new(MkVisibilityChooser, { source: this.$refs.visibilityButton, - compact: true + currentVisibility: this.visibility }); w.$once('chosen', v => { this.applyVisibility(v); diff --git a/src/client/app/mobile/views/components/sub-note-content.vue b/src/client/app/mobile/views/components/sub-note-content.vue index f4c86f19d2..66dbb90ebb 100644 --- a/src/client/app/mobile/views/components/sub-note-content.vue +++ b/src/client/app/mobile/views/components/sub-note-content.vue @@ -4,7 +4,7 @@ <span v-if="note.isHidden" style="opacity: 0.5">({{ $t('private') }})</span> <span v-if="note.deletedAt" style="opacity: 0.5">({{ $t('deleted') }})</span> <a class="reply" v-if="note.replyId"><fa icon="reply"/></a> - <misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i" :custom-emojis="note.emojis"/> + <mfm v-if="note.text" :text="note.text" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis"/> <a class="rp" v-if="note.renoteId">RN: ...</a> </div> <details v-if="note.files.length > 0"> diff --git a/src/client/app/mobile/views/components/ui.header.vue b/src/client/app/mobile/views/components/ui.header.vue index 2bd472f38e..33420581f6 100644 --- a/src/client/app/mobile/views/components/ui.header.vue +++ b/src/client/app/mobile/views/components/ui.header.vue @@ -19,7 +19,6 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import * as anime from 'animejs'; import { env } from '../../../config'; export default Vue.extend({ @@ -45,13 +44,13 @@ export default Vue.extend({ }, mounted() { - this.$store.commit('setUiHeaderHeight', this.$refs.root.offsetHeight); + this.$store.commit('setUiHeaderHeight', 48); if (this.$store.getters.isSignedIn) { this.connection = this.$root.stream.useSharedConnection('main'); this.connection.on('reversiInvited', this.onReversiInvited); - this.connection.on('reversi_no_invites', this.onReversiNoInvites); + this.connection.on('reversiNoInvites', this.onReversiNoInvites); } }, @@ -79,9 +78,11 @@ export default Vue.extend({ position fixed top 0 + left -8px z-index 1024 - width 100% - box-shadow 0 1px 0 rgba(#000, 0.075) + width calc(100% + 16px) + padding 0 8px + box-shadow 0 0px 8px rgba(0, 0, 0, 0.25) &, * user-select none @@ -157,7 +158,7 @@ export default Vue.extend({ left 8px pointer-events none font-size 10px - color var(--primary) + color var(--notificationIndicator) > button:last-child display block diff --git a/src/client/app/mobile/views/components/ui.nav.vue b/src/client/app/mobile/views/components/ui.nav.vue index 4f085a5e6d..7659d89b8d 100644 --- a/src/client/app/mobile/views/components/ui.nav.vue +++ b/src/client/app/mobile/views/components/ui.nav.vue @@ -11,27 +11,27 @@ <div class="body" v-if="isOpen"> <router-link class="me" v-if="$store.getters.isSignedIn" :to="`/@${$store.state.i.username}`"> <img class="avatar" :src="$store.state.i.avatarUrl" alt="avatar"/> - <p class="name">{{ $store.state.i | userName }}</p> + <p class="name"><mk-user-name :user="$store.state.i"/></p> </router-link> <div class="links"> <ul> - <li><router-link to="/" :data-active="$route.name == 'index'"><i><fa icon="home"/></i>{{ $t('timeline') }}<i><fa icon="angle-right"/></i></router-link></li> - <li><router-link to="/i/notifications" :data-active="$route.name == 'notifications'"><i><fa :icon="['far', 'bell']"/></i>{{ $t('notifications') }}<i v-if="hasUnreadNotification" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> - <li><router-link to="/i/messaging" :data-active="$route.name == 'messaging'"><i><fa :icon="['far', 'comments']"/></i>{{ $t('@.messaging') }}<i v-if="hasUnreadMessagingMessage" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> - <li v-if="$store.getters.isSignedIn && ($store.state.i.isLocked || $store.state.i.carefulBot)"><router-link to="/i/received-follow-requests" :data-active="$route.name == 'received-follow-requests'"><i><fa :icon="['far', 'envelope']"/></i>{{ $t('follow-requests') }}<i v-if="$store.getters.isSignedIn && $store.state.i.pendingReceivedFollowRequestsCount" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> - <li><router-link to="/reversi" :data-active="$route.name == 'reversi'"><i><fa icon="gamepad"/></i>{{ $t('game') }}<i v-if="hasGameInvitation" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> + <li><router-link to="/" :data-active="$route.name == 'index'"><i><fa icon="home" fixed-width/></i>{{ $t('timeline') }}<i><fa icon="angle-right"/></i></router-link></li> + <li><router-link to="/i/notifications" :data-active="$route.name == 'notifications'"><i><fa :icon="['far', 'bell']" fixed-width/></i>{{ $t('notifications') }}<i v-if="hasUnreadNotification" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> + <li><router-link to="/i/messaging" :data-active="$route.name == 'messaging'"><i><fa :icon="['far', 'comments']" fixed-width/></i>{{ $t('@.messaging') }}<i v-if="hasUnreadMessagingMessage" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> + <li v-if="$store.getters.isSignedIn && ($store.state.i.isLocked || $store.state.i.carefulBot)"><router-link to="/i/received-follow-requests" :data-active="$route.name == 'received-follow-requests'"><i><fa :icon="['far', 'envelope']" fixed-width/></i>{{ $t('follow-requests') }}<i v-if="$store.getters.isSignedIn && $store.state.i.pendingReceivedFollowRequestsCount" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> + <li><router-link to="/games/reversi" :data-active="$route.name == 'reversi'"><i><fa icon="gamepad" fixed-width/></i>{{ $t('game') }}<i v-if="hasGameInvitation" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> </ul> <ul> - <li><router-link to="/i/widgets" :data-active="$route.name == 'widgets'"><i><fa :icon="['far', 'calendar-alt']"/></i>{{ $t('widgets') }}<i><fa icon="angle-right"/></i></router-link></li> - <li><router-link to="/i/favorites" :data-active="$route.name == 'favorites'"><i><fa icon="star"/></i>{{ $t('favorites') }}<i><fa icon="angle-right"/></i></router-link></li> - <li><router-link to="/i/lists" :data-active="$route.name == 'user-lists'"><i><fa icon="list"/></i>{{ $t('user-lists') }}<i><fa icon="angle-right"/></i></router-link></li> - <li><router-link to="/i/drive" :data-active="$route.name == 'drive'"><i><fa icon="cloud"/></i>{{ $t('@.drive') }}<i><fa icon="angle-right"/></i></router-link></li> + <li><router-link to="/i/widgets" :data-active="$route.name == 'widgets'"><i><fa :icon="['far', 'calendar-alt']" fixed-width/></i>{{ $t('widgets') }}<i><fa icon="angle-right"/></i></router-link></li> + <li><router-link to="/i/favorites" :data-active="$route.name == 'favorites'"><i><fa icon="star" fixed-width/></i>{{ $t('favorites') }}<i><fa icon="angle-right"/></i></router-link></li> + <li><router-link to="/i/lists" :data-active="$route.name == 'user-lists'"><i><fa icon="list" fixed-width/></i>{{ $t('user-lists') }}<i><fa icon="angle-right"/></i></router-link></li> + <li><router-link to="/i/drive" :data-active="$route.name == 'drive'"><i><fa icon="cloud" fixed-width/></i>{{ $t('@.drive') }}<i><fa icon="angle-right"/></i></router-link></li> </ul> <ul> - <li><a @click="search"><i><fa icon="search"/></i>{{ $t('search') }}<i><fa icon="angle-right"/></i></a></li> - <li><router-link to="/i/settings" :data-active="$route.name == 'settings'"><i><fa icon="cog"/></i>{{ $t('settings') }}<i><fa icon="angle-right"/></i></router-link></li> - <li v-if="$store.getters.isSignedIn && ($store.state.i.isAdmin || $store.state.i.isModerator)"><a href="/admin"><i><fa icon="terminal"/></i><span>{{ $t('admin') }}</span><i><fa icon="angle-right"/></i></a></li> - <li @click="dark"><p><template v-if="$store.state.device.darkmode"><i><fa icon="moon"/></i></template><template v-else><i><fa :icon="['far', 'moon']"/></i></template><span>{{ $t('darkmode') }}</span></p></li> + <li><a @click="search"><i><fa icon="search" fixed-width/></i>{{ $t('search') }}<i><fa icon="angle-right"/></i></a></li> + <li><router-link to="/i/settings" :data-active="$route.name == 'settings'"><i><fa icon="cog" fixed-width/></i>{{ $t('settings') }}<i><fa icon="angle-right"/></i></router-link></li> + <li v-if="$store.getters.isSignedIn && ($store.state.i.isAdmin || $store.state.i.isModerator)"><a href="/admin"><i><fa icon="terminal" fixed-width/></i><span>{{ $t('admin') }}</span><i><fa icon="angle-right"/></i></a></li> + <li @click="dark"><p><template v-if="$store.state.device.darkmode"><i><fa icon="moon" fixed-width/></i></template><template v-else><i><fa :icon="['far', 'moon']"/></i></template><span>{{ $t('darkmode') }}</span></p></li> </ul> </div> <div class="announcements" v-if="announcements && announcements.length > 0"> @@ -60,7 +60,8 @@ export default Vue.extend({ hasGameInvitation: false, connection: null, aboutUrl: `/docs/${lang}/about`, - announcements: [] + announcements: [], + searching: false, }; }, @@ -83,7 +84,7 @@ export default Vue.extend({ this.connection = this.$root.stream.useSharedConnection('main'); this.connection.on('reversiInvited', this.onReversiInvited); - this.connection.on('reversi_no_invites', this.onReversiNoInvites); + this.connection.on('reversiNoInvites', this.onReversiNoInvites); } }, @@ -95,9 +96,38 @@ export default Vue.extend({ methods: { search() { - const query = window.prompt(this.$t('search')); - if (query == null || query == '') return; - this.$router.push(`/search?q=${encodeURIComponent(query)}`); + if (this.searching) return; + + this.$root.dialog({ + title: this.$t('search'), + input: true + }).then(async ({ canceled, result: query }) => { + if (canceled) return; + + const q = query.trim(); + if (q.startsWith('@')) { + this.$router.push(`/${q}`); + } else if (q.startsWith('#')) { + this.$router.push(`/tags/${encodeURIComponent(q.substr(1))}`); + } else if (q.startsWith('https://')) { + this.searching = true; + try { + const res = await this.$root.api('ap/show', { + uri: q + }); + if (res.type == 'User') { + this.$router.push(`/@${res.object.username}@${res.object.host}`); + } else if (res.type == 'Note') { + this.$router.push(`/notes/${res.object.id}`); + } + } catch (e) { + // TODO + } + this.searching = false; + } else { + this.$router.push(`/search?q=${encodeURIComponent(q)}`); + } + }); }, onReversiInvited() { @@ -208,7 +238,7 @@ export default Vue.extend({ > i.circle margin-left 6px font-size 10px - color var(--primary) + color var(--notificationIndicator) > i:last-child position absolute diff --git a/src/client/app/mobile/views/components/user-card.vue b/src/client/app/mobile/views/components/user-card.vue index 6d79e7a872..f880f02ac2 100644 --- a/src/client/app/mobile/views/components/user-card.vue +++ b/src/client/app/mobile/views/components/user-card.vue @@ -3,7 +3,9 @@ <header :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"> <mk-avatar class="avatar" :user="user"/> </header> - <a class="name" :href="user | userPage" target="_blank">{{ user | userName }}</a> + <a class="name" :href="user | userPage" target="_blank"> + <mk-user-name :user="user"/> + </a> <p class="username"><mk-acct :user="user"/></p> <mk-follow-button class="follow-button" :user="user"/> </div> diff --git a/src/client/app/mobile/views/components/user-list-timeline.vue b/src/client/app/mobile/views/components/user-list-timeline.vue index a9597319df..d90051710b 100644 --- a/src/client/app/mobile/views/components/user-list-timeline.vue +++ b/src/client/app/mobile/views/components/user-list-timeline.vue @@ -94,7 +94,9 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) { + (this.$refs.timeline as any).append(n); + } this.moreFetching = false; }); diff --git a/src/client/app/mobile/views/components/user-preview.vue b/src/client/app/mobile/views/components/user-preview.vue index a165e66a9d..b40e6f7619 100644 --- a/src/client/app/mobile/views/components/user-preview.vue +++ b/src/client/app/mobile/views/components/user-preview.vue @@ -3,11 +3,15 @@ <mk-avatar class="avatar" :user="user"/> <div class="main"> <header> - <router-link class="name" :to="user | userPage">{{ user | userName }}</router-link> + <router-link class="name" :to="user | userPage"> + <mk-user-name :user="user"/> + </router-link> <span class="username"><mk-acct :user="user"/></span> </header> <div class="body"> - <div class="description">{{ user.description }}</div> + <div class="description"> + <mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> + </div> </div> </div> </div> diff --git a/src/client/app/mobile/views/components/user-timeline.vue b/src/client/app/mobile/views/components/user-timeline.vue index e8d7adc8b5..0d0bbc4073 100644 --- a/src/client/app/mobile/views/components/user-timeline.vue +++ b/src/client/app/mobile/views/components/user-timeline.vue @@ -76,7 +76,9 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) { + (this.$refs.timeline as any).append(n); + } this.moreFetching = false; }); diff --git a/src/client/app/mobile/views/pages/favorites.vue b/src/client/app/mobile/views/pages/favorites.vue index 6ca584ec88..61dd1526ba 100644 --- a/src/client/app/mobile/views/pages/favorites.vue +++ b/src/client/app/mobile/views/pages/favorites.vue @@ -3,9 +3,11 @@ <span slot="header"><span style="margin-right:4px;"><fa icon="star"/></span>{{ $t('title') }}</span> <main> - <template v-for="favorite in favorites"> - <mk-note-detail class="post" :note="favorite.note" :key="favorite.note.id"/> - </template> + <sequential-entrance animation="entranceFromTop" delay="25"> + <template v-for="favorite in favorites"> + <mk-note-detail class="post" :note="favorite.note" :key="favorite.note.id"/> + </template> + </sequential-entrance> <ui-button v-if="existMore" @click="more">{{ $t('@.load-more') }}</ui-button> </main> </mk-ui> @@ -79,13 +81,13 @@ main margin 0 auto padding 8px - > .post + > * > .post margin-bottom 8px @media (min-width 500px) padding 16px - > .post + > * > .post margin-bottom 16px @media (min-width 600px) diff --git a/src/client/app/mobile/views/pages/followers.vue b/src/client/app/mobile/views/pages/followers.vue index e5efe185c8..f5ac8ef195 100644 --- a/src/client/app/mobile/views/pages/followers.vue +++ b/src/client/app/mobile/views/pages/followers.vue @@ -1,7 +1,8 @@ <template> <mk-ui> <template slot="header" v-if="!fetching"> - <img :src="user.avatarUrl" alt="">{{ $t('followers-of', { name }) }} + <img :src="user.avatarUrl" alt=""> + <mfm :text="$t('followers-of', { name })" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/> </template> <mk-users-list v-if="!fetching" diff --git a/src/client/app/mobile/views/pages/following.vue b/src/client/app/mobile/views/pages/following.vue index e55a3b4cd6..d603532498 100644 --- a/src/client/app/mobile/views/pages/following.vue +++ b/src/client/app/mobile/views/pages/following.vue @@ -1,7 +1,8 @@ <template> <mk-ui> <template slot="header" v-if="!fetching"> - <img :src="user.avatarUrl" alt="">{{ $t('following-of', { name }) }} + <img :src="user.avatarUrl" alt=""> + <mfm :text="$t('following-of', { name })" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/> </template> <mk-users-list v-if="!fetching" diff --git a/src/client/app/mobile/views/pages/games/reversi.vue b/src/client/app/mobile/views/pages/games/reversi.vue index 983fb07821..4f371b9b5a 100644 --- a/src/client/app/mobile/views/pages/games/reversi.vue +++ b/src/client/app/mobile/views/pages/games/reversi.vue @@ -20,10 +20,10 @@ export default Vue.extend({ methods: { nav(game, actualNav) { if (actualNav) { - this.$router.push(`/reversi/${game.id}`); + this.$router.push(`/games/reversi/${game.id}`); } else { // TODO: https://github.com/vuejs/vue-router/issues/703 - this.$router.push(`/reversi/${game.id}`); + this.$router.push(`/games/reversi/${game.id}`); } } } diff --git a/src/client/app/mobile/views/pages/home.timeline.vue b/src/client/app/mobile/views/pages/home.timeline.vue index 0699a169e4..91c99bc8fe 100644 --- a/src/client/app/mobile/views/pages/home.timeline.vue +++ b/src/client/app/mobile/views/pages/home.timeline.vue @@ -150,7 +150,9 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) { + (this.$refs.timeline as any).append(n); + } this.moreFetching = false; }); diff --git a/src/client/app/mobile/views/pages/home.vue b/src/client/app/mobile/views/pages/home.vue index ed057c2207..9adf716b32 100644 --- a/src/client/app/mobile/views/pages/home.vue +++ b/src/client/app/mobile/views/pages/home.vue @@ -31,7 +31,7 @@ <span :data-active="src == 'home'" @click="src = 'home'"><fa icon="home"/> {{ $t('home') }}</span> <span :data-active="src == 'local'" @click="src = 'local'" v-if="enableLocalTimeline"><fa :icon="['far', 'comments']"/> {{ $t('local') }}</span> <span :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline"><fa icon="share-alt"/> {{ $t('hybrid') }}</span> - <span :data-active="src == 'global'" @click="src = 'global'"><fa icon="globe"/> {{ $t('global') }}</span> + <span :data-active="src == 'global'" @click="src = 'global'" v-if="enableGlobalTimeline"><fa icon="globe"/> {{ $t('global') }}</span> <div class="hr"></div> <span :data-active="src == 'mentions'" @click="src = 'mentions'"><fa icon="at"/> {{ $t('mentions') }}<i class="badge" v-if="$store.state.i.hasUnreadMentions"><fa icon="circle"/></i></span> <span :data-active="src == 'messages'" @click="src = 'messages'"><fa :icon="['far', 'envelope']"/> {{ $t('messages') }}<i class="badge" v-if="$store.state.i.hasUnreadSpecifiedNotes"><fa icon="circle"/></i></span> @@ -79,7 +79,8 @@ export default Vue.extend({ lists: null, tagTl: null, showNav: false, - enableLocalTimeline: false + enableLocalTimeline: false, + enableGlobalTimeline: false, }; }, @@ -112,7 +113,8 @@ export default Vue.extend({ created() { this.$root.getMeta().then(meta => { - this.enableLocalTimeline = !meta.disableLocalTimeline; + this.enableLocalTimeline = !meta.disableLocalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin; + this.enableGlobalTimeline = !meta.disableGlobalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin; }); if (this.$store.state.device.tl) { @@ -225,7 +227,7 @@ main > .badge margin-left 6px font-size 10px - color var(--primary) + color var(--notificationIndicator) > .tl max-width 680px @@ -248,7 +250,7 @@ main .badge margin-left 6px font-size 10px - color var(--primary) + color var(--notificationIndicator) vertical-align middle </style> diff --git a/src/client/app/mobile/views/pages/messaging-room.vue b/src/client/app/mobile/views/pages/messaging-room.vue index 648a5d72e9..7c18dbfed8 100644 --- a/src/client/app/mobile/views/pages/messaging-room.vue +++ b/src/client/app/mobile/views/pages/messaging-room.vue @@ -1,7 +1,7 @@ <template> <mk-ui> <span slot="header"> - <template v-if="user"><span style="margin-right:4px;"><fa :icon="['far', 'comments']"/></span>{{ user | userName }}</template> + <template v-if="user"><span style="margin-right:4px;"><fa :icon="['far', 'comments']"/></span><mk-user-name :user="user"/></template> <template v-else><mk-ellipsis/></template> </span> <x-messaging-room v-if="!fetching" :user="user" :is-naked="true"/> diff --git a/src/client/app/mobile/views/pages/notifications.vue b/src/client/app/mobile/views/pages/notifications.vue index b64e86d688..c6e5b646f2 100644 --- a/src/client/app/mobile/views/pages/notifications.vue +++ b/src/client/app/mobile/views/pages/notifications.vue @@ -23,12 +23,12 @@ export default Vue.extend({ }, methods: { fn() { - this.$root.alert({ + this.$root.dialog({ type: 'warning', text: this.$t('read-all'), showCancelButton: true - }).then(res => { - if (!res) return; + }).then(({ canceled }) => { + if (canceled) return; this.$root.api('notifications/mark_all_as_read'); }); @@ -41,8 +41,6 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> - - main width 100% max-width 680px diff --git a/src/client/app/mobile/views/pages/received-follow-requests.vue b/src/client/app/mobile/views/pages/received-follow-requests.vue index 48c46f64cd..1b8323e834 100644 --- a/src/client/app/mobile/views/pages/received-follow-requests.vue +++ b/src/client/app/mobile/views/pages/received-follow-requests.vue @@ -4,7 +4,9 @@ <main> <div v-for="req in requests"> - <router-link :key="req.id" :to="req.follower | userPage">{{ req.follower | userName }}</router-link> + <router-link :key="req.id" :to="req.follower | userPage"> + <mk-user-name :user="req.follower"/> + </router-link> <span> <a @click="accept(req.follower)">{{ $t('accept') }}</a>|<a @click="reject(req.follower)">{{ $t('reject') }}</a> </span> diff --git a/src/client/app/mobile/views/pages/search.vue b/src/client/app/mobile/views/pages/search.vue index fbabfdf6a0..669e0b740b 100644 --- a/src/client/app/mobile/views/pages/search.vue +++ b/src/client/app/mobile/views/pages/search.vue @@ -77,7 +77,9 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) { + (this.$refs.timeline as any).append(n); + } this.moreFetching = false; }); diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue index 0f54933925..80b15acdea 100644 --- a/src/client/app/mobile/views/pages/settings.vue +++ b/src/client/app/mobile/views/pages/settings.vue @@ -2,17 +2,13 @@ <mk-ui> <span slot="header"><span style="margin-right:4px;"><fa icon="cog"/></span>{{ $t('settings') }}</span> <main> - <div class="signin-as" v-html="this.$t('signed-in-as').replace('{}', `<b>${name}</b>`)"></div> - + <div class="signed-in-as"> + <mfm :text="$t('signed-in-as').replace('{}', name)" :should-break="false" :plain-text="true" :custom-emojis="$store.state.i.emojis"/> + </div> <div> <x-profile-editor/> - <ui-card> - <div slot="title"><fa icon="palette"/> {{ $t('theme') }}</div> - <section> - <x-theme/> - </section> - </ui-card> + <x-theme/> <ui-card> <div slot="title"><fa icon="poll-h"/> {{ $t('design') }}</div> @@ -20,6 +16,12 @@ <section> <ui-switch v-model="darkmode">{{ $t('dark-mode') }}</ui-switch> <ui-switch v-model="circleIcons">{{ $t('circle-icons') }}</ui-switch> + <section> + <header>{{ $t('@.line-width') }}</header> + <ui-radio v-model="lineWidth" :value="0.5">{{ $t('@.line-width-thin') }}</ui-radio> + <ui-radio v-model="lineWidth" :value="1">{{ $t('@.line-width-normal') }}</ui-radio> + <ui-radio v-model="lineWidth" :value="2">{{ $t('@.line-width-thick') }}</ui-radio> + </section> <ui-switch v-model="reduceMotion">{{ $t('@.reduce-motion') }} ({{ $t('@.this-setting-is-this-device-only') }})</ui-switch> <ui-switch v-model="contrastedAcct">{{ $t('contrasted-acct') }}</ui-switch> <ui-switch v-model="showFullAcct">{{ $t('@.show-full-acct') }}</ui-switch> @@ -27,12 +29,13 @@ <ui-switch v-model="useOsDefaultEmojis">{{ $t('@.use-os-default-emojis') }}</ui-switch> <ui-switch v-model="iLikeSushi">{{ $t('@.i-like-sushi') }}</ui-switch> <ui-switch v-model="disableAnimatedMfm">{{ $t('@.disable-animated-mfm') }}</ui-switch> + <ui-switch v-model="suggestRecentHashtags">{{ $t('@.suggest-recent-hashtags') }}</ui-switch> <ui-switch v-model="alwaysShowNsfw">{{ $t('@.always-show-nsfw') }} ({{ $t('@.this-setting-is-this-device-only') }})</ui-switch> </section> <section> <ui-switch v-model="games_reversi_showBoardLabels">{{ $t('@.show-reversi-board-labels') }}</ui-switch> - <ui-switch v-model="games_reversi_useContrastStones">{{ $t('@.use-contrast-reversi-stones') }}</ui-switch> + <ui-switch v-model="games_reversi_useAvatarStones">{{ $t('@.use-avatar-reversi-stones') }}</ui-switch> </section> <section> @@ -79,82 +82,36 @@ <option value="home">{{ $t('@.note-visibility.home') }}</option> <option value="followers">{{ $t('@.note-visibility.followers') }}</option> <option value="specified">{{ $t('@.note-visibility.specified') }}</option> - <option value="private">{{ $t('@.note-visibility.private') }}</option> <option value="local-public">{{ $t('@.note-visibility.local-public') }}</option> <option value="local-home">{{ $t('@.note-visibility.local-home') }}</option> <option value="local-followers">{{ $t('@.note-visibility.local-followers') }}</option> </ui-select> </section> </section> - </ui-card> - - <x-drive-settings/> - - <x-mute-and-block/> - - <ui-card> - <div slot="title"><fa icon="volume-up"/> {{ $t('sound') }}</div> <section> - <ui-switch v-model="enableSounds">{{ $t('enable-sounds') }}</ui-switch> + <header>{{ $t('web-search-engine') }}</header> + <ui-input v-model="webSearchEngine">{{ $t('web-search-engine') }}<span slot="desc">{{ $t('web-search-engine-desc') }}</span></ui-input> </section> </ui-card> - <ui-card> - <div slot="title"><fa icon="language"/> {{ $t('lang') }}</div> - - <section class="fit-top"> - <ui-select v-model="lang" :placeholder="$t('auto')"> - <optgroup :label="$t('recommended')"> - <option value="">{{ $t('auto') }}</option> - </optgroup> - - <optgroup :label="$t('specify-language')"> - <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> - </optgroup> - </ui-select> - <span><fa icon="info-circle"/> {{ $t('lang-tip') }}</span> - </section> - </ui-card> + <x-notification-settings/> - <ui-card> - <div slot="title"><fa :icon="['fab', 'twitter']"/> {{ $t('twitter') }}</div> + <x-drive-settings/> - <section> - <p class="account" v-if="$store.state.i.twitter"><a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p> - <p> - <a :href="`${apiUrl}/connect/twitter`" target="_blank">{{ $store.state.i.twitter ? this.$t('twitter-reconnect') : this.$t('twitter-connect') }}</a> - <span v-if="$store.state.i.twitter"> or </span> - <a :href="`${apiUrl}/disconnect/twitter`" target="_blank" v-if="$store.state.i.twitter">{{ $t('twitter-disconnect') }}</a> - </p> - </section> - </ui-card> + <x-mute-and-block/> <ui-card> - <div slot="title"><fa :icon="['fab', 'github']"/> {{ $t('github') }}</div> + <div slot="title"><fa icon="volume-up"/> {{ $t('sound') }}</div> <section> - <p class="account" v-if="$store.state.i.github"><a :href="`https://github.com/${$store.state.i.github.login}`" target="_blank">@{{ $store.state.i.github.login }}</a></p> - <p> - <a :href="`${apiUrl}/connect/github`" target="_blank">{{ $store.state.i.github ? this.$t('github-reconnect') : this.$t('github-connect') }}</a> - <span v-if="$store.state.i.github"> or </span> - <a :href="`${apiUrl}/disconnect/github`" target="_blank" v-if="$store.state.i.github">{{ $t('github-disconnect') }}</a> - </p> + <ui-switch v-model="enableSounds">{{ $t('enable-sounds') }}</ui-switch> </section> </ui-card> - <ui-card> - <div slot="title"><fa :icon="['fab', 'discord']"/> {{ $t('discord') }}</div> + <x-language-settings/> - <section> - <p class="account" v-if="$store.state.i.discord"><a :href="`https://discordapp.com/users/${$store.state.i.discord.id}`" target="_blank">@{{ $store.state.i.discord.username }}#{{ $store.state.i.discord.discriminator }}</a></p> - <p> - <a :href="`${apiUrl}/connect/discord`" target="_blank">{{ $store.state.i.discord ? this.$t('discord-reconnect') : this.$t('discord-connect') }}</a> - <span v-if="$store.state.i.discord"> or </span> - <a :href="`${apiUrl}/disconnect/discord`" target="_blank" v-if="$store.state.i.discord">{{ $t('discord-disconnect') }}</a> - </p> - </section> - </ui-card> + <x-integration-settings/> <x-api-settings /> @@ -193,7 +150,7 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import { apiUrl, clientVersion as version, codename, langs } from '../../../config'; +import { apiUrl, clientVersion as version, codename } from '../../../config'; import checkForUpdate from '../../../common/scripts/check-for-update'; import XTheme from '../../../common/views/components/theme.vue'; import XDriveSettings from '../../../common/views/components/drive-settings.vue'; @@ -201,6 +158,9 @@ import XMuteAndBlock from '../../../common/views/components/mute-and-block.vue'; import XPasswordSettings from '../../../common/views/components/password-settings.vue'; import XProfileEditor from '../../../common/views/components/profile-editor.vue'; import XApiSettings from '../../../common/views/components/api-settings.vue'; +import XLanguageSettings from '../../../common/views/components/language-settings.vue'; +import XIntegrationSettings from '../../../common/views/components/integration-settings.vue'; +import XNotificationSettings from '../../../common/views/components/notification-settings.vue'; export default Vue.extend({ i18n: i18n('mobile/views/pages/settings.vue'), @@ -212,6 +172,9 @@ export default Vue.extend({ XPasswordSettings, XProfileEditor, XApiSettings, + XLanguageSettings, + XIntegrationSettings, + XNotificationSettings, }, data() { @@ -219,7 +182,6 @@ export default Vue.extend({ apiUrl, version, codename, - langs, latestVersion: undefined, checkingForUpdate: false }; @@ -245,6 +207,11 @@ export default Vue.extend({ set(value) { this.$store.commit('device/set', { key: 'reduceMotion', value }); } }, + suggestRecentHashtags: { + get() { return this.$store.state.settings.suggestRecentHashtags; }, + set(value) { this.$store.commit('device/set', { key: 'suggestRecentHashtags', value }); } + }, + alwaysShowNsfw: { get() { return this.$store.state.device.alwaysShowNsfw; }, set(value) { this.$store.commit('device/set', { key: 'alwaysShowNsfw', value }); } @@ -270,11 +237,6 @@ export default Vue.extend({ set(value) { this.$store.commit('device/set', { key: 'loadRawImages', value }); } }, - lang: { - get() { return this.$store.state.device.lang; }, - set(value) { this.$store.commit('device/set', { key: 'lang', value }); } - }, - enableSounds: { get() { return this.$store.state.device.enableSounds; }, set(value) { this.$store.commit('device/set', { key: 'enableSounds', value }); } @@ -305,6 +267,11 @@ export default Vue.extend({ set(value) { this.$store.dispatch('settings/set', { key: 'circleIcons', value }); } }, + lineWidth: { + get() { return this.$store.state.device.lineWidth; }, + set(value) { this.$store.commit('device/set', { key: 'lineWidth', value }); } + }, + contrastedAcct: { get() { return this.$store.state.settings.contrastedAcct; }, set(value) { this.$store.dispatch('settings/set', { key: 'contrastedAcct', value }); } @@ -320,7 +287,6 @@ export default Vue.extend({ set(value) { this.$store.dispatch('settings/set', { key: 'showVia', value }); } }, - iLikeSushi: { get() { return this.$store.state.settings.iLikeSushi; }, set(value) { this.$store.dispatch('settings/set', { key: 'iLikeSushi', value }); } @@ -331,9 +297,9 @@ export default Vue.extend({ set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.showBoardLabels', value }); } }, - games_reversi_useContrastStones: { - get() { return this.$store.state.settings.games.reversi.useContrastStones; }, - set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.useContrastStones', value }); } + games_reversi_useAvatarStones: { + get() { return this.$store.state.settings.games.reversi.useAvatarStones; }, + set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.useAvatarStones', value }); } }, disableAnimatedMfm: { @@ -365,6 +331,11 @@ export default Vue.extend({ get() { return this.$store.state.settings.defaultNoteVisibility; }, set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); } }, + + webSearchEngine: { + get() { return this.$store.state.settings.webSearchEngine; }, + set(value) { this.$store.dispatch('settings/set', { key: 'webSearchEngine', value }); } + }, }, mounted() { @@ -382,12 +353,12 @@ export default Vue.extend({ this.checkingForUpdate = false; this.latestVersion = newer; if (newer == null) { - this.$root.alert({ + this.$root.dialog({ title: this.$t('no-updates'), text: this.$t('no-updates-desc') }); } else { - this.$root.alert({ + this.$root.dialog({ title: this.$t('update-available'), text: this.$t('update-available-desc') }); @@ -404,13 +375,14 @@ main max-width 600px width 100% - > .signin-as + > .signed-in-as margin 16px padding 16px text-align center color var(--mobileSignedInAsFg) background var(--mobileSignedInAsBg) box-shadow 0 3px 1px -2px rgba(#000, 0.2), 0 2px 2px 0 rgba(#000, 0.14), 0 1px 5px 0 rgba(#000, 0.12) + font-weight bold > .signout margin 16px diff --git a/src/client/app/mobile/views/pages/tag.vue b/src/client/app/mobile/views/pages/tag.vue index aaf878ce45..ecd523dab2 100644 --- a/src/client/app/mobile/views/pages/tag.vue +++ b/src/client/app/mobile/views/pages/tag.vue @@ -3,7 +3,7 @@ <span slot="header"><span style="margin-right:4px;"><fa icon="hashtag"/></span>{{ $route.params.tag }}</span> <main> - <p v-if="!fetching && empty"><fa icon="search"/> {{ $t('no-posts-found', { q }) }}</p> + <p v-if="!fetching && empty"><fa icon="search"/> {{ $t('no-posts-found', { q: $route.params.tag }) }}</p> <mk-notes ref="timeline" :more="existMore ? more : null"/> </main> </mk-ui> @@ -72,7 +72,9 @@ export default Vue.extend({ } else { this.existMore = false; } - notes.forEach(n => (this.$refs.timeline as any).append(n)); + for (const n of notes) { + (this.$refs.timeline as any).append(n); + } this.moreFetching = false; }); diff --git a/src/client/app/mobile/views/pages/user-list.vue b/src/client/app/mobile/views/pages/user-list.vue index fdc621ef7d..cf2dd134fd 100644 --- a/src/client/app/mobile/views/pages/user-list.vue +++ b/src/client/app/mobile/views/pages/user-list.vue @@ -3,9 +3,7 @@ <span slot="header" v-if="!fetching"><fa icon="list"/>{{ list.title }}</span> <main v-if="!fetching"> - <ul> - <li v-for="user in users" :key="user.id"><router-link :to="user | userPage">{{ user | userName }}</router-link></li> - </ul> + <x-editor :list="list"/> </main> </mk-ui> </template> @@ -13,13 +11,16 @@ <script lang="ts"> import Vue from 'vue'; import Progress from '../../../common/scripts/loading'; +import XEditor from '../../../common/views/components/user-list-editor.vue'; export default Vue.extend({ + components: { + XEditor + }, data() { return { fetching: true, - list: null, - users: null + list: null }; }, watch: { @@ -40,12 +41,6 @@ export default Vue.extend({ this.fetching = false; Progress.done(); - - this.$root.api('users/show', { - userIds: this.list.userIds - }).then(users => { - this.users = users; - }); }); } } @@ -53,8 +48,6 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> - - main width 100% max-width 680px diff --git a/src/client/app/mobile/views/pages/user-lists.vue b/src/client/app/mobile/views/pages/user-lists.vue index 2222a22487..dc9d47de3c 100644 --- a/src/client/app/mobile/views/pages/user-lists.vue +++ b/src/client/app/mobile/views/pages/user-lists.vue @@ -38,9 +38,11 @@ export default Vue.extend({ }, methods: { fn() { - this.$input({ + this.$root.dialog({ title: this.$t('enter-list-name'), - }).then(async title => { + input: true + }).then(async ({ canceled, result: title }) => { + if (canceled) return; const list = await this.$root.api('users/lists/create', { title }); diff --git a/src/client/app/mobile/views/pages/user.vue b/src/client/app/mobile/views/pages/user.vue index b7f0db6eb9..c475750cf2 100644 --- a/src/client/app/mobile/views/pages/user.vue +++ b/src/client/app/mobile/views/pages/user.vue @@ -1,8 +1,10 @@ <template> <mk-ui> - <template slot="header" v-if="!fetching"><img :src="user.avatarUrl" alt="">{{ user | userName }}</template> + <template slot="header" v-if="!fetching"><img :src="user.avatarUrl" alt=""> + <mk-user-name :user="user"/> + </template> <main v-if="!fetching"> - <div class="is-suspended" v-if="user.isSuspended"><p><fa icon="exclamation-triangle"/> {{ $t('is-suspended') }}</p></div> + <div class="is-suspended" v-if="user.isSuspended"><p><fa icon="exclamation-triangle"/> {{ $t('@.user-suspended') }}</p></div> <div class="is-remote" v-if="user.host != null"><p><fa icon="exclamation-triangle"/> {{ $t('@.is-remote-user') }}<a :href="user.url || user.uri" target="_blank">{{ $t('@.view-on-remote') }}</a></p></div> <header> <div class="banner" :style="style"></div> @@ -15,12 +17,22 @@ <mk-follow-button v-if="$store.getters.isSignedIn && $store.state.i.id != user.id" :user="user"/> </div> <div class="title"> - <h1>{{ user | userName }}</h1> + <h1><mk-user-name :user="user"/></h1> <span class="username"><mk-acct :user="user" :detail="true" /></span> <span class="followed" v-if="user.isFollowed">{{ $t('follows-you') }}</span> </div> <div class="description"> - <misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/> + <mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> + </div> + <div class="fields" v-if="user.fields"> + <dl class="field" v-for="(field, i) in user.fields" :key="i"> + <dt class="name"> + <mfm :text="field.name" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/> + </dt> + <dd class="value"> + <mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> + </dd> + </dl> </div> <div class="info"> <p class="location" v-if="user.host === null && user.profile.location"> @@ -68,7 +80,7 @@ import i18n from '../../../i18n'; import * as age from 's-age'; import parseAcct from '../../../../../misc/acct/parse'; import Progress from '../../../common/scripts/loading'; -import Menu from '../../../common/views/components/menu.vue'; +import XUserMenu from '../../../common/views/components/user-menu.vue'; import XHome from './user/home.vue'; export default Vue.extend({ @@ -115,56 +127,9 @@ export default Vue.extend({ }, menu() { - let menu = [{ - icon: this.user.isMuted ? ['fas', 'eye'] : ['far', 'eye-slash'], - text: this.user.isMuted ? this.$t('unmute') : this.$t('mute'), - action: () => { - if (this.user.isMuted) { - this.$root.api('mute/delete', { - userId: this.user.id - }).then(() => { - this.user.isMuted = false; - }, () => { - alert('error'); - }); - } else { - this.$root.api('mute/create', { - userId: this.user.id - }).then(() => { - this.user.isMuted = true; - }, () => { - alert('error'); - }); - } - } - }, { - icon: 'ban', - text: this.user.isBlocking ? this.$t('unblock') : this.$t('block'), - action: () => { - if (this.user.isBlocking) { - this.$root.api('blocking/delete', { - userId: this.user.id - }).then(() => { - this.user.isBlocking = false; - }, () => { - alert('error'); - }); - } else { - this.$root.api('blocking/create', { - userId: this.user.id - }).then(() => { - this.user.isBlocking = true; - }, () => { - alert('error'); - }); - } - } - }]; - - this.$root.new(Menu, { + this.$root.new(XUserMenu, { source: this.$refs.menu, - compact: true, - items: menu + user: this.user }); }, } @@ -271,6 +236,34 @@ main margin 8px 0 color var(--mobileUserPageDescription) + > .fields + margin 8px 0 + + > .field + display flex + padding 0 + margin 0 + align-items center + + > .name + padding 4px + margin 4px + width 30% + overflow hidden + white-space nowrap + text-overflow ellipsis + font-weight bold + color var(--mobileUserPageStatusHighlight) + + > .value + padding 4px + margin 4px + width 70% + overflow hidden + white-space nowrap + text-overflow ellipsis + color var(--mobileUserPageStatusHighlight) + > .info margin 8px 0 @@ -297,6 +290,9 @@ main > i font-size 14px + > button + color var(--text) + > nav position -webkit-sticky position sticky @@ -334,6 +330,7 @@ main max-width 680px margin 0 auto padding 8px + color var(--text) @media (min-width 500px) padding 16px diff --git a/src/client/app/mobile/views/pages/user/home.photos.vue b/src/client/app/mobile/views/pages/user/home.photos.vue index 4dea95910d..cd6d0e674b 100644 --- a/src/client/app/mobile/views/pages/user/home.photos.vue +++ b/src/client/app/mobile/views/pages/user/home.photos.vue @@ -26,20 +26,28 @@ export default Vue.extend({ }; }, mounted() { + const image = [ + 'image/jpeg', + 'image/png', + 'image/gif' + ]; this.$root.api('users/notes', { userId: this.user.id, - withFiles: true, - limit: 6, + fileType: image, + excludeNsfw: !this.$store.state.device.alwaysShowNsfw, + limit: 9, untilDate: new Date().getTime() + 1000 * 86400 * 365 }).then(notes => { - notes.forEach(note => { - note.media.forEach(media => { - if (this.images.length < 9) this.images.push({ - note, - media - }); - }); - }); + for (const note of notes) { + for (const media of note.media) { + if (this.images.length < 9) { + this.images.push({ + note, + media + }); + } + } + } this.fetching = false; }); } @@ -61,7 +69,7 @@ export default Vue.extend({ > .img flex 1 1 33% width 33% - height 80px + height 90px background-position center center background-size cover background-clip content-box diff --git a/src/client/app/mobile/views/pages/user/home.vue b/src/client/app/mobile/views/pages/user/home.vue index ff6b2d4c28..09b4c3f265 100644 --- a/src/client/app/mobile/views/pages/user/home.vue +++ b/src/client/app/mobile/views/pages/user/home.vue @@ -31,7 +31,6 @@ <x-followers-you-know :user="user"/> </div> </section> - <p v-if="user.host === null">{{ $t('last-used-at') }}: <b><mk-time :time="user.lastUsedAt"/></b></p> </div> </template> @@ -90,7 +89,7 @@ export default Vue.extend({ @media (min-width 500px) padding 10px 16px - > i + > [data-icon] margin-right 6px > .activity diff --git a/src/client/app/mobile/views/pages/welcome.vue b/src/client/app/mobile/views/pages/welcome.vue index 9f8dfd17d9..acc4eef792 100644 --- a/src/client/app/mobile/views/pages/welcome.vue +++ b/src/client/app/mobile/views/pages/welcome.vue @@ -306,6 +306,7 @@ export default Vue.extend({ padding 16px 0 border solid 2px rgba(0, 0, 0, 0.1) border-radius 8px + color var(--text) > * margin 0 16px diff --git a/src/client/app/mobile/views/pages/widgets.vue b/src/client/app/mobile/views/pages/widgets.vue index 8db5399cfd..d28a97dbc5 100644 --- a/src/client/app/mobile/views/pages/widgets.vue +++ b/src/client/app/mobile/views/pages/widgets.vue @@ -20,7 +20,6 @@ <option value="version">{{ $t('@.widgets.version') }}</option> <option value="server">{{ $t('@.widgets.server') }}</option> <option value="memo">{{ $t('@.widgets.memo') }}</option> - <option value="donation">{{ $t('@.widgets.donation') }}</option> <option value="nav">{{ $t('@.widgets.nav') }}</option> <option value="tips">{{ $t('@.widgets.tips') }}</option> </select> @@ -90,9 +89,6 @@ export default Vue.extend({ name: 'photo-stream', id: 'd', data: {} }, { - name: 'donation', - id: 'e', data: {} - }, { name: 'nav', id: 'f', data: {} }, { diff --git a/src/client/app/mobile/views/widgets/profile.vue b/src/client/app/mobile/views/widgets/profile.vue index 6ce3468c49..c08cbec7a1 100644 --- a/src/client/app/mobile/views/widgets/profile.vue +++ b/src/client/app/mobile/views/widgets/profile.vue @@ -8,7 +8,9 @@ :src="$store.state.i.avatarUrl" alt="avatar" /> - <router-link :class="$style.name" :to="$store.state.i | userPage">{{ $store.state.i | userName }}</router-link> + <router-link :class="$style.name" :to="$store.state.i | userPage"> + <mk-user-name :user="$store.state.i"/> + </router-link> </mk-widget-container> </div> </template> diff --git a/src/client/app/safe.js b/src/client/app/safe.js index 026fc66c6e..88c603f6b9 100644 --- a/src/client/app/safe.js +++ b/src/client/app/safe.js @@ -5,17 +5,9 @@ // Detect an old browser if (!('fetch' in window)) { alert( - 'お使いのブラウザ(またはOS)が古いためMisskeyを動作させることができません。' + + 'お使いのブラウザ(またはOS)のバージョンが旧式のため、Misskeyを動作させることができません。' + 'バージョンを最新のものに更新するか、別のブラウザをお試しください。' + '\n\n' + 'Your browser (or your OS) seems outdated. ' + 'To run Misskey, please update your browser to latest version or try other browsers.'); } - -// Check whether cookie enabled -if (!navigator.cookieEnabled) { - alert( - 'Misskeyを利用するにはCookieを有効にしてください。' + - '\n\n' + - 'To use Misskey, please enable Cookie.'); -} diff --git a/src/client/app/store.ts b/src/client/app/store.ts index 0a16a71a2a..350826e5ef 100644 --- a/src/client/app/store.ts +++ b/src/client/app/store.ts @@ -35,22 +35,23 @@ const defaultSettings = { iLikeSushi: false, rememberNoteVisibility: false, defaultNoteVisibility: 'public', + webSearchEngine: 'https://www.google.com/?#q={{query}}', mutedWords: [], games: { reversi: { showBoardLabels: false, - useContrastStones: false + useAvatarStones: true, } } }; const defaultDeviceSettings = { reduceMotion: false, - apiViaStream: true, autoPopout: false, darkmode: false, darkTheme: 'dark', lightTheme: 'light', + lineWidth: 1, themes: [], enableSounds: true, soundVolume: 0.5, @@ -63,6 +64,7 @@ const defaultDeviceSettings = { postStyle: 'standard', navbar: 'top', deckColumnAlign: 'center', + deckColumnWidth: 'normal', mobileNotificationPosition: 'bottom', deckTemporaryColumn: null, deckDefault: false, @@ -128,13 +130,14 @@ export default (os: MiOS) => new Vuex.Store({ logout(ctx) { ctx.commit('updateI', null); - document.cookie = `i=; domain=${hostname}; expires=Thu, 01 Jan 1970 00:00:01 GMT;`; + document.cookie = 'i=;'; + localStorage.removeItem('i'); }, mergeMe(ctx, me) { - Object.entries(me).forEach(([key, value]) => { + for (const [key, value] of Object.entries(me)) { ctx.commit('updateIKeyValue', { key, value }); - }); + } if (me.clientSettings) { ctx.dispatch('settings/merge', me.clientSettings); @@ -211,11 +214,11 @@ export default (os: MiOS) => new Vuex.Store({ //#region Deck if (state.deck && state.deck.columns) { - state.deck.columns.filter(c => c.type == 'widgets').forEach(c => { - c.widgets.forEach(w => { - if (w.id == x.id) w.data = x.data; - }); - }); + for (const c of state.deck.columns.filter(c => c.type == 'widgets')) { + for (const w of c.widgets.filter(w => w.id == x.id)) { + w.data = x.data; + } + } } //#endregion }, @@ -341,9 +344,9 @@ export default (os: MiOS) => new Vuex.Store({ actions: { merge(ctx, settings) { if (settings == null) return; - Object.entries(settings).forEach(([key, value]) => { + for (const [key, value] of Object.entries(settings)) { ctx.commit('set', { key, value }); - }); + } }, set(ctx, x) { diff --git a/src/client/app/sw.js b/src/client/app/sw.js index d381bfb7a5..ccf6dc818e 100644 --- a/src/client/app/sw.js +++ b/src/client/app/sw.js @@ -62,6 +62,8 @@ self.addEventListener('push', ev => { self.addEventListener('message', ev => { if (ev.data == 'clear') { - caches.keys().then(keys => keys.forEach(key => caches.delete(key))); + caches.keys().then(keys => { + for (const key of keys) caches.delete(key); + }); } }); diff --git a/src/client/app/test/script.ts b/src/client/app/test/script.ts new file mode 100644 index 0000000000..44fe224cbc --- /dev/null +++ b/src/client/app/test/script.ts @@ -0,0 +1,25 @@ +import VueRouter from 'vue-router'; + +// Style +import './style.styl'; + +import init from '../init'; +import Index from './views/index.vue'; +import NotFound from '../common/views/pages/not-found.vue'; + +init(launch => { + document.title = 'Misskey'; + + // Init router + const router = new VueRouter({ + mode: 'history', + base: '/test/', + routes: [ + { path: '/', component: Index }, + { path: '*', component: NotFound } + ] + }); + + // Launch the app + launch(router); +}); diff --git a/src/client/app/test/style.styl b/src/client/app/test/style.styl new file mode 100644 index 0000000000..ae1a28226a --- /dev/null +++ b/src/client/app/test/style.styl @@ -0,0 +1,6 @@ +@import "../app" +@import "../reset" + +html + height 100% + background var(--bg) diff --git a/src/client/app/test/views/index.vue b/src/client/app/test/views/index.vue new file mode 100644 index 0000000000..028919ad3a --- /dev/null +++ b/src/client/app/test/views/index.vue @@ -0,0 +1,82 @@ +<template> +<main> + <ui-card> + <div slot="title">MFM Playground</div> + <section class="fit-top"> + <ui-textarea v-model="mfm"> + <span>MFM</span> + </ui-textarea> + </section> + <section> + <header>Preview</header> + <mfm :text="mfm" :i="$store.state.i"/> + </section> + <section> + <header style="margin-bottom:0;">AST</header> + <ui-textarea v-model="mfmAst" readonly tall style="margin-top:16px;"></ui-textarea> + </section> + </ui-card> + + <ui-card> + <div slot="title">Dialog Generator</div> + <section class="fit-top"> + <ui-select v-model="dialogType" placeholder=""> + <option value="info">Information</option> + <option value="success">Success</option> + <option value="warning">Warning</option> + <option value="error">Error</option> + </ui-select> + <ui-input v-model="dialogTitle"> + <span>Title</span> + </ui-input> + <ui-input v-model="dialogText"> + <span>Text</span> + </ui-input> + <ui-switch v-model="dialogShowCancelButton">With cancel button</ui-switch> + <ui-button @click="showDialog">Show</ui-button> + </section> + </ui-card> +</main> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import parse from '../../../../mfm/parse'; +import * as JSON5 from 'json5'; + +export default Vue.extend({ + data() { + return { + mfm: '', + dialogType: 'success', + dialogTitle: '', + dialogText: 'Hello World!', + dialogShowCancelButton: false + }; + }, + + computed: { + mfmAst(): any { + return JSON5.stringify(parse(this.mfm), null, 2); + } + }, + + methods: { + showDialog() { + this.$root.dialog({ + type: this.dialogType, + title: this.dialogTitle, + text: this.dialogText, + showCancelButton: this.dialogShowCancelButton + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +main + max-width 700px + margin 0 auto + +</style> diff --git a/src/client/app/theme.ts b/src/client/app/theme.ts index 9c5be74fa1..a89df206e0 100644 --- a/src/client/app/theme.ts +++ b/src/client/app/theme.ts @@ -15,13 +15,29 @@ export const darkTheme: Theme = require('../theme/dark.json5'); export const pinkTheme: Theme = require('../theme/pink.json5'); export const blackTheme: Theme = require('../theme/black.json5'); export const halloweenTheme: Theme = require('../theme/halloween.json5'); +export const cafeTheme: Theme = require('../theme/cafe.json5'); +export const japaneseSushiSetTheme: Theme = require('../theme/japanese-sushi-set.json5'); +export const gruvboxDarkTheme: Theme = require('../theme/gruvbox-dark.json5'); +export const monokaiTheme: Theme = require('../theme/monokai.json5'); +export const colorfulTheme: Theme = require('../theme/colorful.json5'); +export const rainyTheme: Theme = require('../theme/rainy.json5'); +export const mauveTheme: Theme = require('../theme/mauve.json5'); +export const grayTheme: Theme = require('../theme/gray.json5'); export const builtinThemes = [ lightTheme, darkTheme, pinkTheme, blackTheme, - halloweenTheme + halloweenTheme, + cafeTheme, + japaneseSushiSetTheme, + gruvboxDarkTheme, + monokaiTheme, + colorfulTheme, + rainyTheme, + mauveTheme, + grayTheme, ]; export function applyTheme(theme: Theme, persisted = true) { @@ -36,9 +52,9 @@ export function applyTheme(theme: Theme, persisted = true) { const props = compile(_theme); - Object.entries(props).forEach(([k, v]) => { + for (const [k, v] of Object.entries(props)) { document.documentElement.style.setProperty(`--${k}`, v.toString()); - }); + } if (persisted) { localStorage.setItem('theme', JSON.stringify(props)); @@ -74,10 +90,9 @@ function compile(theme: Theme): { [key: string]: string } { const props = {}; - Object.entries(theme.props).forEach(([k, v]) => { - const c = getColor(v); - props[k] = genValue(c); - }); + for (const [k, v] of Object.entries(theme.props)) { + props[k] = genValue(getColor(v)); + } const primary = getColor(props['primary']); @@ -86,12 +101,12 @@ function compile(theme: Theme): { [key: string]: string } { props['primaryAlpha0' + i] = genValue(color); } - for (let i = 1; i < 100; i++) { + for (let i = 5; i < 100; i += 5) { const color = primary.clone().lighten(i); props['primaryLighten' + i] = genValue(color); } - for (let i = 1; i < 100; i++) { + for (let i = 5; i < 100; i += 5) { const color = primary.clone().darken(i); props['primaryDarken' + i] = genValue(color); } diff --git a/src/client/app/v.d.ts b/src/client/app/v.d.ts index 8f3a240d80..b3a21c6cdb 100644 --- a/src/client/app/v.d.ts +++ b/src/client/app/v.d.ts @@ -1,4 +1,4 @@ -declare module "*.vue" { +declare module '*.vue' { import Vue from 'vue'; export default Vue; } diff --git a/src/client/assets/manifest.json b/src/client/assets/manifest.json index bae0ee7f1d..30aea9e267 100644 --- a/src/client/assets/manifest.json +++ b/src/client/assets/manifest.json @@ -4,6 +4,7 @@ "start_url": "/", "display": "standalone", "background_color": "#313a42", + "theme_color": "#fb4e4e", "icons": [ { "src": "/assets/icons/16.png", @@ -34,6 +35,11 @@ "src": "/assets/icons/256.png", "sizes": "256x256", "type": "image/png" + }, + { + "src": "/assets/icons/512.png", + "sizes": "512x512", + "type": "image/png" } ], "share_target": { diff --git a/src/client/assets/misskey-php-like-logo.png b/src/client/assets/misskey-php-like-logo.png Binary files differnew file mode 100644 index 0000000000..7777e52b1d --- /dev/null +++ b/src/client/assets/misskey-php-like-logo.png diff --git a/src/client/assets/pointer.png b/src/client/assets/pointer.png Binary files differindex c9aaada5a3..255e0c8a4f 100644 --- a/src/client/assets/pointer.png +++ b/src/client/assets/pointer.png diff --git a/src/client/style.styl b/src/client/style.styl index 8ebba2f15e..adc331ebba 100644 --- a/src/client/style.styl +++ b/src/client/style.styl @@ -22,7 +22,7 @@ html, body a text-decoration none - color var(--primary) + color var(--link) cursor pointer &:hover @@ -33,7 +33,7 @@ a @css { a { - tap-highlight-color: var(--primaryAlpha07) !important; - -webkit-tap-highlight-color: var(--primaryAlpha07) !important; + tap-highlight-color: var(--linkTapHighlight) !important; + -webkit-tap-highlight-color: var(--linkTapHighlight) !important; } } diff --git a/src/client/theme/cafe.json5 b/src/client/theme/cafe.json5 new file mode 100644 index 0000000000..084f69299c --- /dev/null +++ b/src/client/theme/cafe.json5 @@ -0,0 +1,21 @@ +{ + id: '0ff48d43-aab3-46e7-ab12-8492110d2e2b', + + name: 'Cafe', + author: 'syuilo', + + base: 'light', + + vars: { + primary: 'rgb(234, 154, 82)', + secondary: 'rgb(238, 236, 232)', + text: 'rgb(149, 143, 139)', + }, + + props: { + renoteGradient: '#ffe1c7', + renoteText: '$primary', + quoteBorder: '$primary', + mfmMention: '#56907b', + }, +} diff --git a/src/client/theme/colorful.json5 b/src/client/theme/colorful.json5 new file mode 100644 index 0000000000..5b7441e1cf --- /dev/null +++ b/src/client/theme/colorful.json5 @@ -0,0 +1,23 @@ +{ + id: '2d066d6e-bd39-4f23-bd48-686d5c1c6ae8', + + name: 'Colorful', + author: 'syuilo', + + base: 'light', + + vars: { + primary: 'rgb(255, 153, 64)', + secondary: 'rgb(255, 255, 255)', + text: 'rgb(108, 118, 128)', + }, + + props: { + bg: 'rgb(250, 250, 250)', + mfmMention: '#f07171', + mfmMentionForeground: '#fff', + mfmUrl: '#86b300', + mfmLink: '#399ee6', + mfmHashtag: '#fa8d3e' + }, +} diff --git a/src/client/theme/dark.json5 b/src/client/theme/dark.json5 index 446eac557c..13c55999e5 100644 --- a/src/client/theme/dark.json5 +++ b/src/client/theme/dark.json5 @@ -24,6 +24,15 @@ scrollbarHandle: ':lighten<5<$secondary', scrollbarHandleHover: ':lighten<10<$secondary', + link: '$primary', + linkTapHighlight: ':alpha<0.7<@link', + + notificationIndicator: '$primary', + + switchActive: '$primary', + switchActiveTrack: ':alpha<0.4<@switchActive', + radioActive: '$primary', + face: '$secondary', faceText: '#fff', faceHeader: ':lighten<5<$secondary', @@ -111,9 +120,6 @@ announcementsTitle: '#539eff', announcementsText: '#fff', - donationBg: '#5d5242', - donationFg: '#e4dbce', - googleSearchBg: 'rgba(0, 0, 0, 0.2)', googleSearchFg: '#dee4e8', googleSearchBorder: 'rgba(255, 255, 255, 0.2)', @@ -123,8 +129,10 @@ mfmTitleBg: 'rgba(0, 0, 0, 0.2)', mfmQuote: ':alpha<0.7<$text', mfmQuoteLine: ':alpha<0.6<$text', - mfmLink: '$primary', + mfmUrl: '$primary', + mfmLink: '@mfmUrl', mfmMention: '$primary', + mfmMentionForeground: '@primaryForeground', mfmHashtag: '$primary', suspendedInfoBg: '#611d1d', diff --git a/src/client/theme/gray.json5 b/src/client/theme/gray.json5 new file mode 100644 index 0000000000..59494f278a --- /dev/null +++ b/src/client/theme/gray.json5 @@ -0,0 +1,21 @@ +{ + id: '56ff14eb-1e6d-4c0c-9e84-71eb156234e5', + + name: 'Gray', + author: 'syuilo', + + base: 'light', + + vars: { + primary: '#C03233', + secondary: 'rgb(213, 213, 213)', + text: 'rgb(102, 102, 102)', + }, + + props: { + renoteGradient: '#bdbdbd', + renoteText: '$primary', + quoteBorder: '$primary', + desktopPostFormBg: '#ececec', + }, +} diff --git a/src/client/theme/gruvbox-dark.json5 b/src/client/theme/gruvbox-dark.json5 new file mode 100644 index 0000000000..2d03153190 --- /dev/null +++ b/src/client/theme/gruvbox-dark.json5 @@ -0,0 +1,29 @@ +{ + id: '0c6e70e2-a1ec-4053-9b1a-b6082fe016cb', + + name: 'Gruvbox Dark', + author: 'syuilo', + + base: 'dark', + + vars: { + primary: 'rgb(215, 153, 33)', + secondary: 'rgb(40, 40, 40)', + text: 'rgb(235, 219, 178)', + }, + + props: { + renoteGradient: '#58581e', + renoteText: 'rgb(169, 174, 36)', + quoteBorder: 'rgb(169, 174, 36)', + mfmMention: 'rgb(177, 98, 134)', + mfmMentionForeground: '#fff', + mfmUrl: 'rgb(69, 133, 136)', + mfmLink: 'rgb(104, 157, 106)', + mfmHashtag: 'rgb(251, 73, 52)', + notificationIndicator: 'rgb(184, 187, 38)', + switchActive: 'rgb(254, 128, 25)', + radioActive: 'rgb(131, 165, 152)', + link: 'rgb(104, 157, 106)', + }, +} diff --git a/src/client/theme/japanese-sushi-set.json5 b/src/client/theme/japanese-sushi-set.json5 new file mode 100644 index 0000000000..03c6b4f638 --- /dev/null +++ b/src/client/theme/japanese-sushi-set.json5 @@ -0,0 +1,20 @@ +{ + id: '2b0a0654-cdb4-4c9a-8244-736b647d3c2a', + + name: 'Japanese Sushi Set', + author: 'noizenecio & syuilo', + + base: 'dark', + + vars: { + primary: 'rgb(234, 136, 50)', + secondary: 'rgb(34, 36, 42)', + text: 'rgb(221, 209, 203)', + }, + + props: { + renoteGradient: '#6d3d14', + renoteText: '$primary', + quoteBorder: '$primary', + }, +} diff --git a/src/client/theme/light.json5 b/src/client/theme/light.json5 index 4a182c2428..65bd3b1216 100644 --- a/src/client/theme/light.json5 +++ b/src/client/theme/light.json5 @@ -24,6 +24,15 @@ scrollbarHandle: '#00000033', scrollbarHandleHover: '#00000066', + link: '$primary', + linkTapHighlight: ':alpha<0.7<@link', + + notificationIndicator: '$primary', + + switchActive: '$primary', + switchActiveTrack: ':alpha<0.4<@switchActive', + radioActive: '$primary', + face: '$secondary', faceText: '$text', faceHeader: ':lighten<5<$secondary', @@ -111,9 +120,6 @@ announcementsTitle: '#4078c0', announcementsText: '#57616f', - donationBg: '#fbead4', - donationFg: '#777d71', - googleSearchBg: '#fff', googleSearchFg: '#55595c', googleSearchBorder: 'rgba(0, 0, 0, 0.2)', @@ -123,8 +129,10 @@ mfmTitleBg: 'rgba(0, 0, 0, 0.07)', mfmQuote: ':alpha<0.6<$text', mfmQuoteLine: ':alpha<0.5<$text', - mfmLink: '$primary', + mfmUrl: '$primary', + mfmLink: '@mfmUrl', mfmMention: '$primary', + mfmMentionForeground: '@primaryForeground', mfmHashtag: '$primary', suspendedInfoBg: '#ffdbdb', @@ -215,7 +223,7 @@ reversiGameHeaderLine: '#c4cdd4', reversiGameEmptyCell: 'rgba(0, 0, 0, 0.06)', reversiGameEmptyCellMyTurn: 'rgba(0, 0, 0, 0.12)', - reversiGameEmptyCellCanPut: 'rgba(0, 0, 0, 0.9)', + reversiGameEmptyCellCanPut: 'rgba(0, 0, 0, 0.09)', adminDashboardHeaderFg: ':alpha<0.9<$text', adminDashboardHeaderBorder: 'rgba(0, 0, 0, 0.1)', diff --git a/src/client/theme/mauve.json5 b/src/client/theme/mauve.json5 new file mode 100644 index 0000000000..aa1eb92f5e --- /dev/null +++ b/src/client/theme/mauve.json5 @@ -0,0 +1,20 @@ +{ + id: '252b2caf-86c2-4c3f-a73f-e1fc1cfa5298', + + name: 'Mauve', + author: 'とわこ & syuilo', + + base: 'dark', + + vars: { + primary: 'rgb(133, 88, 150)', + secondary: 'rgb(54, 43, 59)', + text: 'rgb(229, 223, 231)', + }, + + props: { + renoteGradient: '#54415d', + renoteText: '$primary', + quoteBorder: '$primary', + }, +} diff --git a/src/client/theme/monokai.json5 b/src/client/theme/monokai.json5 new file mode 100644 index 0000000000..1ecd68730e --- /dev/null +++ b/src/client/theme/monokai.json5 @@ -0,0 +1,29 @@ +{ + id: 'fef11dc4-6b17-436e-b374-73282c44ddc0', + + name: 'Monokai', + author: 'syuilo', + + base: 'dark', + + vars: { + primary: '#f92672', + secondary: '#272822', + text: '#f8f8f2', + }, + + props: { + renoteGradient: '#3f500f', + renoteText: '#a6e22e', + quoteBorder: '#a6e22e', + mfmMention: '#ae81ff', + mfmMentionForeground: '#fff', + mfmUrl: '#66d9ef', + mfmLink: '#e6db74', + mfmHashtag: '#fd971f', + notificationIndicator: '#66d9ef', + switchActive: 'rgb(166, 226, 46)', + radioActive: '#fd971f', + link: '#e6db74', + }, +} diff --git a/src/client/theme/rainy.json5 b/src/client/theme/rainy.json5 new file mode 100644 index 0000000000..ba3854810f --- /dev/null +++ b/src/client/theme/rainy.json5 @@ -0,0 +1,20 @@ +{ + id: '2058b33e-5127-4e63-ae67-a900f3a11723', + + name: 'Rainy', + author: 'syuilo', + + base: 'light', + + vars: { + primary: 'rgb(100, 184, 193)', + secondary: 'rgb(228, 234, 234)', + text: 'rgb(85, 94, 92)', + }, + + props: { + renoteGradient: '#bcd0d0', + renoteText: '$primary', + quoteBorder: '$primary', + }, +} diff --git a/src/config/types.ts b/src/config/types.ts index f9cb9d8659..2ce9c0c80d 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -37,30 +37,9 @@ export type Source = { proxy?: string; - summalyProxy?: string; - accesslog?: string; - github_bot?: { - hook_secret: string; - username: string; - }; - - /** - * Service Worker - */ - sw?: { - public_key: string; - private_key: string; - }; - clusterLimit?: number; - - user_recommendation?: { - external: boolean; - engine: string; - timeout: number; - }; }; /** diff --git a/src/const.json b/src/const.json index af9a22bce8..f0892e54c5 100644 --- a/src/const.json +++ b/src/const.json @@ -1,5 +1,3 @@ { - "copyright": "Copyright (c) 2014-2018 syuilo", - "themeColor": "#fb4e4e", - "themeColorForeground": "#fff" + "copyright": "Copyright (c) 2014-2019 syuilo" } diff --git a/src/crypto_key.cc b/src/crypto_key.cc index fe67b14532..658586baed 100644 --- a/src/crypto_key.cc +++ b/src/crypto_key.cc @@ -8,7 +8,7 @@ NAN_METHOD(extractPublic) { - const auto sourceString = info[0]->ToString(); + const auto sourceString = info[0]->ToString(Nan::GetCurrentContext()).ToLocalChecked(); if (!sourceString->IsOneByte()) { Nan::ThrowError("Malformed character found"); return; diff --git a/src/crypto_key.d.ts b/src/crypto_key.d.ts index 48efef2980..9aa81a687c 100644 --- a/src/crypto_key.d.ts +++ b/src/crypto_key.d.ts @@ -1,2 +1,2 @@ -export function extractPublic(keypair: String): String; -export function generate(): String; +export function extractPublic(keypair: string): string; +export function generate(): string; diff --git a/src/daemons/server-stats.ts b/src/daemons/server-stats.ts index 9bb43fe84e..f3e3d61f8b 100644 --- a/src/daemons/server-stats.ts +++ b/src/daemons/server-stats.ts @@ -23,7 +23,7 @@ export default function() { const cpu = await cpuUsage(); const usedmem = await usedMem(); const totalmem = await totalMem(); - const disk = diskusage.checkSync(os.platform() == 'win32' ? 'c:' : '/'); + const disk = await diskusage.check(os.platform() == 'win32' ? 'c:' : '/'); const stats = { cpu_usage: cpu, diff --git a/src/docs/api/entities/note.yaml b/src/docs/api/entities/note.yaml index 89846a56c7..ce0c5de2e4 100644 --- a/src/docs/api/entities/note.yaml +++ b/src/docs/api/entities/note.yaml @@ -75,6 +75,20 @@ props: ja-JP: "この投稿に対する自分の<a href='/docs/api/reactions'>リアクション</a>" en-US: "The your <a href='/docs/api/reactions'>reaction</a> of this note" + renoteCount: + type: "number" + optional: false + desc: + ja-JP: "この投稿がRenoteされた数" + en-US: "The number of renotes for this post" + + repliesCount: + type: "number" + optional: false + desc: + ja-JP: "この投稿に返信された数" + en-US: "The number of replies to this post" + reactionCounts: type: "object" optional: false diff --git a/src/docs/follow.ja-JP.md b/src/docs/follow.ja-JP.md index a883435ab4..28a606e283 100644 --- a/src/docs/follow.ja-JP.md +++ b/src/docs/follow.ja-JP.md @@ -1,8 +1,3 @@ # フォロー ユーザーをフォローすると、タイムラインにそのユーザーの投稿が表示されるようになります。ただし、他のユーザーに対する返信は含まれません。 ユーザーをフォローするには、ユーザーページの「フォロー」ボタンをクリックします。フォローを解除するには、もう一度クリックします。 - -## ストーキング -ユーザーをフォローしている状態では、さらに「ストーキング」モードをオンにすることができます。ストーキングを行うと、タイムラインにそのユーザーの全ての投稿が表示されるようになります。つまり、他のユーザーに対する返信も含まれることになります。 -ストーキングするには、ユーザーページの「ストークする」をクリックします。ストーキングをやめるには、もう一度クリックします。 -ストーキングしていることは相手に通知されません。 diff --git a/src/docs/reversi-bot.ja-JP.md b/src/docs/reversi-bot.ja-JP.md index 98b543ca6c..a389ead571 100644 --- a/src/docs/reversi-bot.ja-JP.md +++ b/src/docs/reversi-bot.ja-JP.md @@ -124,7 +124,7 @@ type: `switch` スイッチを表示します。何かの機能をオン/オフさせたい場合に有用です。 ##### プロパティ -`desc` ... スイッチの詳細な説明。 +`label` ... スイッチに表記するテキスト。 #### ラジオボタン type: `radio` diff --git a/src/games/reversi/core.ts b/src/games/reversi/core.ts index e724917fbf..a198e8dd27 100644 --- a/src/games/reversi/core.ts +++ b/src/games/reversi/core.ts @@ -1,4 +1,4 @@ -import { count, concat } from "../../prelude/array"; +import { count, concat } from '../../prelude/array'; // MISSKEY REVERSI ENGINE @@ -76,27 +76,14 @@ export default class Reversi { this.mapHeight = map.length; const mapData = map.join(''); - this.board = mapData.split('').map(d => { - if (d == '-') return null; - if (d == 'b') return BLACK; - if (d == 'w') return WHITE; - return undefined; - }); + this.board = mapData.split('').map(d => d === '-' ? null : d === 'b' ? BLACK : d === 'w' ? WHITE : undefined); - this.map = mapData.split('').map(d => { - if (d == '-' || d == 'b' || d == 'w') return 'empty'; - return 'null'; - }); + this.map = mapData.split('').map(d => d === '-' || d === 'b' || d === 'w' ? 'empty' : 'null'); //#endregion // ゲームが始まった時点で片方の色の石しかないか、始まった時点で勝敗が決定するようなマップの場合がある - if (!this.canPutSomewhere(BLACK)) { - if (!this.canPutSomewhere(WHITE)) { - this.turn = null; - } else { - this.turn = WHITE; - } - } + if (!this.canPutSomewhere(BLACK)) + this.turn = this.canPutSomewhere(WHITE) ? WHITE : null; } /** @@ -117,16 +104,14 @@ export default class Reversi { * 黒石の比率 */ public get blackP() { - if (this.blackCount == 0 && this.whiteCount == 0) return 0; - return this.blackCount / (this.blackCount + this.whiteCount); + return this.blackCount == 0 && this.whiteCount == 0 ? 0 : this.blackCount / (this.blackCount + this.whiteCount); } /** * 白石の比率 */ public get whiteP() { - if (this.blackCount == 0 && this.whiteCount == 0) return 0; - return this.whiteCount / (this.blackCount + this.whiteCount); + return this.blackCount == 0 && this.whiteCount == 0 ? 0 : this.whiteCount / (this.blackCount + this.whiteCount); } public transformPosToXy(pos: number): number[] { @@ -172,13 +157,10 @@ export default class Reversi { private calcTurn() { // ターン計算 - if (this.canPutSomewhere(!this.prevColor)) { - this.turn = !this.prevColor; - } else if (this.canPutSomewhere(this.prevColor)) { - this.turn = this.prevColor; - } else { - this.turn = null; - } + this.turn = + this.canPutSomewhere(!this.prevColor) ? !this.prevColor : + this.canPutSomewhere(this.prevColor) ? this.prevColor : + null; } public undo() { @@ -199,8 +181,7 @@ export default class Reversi { */ public mapDataGet(pos: number): MapPixel { const [x, y] = this.transformPosToXy(pos); - if (x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight) return 'null'; - return this.map[pos]; + return x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight ? 'null' : this.map[pos]; } /** @@ -223,16 +204,10 @@ export default class Reversi { * @param pos 位置 */ public canPut(color: Color, pos: number): boolean { - // 既に石が置いてある場所には打てない - if (this.board[pos] !== null) return false; - - if (this.opts.canPutEverywhere) { - // 挟んでなくても置けるモード - return this.mapDataGet(pos) == 'empty'; - } else { - // 相手の石を1つでも反転させられるか - return this.effects(color, pos).length !== 0; - } + return ( + this.board[pos] !== null ? false : // 既に石が置いてある場所には打てない + this.opts.canPutEverywhere ? this.mapDataGet(pos) == 'empty' : // 挟んでなくても置けるモード + this.effects(color, pos).length !== 0); // 相手の石を1つでも反転させられるか } /** @@ -263,19 +238,13 @@ export default class Reversi { [x, y] = nextPos(x, y); // 座標が指し示す位置がボード外に出たとき - if (this.opts.loopedBoard) { - x = ((x % this.mapWidth) + this.mapWidth) % this.mapWidth; - y = ((y % this.mapHeight) + this.mapHeight) % this.mapHeight; - - if (this.transformXyToPos(x, y) == initPos) { + if (this.opts.loopedBoard && this.transformXyToPos( + (x = ((x % this.mapWidth) + this.mapWidth) % this.mapWidth), + (y = ((y % this.mapHeight) + this.mapHeight) % this.mapHeight)) == initPos) // 盤面の境界でループし、自分が石を置く位置に戻ってきたとき、挟めるようにしている (ref: Test4のマップ) - return found; - } - } else { - if (x == -1 || y == -1 || x == this.mapWidth || y == this.mapHeight) { - return []; // 挟めないことが確定 (盤面外に到達) - } - } + return found; + else if (x == -1 || y == -1 || x == this.mapWidth || y == this.mapHeight) + return []; // 挟めないことが確定 (盤面外に到達) const pos = this.transformXyToPos(x, y); if (this.mapDataGet(pos) === 'null') return []; // 挟めないことが確定 (配置不可能なマスに到達) @@ -300,14 +269,9 @@ export default class Reversi { * ゲームの勝者 (null = 引き分け) */ public get winner(): Color { - if (!this.isEnded) return undefined; - - if (this.blackCount == this.whiteCount) return null; - - if (this.opts.isLlotheo) { - return this.blackCount > this.whiteCount ? WHITE : BLACK; - } else { - return this.blackCount > this.whiteCount ? BLACK : WHITE; - } + return this.isEnded ? + this.blackCount == this.whiteCount ? null : + this.opts.isLlotheo === this.blackCount > this.whiteCount ? WHITE : BLACK : + undefined; } } diff --git a/src/games/reversi/maps.ts b/src/games/reversi/maps.ts index f55cb1d516..f74cc85659 100644 --- a/src/games/reversi/maps.ts +++ b/src/games/reversi/maps.ts @@ -892,7 +892,7 @@ export const test4: Map = { ] }; -// https://misskey.xyz/reversi/5aaabf7fe126e10b5216ea09 64 +// https://misskey.xyz/games/reversi/5aaabf7fe126e10b5216ea09 64 export const test5: Map = { name: 'Test5', category: 'Test', diff --git a/src/index.ts b/src/index.ts index 4ecc2c016f..b61283b4e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,11 +14,10 @@ import * as portscanner from 'portscanner'; import isRoot = require('is-root'); import Xev from 'xev'; import * as program from 'commander'; +import * as sysUtils from 'systeminformation'; import mongo, { nativeDbConn } from './db/mongodb'; import Logger from './misc/logger'; -import EnvironmentInfo from './misc/environmentInfo'; -import MachineInfo from './misc/machineInfo'; import serverStats from './daemons/server-stats'; import notesStats from './daemons/notes-stats'; import loadConfig from './config/load'; @@ -42,8 +41,6 @@ program .parse(process.argv); //#endregion -main(); - /** * Init process */ @@ -105,6 +102,43 @@ async function workerMain() { } } +const runningNodejsVersion = process.version.slice(1).split('.').map(x => parseInt(x, 10)); +const requiredNodejsVersion = [10, 0, 0]; +const satisfyNodejsVersion = !lessThan(runningNodejsVersion, requiredNodejsVersion); + +function isWellKnownPort(port: number): boolean { + return port < 1024; +} + +async function isPortAvailable(port: number): Promise<boolean> { + return await portscanner.checkPortStatus(port, '127.0.0.1') === 'closed'; +} + +async function showMachine() { + const logger = new Logger('Machine'); + logger.info(`Hostname: ${os.hostname()}`); + logger.info(`Platform: ${process.platform}`); + logger.info(`Architecture: ${process.arch}`); + logger.info(`CPU: ${os.cpus().length} core`); + const mem = await sysUtils.mem(); + const totalmem = (mem.total / 1024 / 1024 / 1024).toFixed(1); + const availmem = (mem.available / 1024 / 1024 / 1024).toFixed(1); + logger.info(`MEM: ${totalmem}GB (available: ${availmem}GB)`); +} + +function showEnvironment(): void { + const env = process.env.NODE_ENV; + const logger = new Logger('Env'); + logger.info(typeof env == 'undefined' ? 'NODE_ENV is not set' : `NODE_ENV: ${env}`); + + if (env !== 'production') { + logger.warn('The environment is not in production mode'); + logger.warn('Do not use for production purpose'); + } + + logger.info(`You ${isRoot() ? '' : 'do not '}have root privileges`); +} + /** * Init app */ @@ -112,14 +146,15 @@ async function init(): Promise<Config> { Logger.info('Welcome to Misskey!'); Logger.info(`<<< Misskey v${pkg.version} >>>`); - new Logger('Nodejs').info(`Version ${process.version}`); - if (lessThan(process.version.slice(1).split('.').map(x => parseInt(x, 10)), [10, 0, 0])) { - new Logger('Nodejs').error(`Node.js version is less than 10.0.0. Please upgrade it.`); + new Logger('Nodejs').info(`Version ${runningNodejsVersion.join('.')}`); + + if (!satisfyNodejsVersion) { + new Logger('Nodejs').error(`Node.js version is less than ${requiredNodejsVersion.join('.')}. Please upgrade it.`); process.exit(1); } - await MachineInfo.show(); - EnvironmentInfo.show(); + await showMachine(); + showEnvironment(); const configLogger = new Logger('Config'); let config; @@ -145,23 +180,25 @@ async function init(): Promise<Config> { process.exit(1); } - if (process.platform === 'linux' && !isRoot() && config.port < 1024) { - Logger.error('You need root privileges to listen on port below 1024 on Linux'); + if (process.platform === 'linux' && isWellKnownPort(config.port) && !isRoot()) { + Logger.error('You need root privileges to listen on well-known port on Linux'); process.exit(1); } - if (await portscanner.checkPortStatus(config.port, '127.0.0.1') === 'open') { + if (!await isPortAvailable(config.port)) { Logger.error(`Port ${config.port} is already in use`); process.exit(1); } // Try to connect to MongoDB - checkMongoDb(config); + await checkMongoDB(config); return config; } -function checkMongoDb(config: Config) { +const requiredMongoDBVersion = [3, 6]; + +function checkMongoDB(config: Config) { const mongoDBLogger = new Logger('MongoDB'); const u = config.mongodb.user ? encodeURIComponent(config.mongodb.user) : null; const p = config.mongodb.pass ? encodeURIComponent(config.mongodb.pass) : null; @@ -169,46 +206,35 @@ function checkMongoDb(config: Config) { mongoDBLogger.info(`Connecting to ${uri}`); mongo.then(() => { + mongoDBLogger.succ('Connectivity confirmed'); + nativeDbConn().then(db => db.admin().serverInfo()).then(x => x.version).then((version: string) => { mongoDBLogger.info(`Version: ${version}`); - if (lessThan(version.split('.').map(x => parseInt(x, 10)), [3, 6])) { - mongoDBLogger.error(`MongoDB version is less than 3.6. Please upgrade it.`); + if (lessThan(version.split('.').map(x => parseInt(x, 10)), requiredMongoDBVersion)) { + mongoDBLogger.error(`MongoDB version is less than ${requiredMongoDBVersion.join('.')}. Please upgrade it.`); process.exit(1); } }); - - mongoDBLogger.succ('Connectivity confirmed'); - }) - .catch(err => { - mongoDBLogger.error(err.message); - }); + }).catch(err => { + mongoDBLogger.error(err.message); + }); } -function spawnWorkers(limit: number) { - Logger.info('Starting workers...'); +async function spawnWorkers(limit: number = Infinity) { + const workers = Math.min(limit, os.cpus().length); + Logger.info(`Starting ${workers} worker${workers === 1 ? '' : 's'}...`); + await Promise.all([...Array(workers)].map(spawnWorker)); + Logger.succ('All workers started'); +} +function spawnWorker(): Promise<void> { return new Promise(res => { - // Count the machine's CPUs - const cpuCount = os.cpus().length; - - const count = limit || cpuCount; - let started = 0; - - // Create a worker for each CPU - for (let i = 0; i < count; i++) { - const worker = cluster.fork(); - - worker.on('message', message => { - if (message !== 'ready') return; - started++; - - // When all workers started - if (started == count) { - Logger.succ('All workers started'); - res(); - } - }); - } + const worker = cluster.fork(); + worker.on('message', message => { + if (message !== 'ready') return; + Logger.succ('A worker started'); + res(); + }); }); } @@ -246,3 +272,5 @@ process.on('exit', code => { }); //#endregion + +main(); diff --git a/src/mfm/html-to-mfm.ts b/src/mfm/html-to-mfm.ts index aa887c5560..1441f97d94 100644 --- a/src/mfm/html-to-mfm.ts +++ b/src/mfm/html-to-mfm.ts @@ -8,7 +8,9 @@ export default function(html: string): string { let text = ''; - dom.childNodes.forEach((n: any) => analyze(n)); + for (const n of dom.childNodes) { + analyze(n); + } return text.trim(); @@ -41,7 +43,7 @@ export default function(html: string): string { if ((rel && rel.value.match('tag') !== null) || !href || href.value == txt) { text += txt; // メンション - } else if (txt.startsWith('@')) { + } else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) { const part = txt.split('@'); if (part.length == 2) { @@ -61,13 +63,17 @@ export default function(html: string): string { case 'p': text += '\n\n'; if (node.childNodes) { - node.childNodes.forEach((n: any) => analyze(n)); + for (const n of node.childNodes) { + analyze(n); + } } break; default: if (node.childNodes) { - node.childNodes.forEach((n: any) => analyze(n)); + for (const n of node.childNodes) { + analyze(n); + } } break; } diff --git a/src/mfm/html.ts b/src/mfm/html.ts index cb7c7e2855..6af2833858 100644 --- a/src/mfm/html.ts +++ b/src/mfm/html.ts @@ -1,127 +1,163 @@ -const { lib: emojilib } = require('emojilib'); const jsdom = require('jsdom'); const { JSDOM } = jsdom; import config from '../config'; import { INote } from '../models/note'; -import { TextElement } from './parse'; import { intersperse } from '../prelude/array'; +import { MfmForest, MfmTree } from './parser'; -const handlers: { [key: string]: (window: any, token: any, mentionedRemoteUsers: INote['mentionedRemoteUsers']) => void } = { - bold({ document }, { bold }) { - const b = document.createElement('b'); - b.textContent = bold; - document.body.appendChild(b); - }, +export default (tokens: MfmForest, mentionedRemoteUsers: INote['mentionedRemoteUsers'] = []) => { + if (tokens == null) { + return null; + } + + const { window } = new JSDOM(''); + + const doc = window.document; - big({ document }, { big }) { - const b = document.createElement('strong'); - b.textContent = big; - document.body.appendChild(b); - }, + function appendChildren(children: MfmForest, targetElement: any): void { + for (const child of children.map(t => handlers[t.node.type](t))) targetElement.appendChild(child); + } + + const handlers: { [key: string]: (token: MfmTree) => any } = { + bold(token) { + const el = doc.createElement('b'); + appendChildren(token.children, el); + return el; + }, + + big(token) { + const el = doc.createElement('strong'); + appendChildren(token.children, el); + return el; + }, - motion({ document }, { big }) { - const b = document.createElement('strong'); - b.textContent = big; - document.body.appendChild(b); - }, + small(token) { + const el = doc.createElement('small'); + appendChildren(token.children, el); + return el; + }, - code({ document }, { code }) { - const pre = document.createElement('pre'); - const inner = document.createElement('code'); - inner.innerHTML = code; - pre.appendChild(inner); - document.body.appendChild(pre); - }, + strike(token) { + const el = doc.createElement('del'); + appendChildren(token.children, el); + return el; + }, - emoji({ document }, { content, emoji }) { - const found = emojilib[emoji]; - const node = document.createTextNode(found ? found.char : content); - document.body.appendChild(node); - }, + italic(token) { + const el = doc.createElement('i'); + appendChildren(token.children, el); + return el; + }, - hashtag({ document }, { hashtag }) { - const a = document.createElement('a'); - a.href = `${config.url}/tags/${hashtag}`; - a.textContent = `#${hashtag}`; - a.setAttribute('rel', 'tag'); - document.body.appendChild(a); - }, + motion(token) { + const el = doc.createElement('i'); + appendChildren(token.children, el); + return el; + }, - 'inline-code'({ document }, { code }) { - const element = document.createElement('code'); - element.textContent = code; - document.body.appendChild(element); - }, + blockCode(token) { + const pre = doc.createElement('pre'); + const inner = doc.createElement('code'); + inner.innerHTML = token.node.props.code; + pre.appendChild(inner); + return pre; + }, - math({ document }, { formula }) { - const element = document.createElement('code'); - element.textContent = formula; - document.body.appendChild(element); - }, + center(token) { + const el = doc.createElement('div'); + appendChildren(token.children, el); + return el; + }, - link({ document }, { url, title }) { - const a = document.createElement('a'); - a.href = url; - a.textContent = title; - document.body.appendChild(a); - }, + emoji(token) { + return doc.createTextNode(token.node.props.emoji ? token.node.props.emoji : `:${token.node.props.name}:`); + }, - mention({ document }, { content, username, host }, mentionedRemoteUsers) { - const a = document.createElement('a'); - const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host); - a.href = remoteUserInfo ? remoteUserInfo.uri : `${config.url}/${content}`; - a.textContent = content; - document.body.appendChild(a); - }, + hashtag(token) { + const a = doc.createElement('a'); + a.href = `${config.url}/tags/${token.node.props.hashtag}`; + a.textContent = `#${token.node.props.hashtag}`; + a.setAttribute('rel', 'tag'); + return a; + }, - quote({ document }, { quote }) { - const blockquote = document.createElement('blockquote'); - blockquote.textContent = quote; - document.body.appendChild(blockquote); - }, + inlineCode(token) { + const el = doc.createElement('code'); + el.textContent = token.node.props.code; + return el; + }, - title({ document }, { content }) { - const h1 = document.createElement('h1'); - h1.textContent = content; - document.body.appendChild(h1); - }, + math(token) { + const el = doc.createElement('code'); + el.textContent = token.node.props.formula; + return el; + }, - text({ document }, { content }) { - const nodes = (content as string).split('\n').map(x => document.createTextNode(x)); - for (const x of intersperse('br', nodes)) { - if (x === 'br') { - document.body.appendChild(document.createElement('br')); - } else { - document.body.appendChild(x); + link(token) { + const a = doc.createElement('a'); + a.href = token.node.props.url; + appendChildren(token.children, a); + return a; + }, + + mention(token) { + const a = doc.createElement('a'); + const { username, host, acct } = token.node.props; + switch (host) { + case 'github.com': + a.href = `https://github.com/${username}`; + break; + case 'twitter.com': + a.href = `https://twitter.com/${username}`; + break; + default: + const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host); + a.href = remoteUserInfo ? remoteUserInfo.uri : `${config.url}/${acct}`; + break; } - } - }, + a.textContent = acct; + return a; + }, - url({ document }, { url }) { - const a = document.createElement('a'); - a.href = url; - a.textContent = url; - document.body.appendChild(a); - }, + quote(token) { + const el = doc.createElement('blockquote'); + appendChildren(token.children, el); + return el; + }, - search({ document }, { content, query }) { - const a = document.createElement('a'); - a.href = `https://www.google.com/?#q=${query}`; - a.textContent = content; - document.body.appendChild(a); - } -}; + title(token) { + const el = doc.createElement('h1'); + appendChildren(token.children, el); + return el; + }, -export default (tokens: TextElement[], mentionedRemoteUsers: INote['mentionedRemoteUsers'] = []) => { - if (tokens == null) { - return null; - } + text(token) { + const el = doc.createElement('span'); + const nodes = (token.node.props.text as string).split('\n').map(x => doc.createTextNode(x)); - const { window } = new JSDOM(''); + for (const x of intersperse('br', nodes)) { + el.appendChild(x === 'br' ? doc.createElement('br') : x); + } - for (const token of tokens) { - handlers[token.type](window, token, mentionedRemoteUsers); - } + return el; + }, + + url(token) { + const a = doc.createElement('a'); + a.href = token.node.props.url; + a.textContent = token.node.props.url; + return a; + }, + + search(token) { + const a = doc.createElement('a'); + a.href = `https://www.google.com/?#q=${token.node.props.query}`; + a.textContent = token.node.props.content; + return a; + } + }; + + appendChildren(tokens, doc.body); - return `<p>${window.document.body.innerHTML}</p>`; + return `<p>${doc.body.innerHTML}</p>`; }; diff --git a/src/mfm/parse.ts b/src/mfm/parse.ts new file mode 100644 index 0000000000..21e4ca651f --- /dev/null +++ b/src/mfm/parse.ts @@ -0,0 +1,36 @@ +import parser, { plainParser, MfmForest, MfmTree } from './parser'; +import * as A from '../prelude/array'; +import * as S from '../prelude/string'; +import { createTree, createLeaf } from '../prelude/tree'; + +function concatTextTrees(ts: MfmForest): MfmTree { + return createLeaf({ type: 'text', props: { text: S.concat(ts.map(x => x.node.props.text)) } }); +} + +function concatIfTextTrees(ts: MfmForest): MfmForest { + return ts[0].node.type === 'text' ? [concatTextTrees(ts)] : ts; +} + +function concatConsecutiveTextTrees(ts: MfmForest): MfmForest { + const us = A.concat(A.groupOn(t => t.node.type, ts).map(concatIfTextTrees)); + return us.map(t => createTree(t.node, concatConsecutiveTextTrees(t.children))); +} + +function isEmptyTextTree(t: MfmTree): boolean { + return t.node.type == 'text' && t.node.props.text === ''; +} + +function removeEmptyTextNodes(ts: MfmForest): MfmForest { + return ts + .filter(t => !isEmptyTextTree(t)) + .map(t => createTree(t.node, removeEmptyTextNodes(t.children))); +} + +export default (source: string, plainText = false): MfmForest => { + if (source == null || source == '') { + return null; + } + + const raw = plainText ? plainParser.root.tryParse(source) : parser.root.tryParse(source) as MfmForest; + return removeEmptyTextNodes(concatConsecutiveTextTrees(raw)); +}; diff --git a/src/mfm/parse/elements/big.ts b/src/mfm/parse/elements/big.ts deleted file mode 100644 index 24e8bad50e..0000000000 --- a/src/mfm/parse/elements/big.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Big - */ - -export type TextElementBig = { - type: 'big'; - content: string; - big: string; -}; - -export default function(text: string) { - const match = text.match(/^\*\*\*(.+?)\*\*\*/); - if (!match) return null; - const big = match[0]; - return { - type: 'big', - content: big, - big: match[1] - } as TextElementBig; -} diff --git a/src/mfm/parse/elements/bold.ts b/src/mfm/parse/elements/bold.ts deleted file mode 100644 index 42c9cf0e1e..0000000000 --- a/src/mfm/parse/elements/bold.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Bold - */ - -export type TextElementBold = { - type: 'bold'; - content: string; - bold: string; -}; - -export default function(text: string) { - const match = text.match(/^\*\*(.+?)\*\*/); - if (!match) return null; - const bold = match[0]; - return { - type: 'bold', - content: bold, - bold: match[1] - } as TextElementBold; -} diff --git a/src/mfm/parse/elements/code.ts b/src/mfm/parse/elements/code.ts deleted file mode 100644 index 63a535fc55..0000000000 --- a/src/mfm/parse/elements/code.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Code (block) - */ - -import genHtml from '../core/syntax-highlighter'; - -export type TextElementCode = { - type: 'code'; - content: string; - code: string; - html: string; -}; - -export default function(text: string) { - const match = text.match(/^```([\s\S]+?)```/); - if (!match) return null; - const code = match[0]; - return { - type: 'code', - content: code, - code: match[1], - html: genHtml(match[1].trim()) - } as TextElementCode; -} diff --git a/src/mfm/parse/elements/emoji.regex.ts b/src/mfm/parse/elements/emoji.regex.ts deleted file mode 100644 index a5c6b71825..0000000000 --- a/src/mfm/parse/elements/emoji.regex.ts +++ /dev/null @@ -1,2 +0,0 @@ -// https://github.com/twitter/twemoji/blob/fc458b467c1bd706acd8653028ee8ab3e6562ce3/2/scripts/regex -export const emojiRegex = /^((?:\ud83d[\udc68\udc69])(?:\ud83c[\udffb-\udfff])?\u200d(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|\ud83c[\udf3e\udf73\udf93\udfa4\udfa8\udfeb\udfed]|\ud83d[\udcbb\udcbc\udd27\udd2c\ude80\ude92]|\ud83e[\uddb0-\uddb3])|(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75]|\u26f9)((?:\ud83c[\udffb-\udfff]|\ufe0f)\u200d[\u2640\u2642]\ufe0f)|(?:\ud83c[\udfc3\udfc4\udfca]|\ud83d[\udc6e\udc71\udc73\udc77\udc81\udc82\udc86\udc87\ude45-\ude47\ude4b\ude4d\ude4e\udea3\udeb4-\udeb6]|\ud83e[\udd26\udd35\udd37-\udd39\udd3d\udd3e\uddb8\uddb9\uddd6-\udddd])(?:\ud83c[\udffb-\udfff])?\u200d[\u2640\u2642]\ufe0f|(?:\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83c\udff4\u200d\u2620\ufe0f|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83e\udd3c\u200d\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f)|[\u0023\u002a\u0030-\u0039]\ufe0f?\u20e3|(?:[\u00a9\u00ae\u2122\u265f]\ufe0f)|(?:\ud83c[\udc04\udd70\udd71\udd7e\udd7f\ude02\ude1a\ude2f\ude37\udf21\udf24-\udf2c\udf36\udf7d\udf96\udf97\udf99-\udf9b\udf9e\udf9f\udfcd\udfce\udfd4-\udfdf\udff3\udff5\udff7]|\ud83d[\udc3f\udc41\udcfd\udd49\udd4a\udd6f\udd70\udd73\udd76-\udd79\udd87\udd8a-\udd8d\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa\udecb\udecd-\udecf\udee0-\udee5\udee9\udef0\udef3]|[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299])(?:\ufe0f|(?!\ufe0e))|(?:(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75\udd90]|[\u261d\u26f7\u26f9\u270c\u270d])(?:\ufe0f|(?!\ufe0e))|(?:\ud83c[\udf85\udfc2-\udfc4\udfc7\udfca]|\ud83d[\udc42\udc43\udc46-\udc50\udc66-\udc69\udc6e\udc70-\udc78\udc7c\udc81-\udc83\udc85-\udc87\udcaa\udd7a\udd95\udd96\ude45-\ude47\ude4b-\ude4f\udea3\udeb4-\udeb6\udec0\udecc]|\ud83e[\udd18-\udd1c\udd1e\udd1f\udd26\udd30-\udd39\udd3d\udd3e\uddb5\uddb6\uddb8\uddb9\uddd1-\udddd]|[\u270a\u270b]))(?:\ud83c[\udffb-\udfff])?|(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|\ud83c\udde6\ud83c[\udde8-\uddec\uddee\uddf1\uddf2\uddf4\uddf6-\uddfa\uddfc\uddfd\uddff]|\ud83c\udde7\ud83c[\udde6\udde7\udde9-\uddef\uddf1-\uddf4\uddf6-\uddf9\uddfb\uddfc\uddfe\uddff]|\ud83c\udde8\ud83c[\udde6\udde8\udde9\uddeb-\uddee\uddf0-\uddf5\uddf7\uddfa-\uddff]|\ud83c\udde9\ud83c[\uddea\uddec\uddef\uddf0\uddf2\uddf4\uddff]|\ud83c\uddea\ud83c[\udde6\udde8\uddea\uddec\udded\uddf7-\uddfa]|\ud83c\uddeb\ud83c[\uddee-\uddf0\uddf2\uddf4\uddf7]|\ud83c\uddec\ud83c[\udde6\udde7\udde9-\uddee\uddf1-\uddf3\uddf5-\uddfa\uddfc\uddfe]|\ud83c\udded\ud83c[\uddf0\uddf2\uddf3\uddf7\uddf9\uddfa]|\ud83c\uddee\ud83c[\udde8-\uddea\uddf1-\uddf4\uddf6-\uddf9]|\ud83c\uddef\ud83c[\uddea\uddf2\uddf4\uddf5]|\ud83c\uddf0\ud83c[\uddea\uddec-\uddee\uddf2\uddf3\uddf5\uddf7\uddfc\uddfe\uddff]|\ud83c\uddf1\ud83c[\udde6-\udde8\uddee\uddf0\uddf7-\uddfb\uddfe]|\ud83c\uddf2\ud83c[\udde6\udde8-\udded\uddf0-\uddff]|\ud83c\uddf3\ud83c[\udde6\udde8\uddea-\uddec\uddee\uddf1\uddf4\uddf5\uddf7\uddfa\uddff]|\ud83c\uddf4\ud83c\uddf2|\ud83c\uddf5\ud83c[\udde6\uddea-\udded\uddf0-\uddf3\uddf7-\uddf9\uddfc\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7\ud83c[\uddea\uddf4\uddf8\uddfa\uddfc]|\ud83c\uddf8\ud83c[\udde6-\uddea\uddec-\uddf4\uddf7-\uddf9\uddfb\uddfd-\uddff]|\ud83c\uddf9\ud83c[\udde6\udde8\udde9\uddeb-\udded\uddef-\uddf4\uddf7\uddf9\uddfb\uddfc\uddff]|\ud83c\uddfa\ud83c[\udde6\uddec\uddf2\uddf3\uddf8\uddfe\uddff]|\ud83c\uddfb\ud83c[\udde6\udde8\uddea\uddec\uddee\uddf3\uddfa]|\ud83c\uddfc\ud83c[\uddeb\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c\uddfe\ud83c[\uddea\uddf9]|\ud83c\uddff\ud83c[\udde6\uddf2\uddfc]|\ud83c[\udccf\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude32-\ude36\ude38-\ude3a\ude50\ude51\udf00-\udf20\udf2d-\udf35\udf37-\udf7c\udf7e-\udf84\udf86-\udf93\udfa0-\udfc1\udfc5\udfc6\udfc8\udfc9\udfcf-\udfd3\udfe0-\udff0\udff4\udff8-\udfff]|\ud83d[\udc00-\udc3e\udc40\udc44\udc45\udc51-\udc65\udc6a-\udc6d\udc6f\udc79-\udc7b\udc7d-\udc80\udc84\udc88-\udca9\udcab-\udcfc\udcff-\udd3d\udd4b-\udd4e\udd50-\udd67\udda4\uddfb-\ude44\ude48-\ude4a\ude80-\udea2\udea4-\udeb3\udeb7-\udebf\udec1-\udec5\uded0-\uded2\udeeb\udeec\udef4-\udef9]|\ud83e[\udd10-\udd17\udd1d\udd20-\udd25\udd27-\udd2f\udd3a\udd3c\udd40-\udd45\udd47-\udd70\udd73-\udd76\udd7a\udd7c-\udda2\uddb4\uddb7\uddc0-\uddc2\uddd0\uddde-\uddff]|[\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797\u27b0\u27bf\ue50a])|\ufe0f)/; diff --git a/src/mfm/parse/elements/emoji.ts b/src/mfm/parse/elements/emoji.ts deleted file mode 100644 index 6c09ddf5c0..0000000000 --- a/src/mfm/parse/elements/emoji.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Emoji - */ - -import { emojiRegex } from "./emoji.regex"; - -export type TextElementEmoji = { - type: 'emoji'; - content: string; - emoji?: string; - name?: string; -}; - -export default function(text: string) { - const name = text.match(/^:([a-zA-Z0-9+_-]+):/); - if (name) { - return { - type: 'emoji', - content: name[0], - name: name[1] - } as TextElementEmoji; - } - const unicode = text.match(emojiRegex); - if (unicode) { - const [content] = unicode; - return { - type: 'emoji', - content, - emoji: content - } as TextElementEmoji; - } - return null; -} diff --git a/src/mfm/parse/elements/hashtag.ts b/src/mfm/parse/elements/hashtag.ts deleted file mode 100644 index df07de6645..0000000000 --- a/src/mfm/parse/elements/hashtag.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Hashtag - */ - -export type TextElementHashtag = { - type: 'hashtag'; - content: string; - hashtag: string; -}; - -export default function(text: string, before: string) { - const isBegin = before == ''; - - if (!(/^\s#[^\s\.,!\?#]+/.test(text) || (isBegin && /^#[^\s\.,!\?#]+/.test(text)))) return null; - const isHead = text.startsWith('#'); - const hashtag = text.match(/^\s?#[^\s\.,!\?#]+/)[0]; - const res: any[] = !isHead ? [{ - type: 'text', - content: text[0] - }] : []; - res.push({ - type: 'hashtag', - content: isHead ? hashtag : hashtag.substr(1), - hashtag: isHead ? hashtag.substr(1) : hashtag.substr(2) - }); - return res as TextElementHashtag[]; -} diff --git a/src/mfm/parse/elements/inline-code.ts b/src/mfm/parse/elements/inline-code.ts deleted file mode 100644 index efacd734cb..0000000000 --- a/src/mfm/parse/elements/inline-code.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Code (inline) - */ - -import genHtml from '../core/syntax-highlighter'; - -export type TextElementInlineCode = { - type: 'inline-code'; - content: string; - code: string; - html: string; -}; - -export default function(text: string) { - const match = text.match(/^`(.+?)`/); - if (!match) return null; - if (match[1].includes('´')) return null; - const code = match[0]; - return { - type: 'inline-code', - content: code, - code: match[1], - html: genHtml(match[1]) - } as TextElementInlineCode; -} diff --git a/src/mfm/parse/elements/link.ts b/src/mfm/parse/elements/link.ts deleted file mode 100644 index 972fce3810..0000000000 --- a/src/mfm/parse/elements/link.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Link - */ - -export type TextElementLink = { - type: 'link'; - content: string; - title: string; - url: string; - silent: boolean; -}; - -export default function(text: string) { - const match = text.match(/^\??\[([^\[\]]+?)\]\((https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.=\+\-]+?)\)/); - if (!match) return null; - const silent = text.startsWith('?'); - const link = match[0]; - const title = match[1]; - const url = match[2]; - return { - type: 'link', - content: link, - title: title, - url: url, - silent: silent - } as TextElementLink; -} diff --git a/src/mfm/parse/elements/math.ts b/src/mfm/parse/elements/math.ts deleted file mode 100644 index f2b6c5f479..0000000000 --- a/src/mfm/parse/elements/math.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Math - */ - -export type TextElementMath = { - type: 'math'; - content: string; - formula: string; -}; - -export default function(text: string) { - const match = text.match(/^\\\((.+?)\\\)/); - if (!match) return null; - const math = match[0]; - return { - type: 'math', - content: math, - formula: match[1] - } as TextElementMath; -} diff --git a/src/mfm/parse/elements/mention.ts b/src/mfm/parse/elements/mention.ts deleted file mode 100644 index 7a609e5d34..0000000000 --- a/src/mfm/parse/elements/mention.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Mention - */ -import parseAcct from '../../../misc/acct/parse'; -import { toUnicode } from 'punycode'; - -export type TextElementMention = { - type: 'mention'; - content: string; - canonical: string; - username: string; - host: string; -}; - -export default function(text: string, before: string) { - const match = text.match(/^@[a-z0-9_]+(?:@[a-z0-9\.\-]+[a-z0-9])?/i); - if (!match) return null; - if (/[a-zA-Z0-9]$/.test(before)) return null; - const mention = match[0]; - const { username, host } = parseAcct(mention.substr(1)); - const canonical = host != null ? `@${username}@${toUnicode(host)}` : mention; - return { - type: 'mention', - content: mention, - canonical, - username, - host - } as TextElementMention; -} diff --git a/src/mfm/parse/elements/motion.ts b/src/mfm/parse/elements/motion.ts deleted file mode 100644 index c6500e7be0..0000000000 --- a/src/mfm/parse/elements/motion.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Motion - */ - -export type TextElementMotion = { - type: 'motion'; - content: string; - motion: string; -}; - -export default function(text: string) { - const match = text.match(/^\(\(\((.+?)\)\)\)/) || text.match(/^<motion>(.+?)<\/motion>/); - if (!match) return null; - const motion = match[0]; - return { - type: 'motion', - content: motion, - motion: match[1] - } as TextElementMotion; -} diff --git a/src/mfm/parse/elements/quote.ts b/src/mfm/parse/elements/quote.ts deleted file mode 100644 index 969c1fb4a9..0000000000 --- a/src/mfm/parse/elements/quote.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Quoted text - */ - -export type TextElementQuote = { - type: 'quote'; - content: string; - quote: string; -}; - -export default function(text: string, before: string) { - const isBegin = before == ''; - - const match = text.match(/^"([\s\S]+?)\n"/) || text.match(/^\n>([\s\S]+?)(\n\n|$)/) || - (isBegin ? text.match(/^>([\s\S]+?)(\n\n|$)/) : null); - - if (!match) return null; - - const quote = match[1] - .split('\n') - .map(line => line.replace(/^>+/g, '').trim()) - .join('\n') - .trim(); - - return { - type: 'quote', - content: match[0], - quote: quote, - } as TextElementQuote; -} diff --git a/src/mfm/parse/elements/search.ts b/src/mfm/parse/elements/search.ts deleted file mode 100644 index f51844b079..0000000000 --- a/src/mfm/parse/elements/search.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Search - */ - -export type TextElementSearch = { - type: 'search'; - content: string; - query: string; -}; - -export default function(text: string) { - const match = text.match(/^(.+?)( | )(検索|\[検索\]|Search|\[Search\])(\n|$)/i); - if (!match) return null; - return { - type: 'search', - content: match[0], - query: match[1] - }; -} diff --git a/src/mfm/parse/elements/title.ts b/src/mfm/parse/elements/title.ts deleted file mode 100644 index a9922c8aca..0000000000 --- a/src/mfm/parse/elements/title.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Title - */ - -export type TextElementTitle = { - type: 'title'; - content: string; - title: string; -}; - -export default function(text: string, before: string) { - const isBegin = before == ''; - - const match = isBegin ? text.match(/^(【|\[)(.+?)(】|])\n/) : text.match(/^\n(【|\[)(.+?)(】|])\n/); - if (!match) return null; - return { - type: 'title', - content: match[0], - title: match[2] - } as TextElementTitle; -} diff --git a/src/mfm/parse/elements/url.ts b/src/mfm/parse/elements/url.ts deleted file mode 100644 index a16f67f2c2..0000000000 --- a/src/mfm/parse/elements/url.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * URL - */ - -export type TextElementUrl = { - type: 'url'; - content: string; - url: string; -}; - -export default function(text: string, before: string) { - const match = text.match(/^https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.,=\+\-]+/); - if (!match) return null; - let url = match[0]; - if (url.endsWith('.')) url = url.substr(0, url.lastIndexOf('.')); - if (url.endsWith(',')) url = url.substr(0, url.lastIndexOf(',')); - if (url.endsWith(')') && before.endsWith('(')) url = url.substr(0, url.lastIndexOf(')')); - return { - type: 'url', - content: url, - url: url - } as TextElementUrl; -} diff --git a/src/mfm/parse/index.ts b/src/mfm/parse/index.ts deleted file mode 100644 index 7697bb6e36..0000000000 --- a/src/mfm/parse/index.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Misskey Text Analyzer - */ - -import { TextElementBold } from './elements/bold'; -import { TextElementBig } from './elements/big'; -import { TextElementCode } from './elements/code'; -import { TextElementEmoji } from './elements/emoji'; -import { TextElementHashtag } from './elements/hashtag'; -import { TextElementInlineCode } from './elements/inline-code'; -import { TextElementMath } from './elements/math'; -import { TextElementLink } from './elements/link'; -import { TextElementMention } from './elements/mention'; -import { TextElementQuote } from './elements/quote'; -import { TextElementSearch } from './elements/search'; -import { TextElementTitle } from './elements/title'; -import { TextElementUrl } from './elements/url'; -import { TextElementMotion } from './elements/motion'; -import { groupOn } from '../../prelude/array'; -import * as A from '../../prelude/array'; -import * as S from '../../prelude/string'; - -const elements = [ - require('./elements/big'), - require('./elements/bold'), - require('./elements/title'), - require('./elements/url'), - require('./elements/link'), - require('./elements/mention'), - require('./elements/hashtag'), - require('./elements/code'), - require('./elements/inline-code'), - require('./elements/math'), - require('./elements/quote'), - require('./elements/emoji'), - require('./elements/search'), - require('./elements/motion') -].map(element => element.default as TextElementProcessor); - -export type TextElement = { type: 'text', content: string } - | TextElementBold - | TextElementBig - | TextElementCode - | TextElementEmoji - | TextElementHashtag - | TextElementInlineCode - | TextElementMath - | TextElementLink - | TextElementMention - | TextElementQuote - | TextElementSearch - | TextElementTitle - | TextElementUrl - | TextElementMotion; -export type TextElementProcessor = (text: string, before: string) => TextElement | TextElement[]; - -export default (source: string): TextElement[] => { - if (source == null || source == '') { - return null; - } - - const tokens: TextElement[] = []; - - function push(token: TextElement) { - if (token != null) { - tokens.push(token); - source = source.substr(token.content.length); - } - } - - // パース - while (source != '') { - const parsed = elements.some(el => { - let _tokens = el(source, tokens.map(token => token.content).join('')); - if (_tokens) { - if (!Array.isArray(_tokens)) { - _tokens = [_tokens]; - } - _tokens.forEach(push); - return true; - } else { - return false; - } - }); - - if (!parsed) { - push({ - type: 'text', - content: source[0] - }); - } - } - - const combineText = (es: TextElement[]): TextElement => - ({ type: 'text', content: S.concat(es.map(e => e.content)) }); - - return A.concat(groupOn(x => x.type, tokens).map(es => - es[0].type === 'text' ? [combineText(es)] : es - )); -}; diff --git a/src/mfm/parser.ts b/src/mfm/parser.ts new file mode 100644 index 0000000000..10b16d619a --- /dev/null +++ b/src/mfm/parser.ts @@ -0,0 +1,390 @@ +import * as P from 'parsimmon'; +import parseAcct from '../misc/acct/parse'; +import { toUnicode } from 'punycode'; +import { takeWhile, cumulativeSum } from '../prelude/array'; +import { Tree } from '../prelude/tree'; +import * as T from '../prelude/tree'; + +const emojiRegex = /((?:\ud83d[\udc68\udc69])(?:\ud83c[\udffb-\udfff])?\u200d(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|\ud83c[\udf3e\udf73\udf93\udfa4\udfa8\udfeb\udfed]|\ud83d[\udcbb\udcbc\udd27\udd2c\ude80\ude92]|\ud83e[\uddb0-\uddb3])|(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75]|\u26f9)((?:\ud83c[\udffb-\udfff]|\ufe0f)\u200d[\u2640\u2642]\ufe0f)|(?:\ud83c[\udfc3\udfc4\udfca]|\ud83d[\udc6e\udc71\udc73\udc77\udc81\udc82\udc86\udc87\ude45-\ude47\ude4b\ude4d\ude4e\udea3\udeb4-\udeb6]|\ud83e[\udd26\udd35\udd37-\udd39\udd3d\udd3e\uddb8\uddb9\uddd6-\udddd])(?:\ud83c[\udffb-\udfff])?\u200d[\u2640\u2642]\ufe0f|(?:\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83c\udff4\u200d\u2620\ufe0f|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83e\udd3c\u200d\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f)|[\u0023\u002a\u0030-\u0039]\ufe0f?\u20e3|(?:[\u00a9\u00ae\u2122\u265f]\ufe0f)|(?:\ud83c[\udc04\udd70\udd71\udd7e\udd7f\ude02\ude1a\ude2f\ude37\udf21\udf24-\udf2c\udf36\udf7d\udf96\udf97\udf99-\udf9b\udf9e\udf9f\udfcd\udfce\udfd4-\udfdf\udff3\udff5\udff7]|\ud83d[\udc3f\udc41\udcfd\udd49\udd4a\udd6f\udd70\udd73\udd76-\udd79\udd87\udd8a-\udd8d\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa\udecb\udecd-\udecf\udee0-\udee5\udee9\udef0\udef3]|[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299])(?:\ufe0f|(?!\ufe0e))|(?:(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75\udd90]|[\u261d\u26f7\u26f9\u270c\u270d])(?:\ufe0f|(?!\ufe0e))|(?:\ud83c[\udf85\udfc2-\udfc4\udfc7\udfca]|\ud83d[\udc42\udc43\udc46-\udc50\udc66-\udc69\udc6e\udc70-\udc78\udc7c\udc81-\udc83\udc85-\udc87\udcaa\udd7a\udd95\udd96\ude45-\ude47\ude4b-\ude4f\udea3\udeb4-\udeb6\udec0\udecc]|\ud83e[\udd18-\udd1c\udd1e\udd1f\udd26\udd30-\udd39\udd3d\udd3e\uddb5\uddb6\uddb8\uddb9\uddd1-\udddd]|[\u270a\u270b]))(?:\ud83c[\udffb-\udfff])?|(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|\ud83c\udde6\ud83c[\udde8-\uddec\uddee\uddf1\uddf2\uddf4\uddf6-\uddfa\uddfc\uddfd\uddff]|\ud83c\udde7\ud83c[\udde6\udde7\udde9-\uddef\uddf1-\uddf4\uddf6-\uddf9\uddfb\uddfc\uddfe\uddff]|\ud83c\udde8\ud83c[\udde6\udde8\udde9\uddeb-\uddee\uddf0-\uddf5\uddf7\uddfa-\uddff]|\ud83c\udde9\ud83c[\uddea\uddec\uddef\uddf0\uddf2\uddf4\uddff]|\ud83c\uddea\ud83c[\udde6\udde8\uddea\uddec\udded\uddf7-\uddfa]|\ud83c\uddeb\ud83c[\uddee-\uddf0\uddf2\uddf4\uddf7]|\ud83c\uddec\ud83c[\udde6\udde7\udde9-\uddee\uddf1-\uddf3\uddf5-\uddfa\uddfc\uddfe]|\ud83c\udded\ud83c[\uddf0\uddf2\uddf3\uddf7\uddf9\uddfa]|\ud83c\uddee\ud83c[\udde8-\uddea\uddf1-\uddf4\uddf6-\uddf9]|\ud83c\uddef\ud83c[\uddea\uddf2\uddf4\uddf5]|\ud83c\uddf0\ud83c[\uddea\uddec-\uddee\uddf2\uddf3\uddf5\uddf7\uddfc\uddfe\uddff]|\ud83c\uddf1\ud83c[\udde6-\udde8\uddee\uddf0\uddf7-\uddfb\uddfe]|\ud83c\uddf2\ud83c[\udde6\udde8-\udded\uddf0-\uddff]|\ud83c\uddf3\ud83c[\udde6\udde8\uddea-\uddec\uddee\uddf1\uddf4\uddf5\uddf7\uddfa\uddff]|\ud83c\uddf4\ud83c\uddf2|\ud83c\uddf5\ud83c[\udde6\uddea-\udded\uddf0-\uddf3\uddf7-\uddf9\uddfc\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7\ud83c[\uddea\uddf4\uddf8\uddfa\uddfc]|\ud83c\uddf8\ud83c[\udde6-\uddea\uddec-\uddf4\uddf7-\uddf9\uddfb\uddfd-\uddff]|\ud83c\uddf9\ud83c[\udde6\udde8\udde9\uddeb-\udded\uddef-\uddf4\uddf7\uddf9\uddfb\uddfc\uddff]|\ud83c\uddfa\ud83c[\udde6\uddec\uddf2\uddf3\uddf8\uddfe\uddff]|\ud83c\uddfb\ud83c[\udde6\udde8\uddea\uddec\uddee\uddf3\uddfa]|\ud83c\uddfc\ud83c[\uddeb\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c\uddfe\ud83c[\uddea\uddf9]|\ud83c\uddff\ud83c[\udde6\uddf2\uddfc]|\ud83c[\udccf\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude32-\ude36\ude38-\ude3a\ude50\ude51\udf00-\udf20\udf2d-\udf35\udf37-\udf7c\udf7e-\udf84\udf86-\udf93\udfa0-\udfc1\udfc5\udfc6\udfc8\udfc9\udfcf-\udfd3\udfe0-\udff0\udff4\udff8-\udfff]|\ud83d[\udc00-\udc3e\udc40\udc44\udc45\udc51-\udc65\udc6a-\udc6d\udc6f\udc79-\udc7b\udc7d-\udc80\udc84\udc88-\udca9\udcab-\udcfc\udcff-\udd3d\udd4b-\udd4e\udd50-\udd67\udda4\uddfb-\ude44\ude48-\ude4a\ude80-\udea2\udea4-\udeb3\udeb7-\udebf\udec1-\udec5\uded0-\uded2\udeeb\udeec\udef4-\udef9]|\ud83e[\udd10-\udd17\udd1d\udd20-\udd25\udd27-\udd2f\udd3a\udd3c\udd40-\udd45\udd47-\udd70\udd73-\udd76\udd7a\udd7c-\udda2\uddb4\uddb7\uddc0-\uddc2\uddd0\uddde-\uddff]|[\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797\u27b0\u27bf\ue50a])|\ufe0f)/; + +type Node<T, P> = { type: T, props: P }; + +export type MentionNode = Node<'mention', { + canonical: string, + username: string, + host: string, + acct: string +}>; + +export type HashtagNode = Node<'hashtag', { + hashtag: string +}>; + +export type EmojiNode = Node<'emoji', { + name: string +}>; + +export type MfmNode = + MentionNode | + HashtagNode | + EmojiNode | + Node<string, any>; + +export type MfmTree = Tree<MfmNode>; + +export type MfmForest = MfmTree[]; + +export function createLeaf(type: string, props: any): MfmTree { + return T.createLeaf({ type, props }); +} + +export function createTree(type: string, children: MfmForest, props: any): MfmTree { + return T.createTree({ type, props }, children); +} + +export function removeOrphanedBrackets(s: string): string { + const openBrackets = ['(', '「']; + const closeBrackets = [')', '」']; + const xs = cumulativeSum(s.split('').map(c => { + if (openBrackets.includes(c)) return 1; + if (closeBrackets.includes(c)) return -1; + return 0; + })); + const firstOrphanedCloseBracket = xs.findIndex(x => x < 0); + if (firstOrphanedCloseBracket !== -1) return s.substr(0, firstOrphanedCloseBracket); + const lastMatched = xs.lastIndexOf(0); + return s.substr(0, lastMatched + 1); +} + +const newline = P((input, i) => { + if (i == 0 || input[i] == '\n' || input[i - 1] == '\n') { + return P.makeSuccess(i, null); + } else { + return P.makeFailure(i, 'not newline'); + } +}); + +export const plainParser = P.createLanguage({ + root: r => P.alt( + r.emoji, + r.text + ).atLeast(1), + + text: () => P.any.map(x => createLeaf('text', { text: x })), + + //#region Emoji + emoji: r => + P.alt( + P.regexp(/:([a-z0-9_+-]+):/i, 1) + .map(x => createLeaf('emoji', { + name: x + })), + P.regexp(emojiRegex) + .map(x => createLeaf('emoji', { + emoji: x + })), + ), + //#endregion +}); + +const mfm = P.createLanguage({ + root: r => P.alt( + r.big, + r.small, + r.bold, + r.strike, + r.italic, + r.motion, + r.url, + r.link, + r.mention, + r.hashtag, + r.emoji, + r.blockCode, + r.inlineCode, + r.quote, + r.math, + r.search, + r.title, + r.center, + r.text + ).atLeast(1), + + text: () => P.any.map(x => createLeaf('text', { text: x })), + + //#region Big + big: r => + P.regexp(/^\*\*\*([\s\S]+?)\*\*\*/, 1) + .map(x => createTree('big', P.alt( + r.strike, + r.italic, + r.mention, + r.hashtag, + r.emoji, + r.math, + r.text + ).atLeast(1).tryParse(x), {})), + //#endregion + + //#region Small + small: r => + P.regexp(/<small>([\s\S]+?)<\/small>/, 1) + .map(x => createTree('small', P.alt( + r.strike, + r.italic, + r.mention, + r.hashtag, + r.emoji, + r.math, + r.text + ).atLeast(1).tryParse(x), {})), + //#endregion + + //#region Block code + blockCode: r => + newline.then( + P((input, i) => { + const text = input.substr(i); + const match = text.match(/^```(.+?)?\n([\s\S]+?)\n```(\n|$)/i); + if (!match) return P.makeFailure(i, 'not a blockCode'); + return P.makeSuccess(i + match[0].length, createLeaf('blockCode', { code: match[2], lang: match[1] ? match[1].trim() : null })); + }) + ), + //#endregion + + //#region Bold + bold: r => + P.alt(P.regexp(/\*\*([\s\S]+?)\*\*/, 1), P.regexp(/__([a-zA-Z0-9\s]+?)__/, 1)) + .map(x => createTree('bold', P.alt( + r.strike, + r.italic, + r.mention, + r.hashtag, + r.url, + r.link, + r.emoji, + r.text + ).atLeast(1).tryParse(x), {})), + //#endregion + + //#region Center + center: r => + P.regexp(/<center>([\s\S]+?)<\/center>/, 1) + .map(x => createTree('center', P.alt( + r.big, + r.small, + r.bold, + r.strike, + r.italic, + r.motion, + r.mention, + r.hashtag, + r.emoji, + r.math, + r.url, + r.link, + r.text + ).atLeast(1).tryParse(x), {})), + //#endregion + + //#region Emoji + emoji: r => + P.alt( + P.regexp(/:([a-z0-9_+-]+):/i, 1) + .map(x => createLeaf('emoji', { + name: x + })), + P.regexp(emojiRegex) + .map(x => createLeaf('emoji', { + emoji: x + })), + ), + //#endregion + + //#region Hashtag + hashtag: r => + P((input, i) => { + const text = input.substr(i); + const match = text.match(/^#([^\s\.,!\?'"#:]+)/i); + if (!match) return P.makeFailure(i, 'not a hashtag'); + let hashtag = match[1]; + hashtag = removeOrphanedBrackets(hashtag); + if (hashtag.match(/^[0-9]+$/)) return P.makeFailure(i, 'not a hashtag'); + if (input[i - 1] != null && input[i - 1].match(/[a-z0-9]/i)) return P.makeFailure(i, 'not a hashtag'); + if (hashtag.length > 50) return P.makeFailure(i, 'not a hashtag'); + return P.makeSuccess(i + ('#' + hashtag).length, createLeaf('hashtag', { hashtag: hashtag })); + }), + //#endregion + + //#region Inline code + inlineCode: r => + P.regexp(/`([^´\n]+?)`/, 1) + .map(x => createLeaf('inlineCode', { code: x })), + //#endregion + + //#region Italic + italic: r => + P.alt(P.regexp(/<i>([\s\S]+?)<\/i>/, 1), P.regexp(/(\*|_)([a-zA-Z0-9]+?[\s\S]*?)\1/, 2)) + .map(x => createTree('italic', P.alt( + r.bold, + r.strike, + r.mention, + r.hashtag, + r.url, + r.link, + r.emoji, + r.text + ).atLeast(1).tryParse(x), {})), + //#endregion + + //#region Link + link: r => + P.seqObj( + ['silent', P.string('?').fallback(null).map(x => x != null)] as any, + P.string('['), + ['text', P.regexp(/[^\n\[\]]+/)] as any, + P.string(']'), + P.string('('), + ['url', r.url] as any, + P.string(')'), + ) + .map((x: any) => { + return createTree('link', P.alt( + r.big, + r.small, + r.bold, + r.strike, + r.italic, + r.motion, + r.emoji, + r.text + ).atLeast(1).tryParse(x.text), { + silent: x.silent, + url: x.url.node.props.url + }); + }), + //#endregion + + //#region Math + math: r => + P.regexp(/\\\((.+?)\\\)/, 1) + .map(x => createLeaf('math', { formula: x })), + //#endregion + + //#region Mention + mention: r => + P((input, i) => { + const text = input.substr(i); + const match = text.match(/^@\w([\w-]*\w)?(?:@[\w\.\-]+\w)?/); + if (!match) return P.makeFailure(i, 'not a mention'); + if (input[i - 1] != null && input[i - 1].match(/[a-z0-9]/i)) return P.makeFailure(i, 'not a mention'); + return P.makeSuccess(i + match[0].length, match[0]); + }) + .map(x => { + const { username, host } = parseAcct(x.substr(1)); + const canonical = host != null ? `@${username}@${toUnicode(host)}` : x; + return createLeaf('mention', { + canonical, username, host, acct: x + }); + }), + //#endregion + + //#region Motion + motion: r => + P.alt(P.regexp(/\(\(\(([\s\S]+?)\)\)\)/, 1), P.regexp(/<motion>(.+?)<\/motion>/, 1)) + .map(x => createTree('motion', P.alt( + r.bold, + r.small, + r.strike, + r.italic, + r.mention, + r.hashtag, + r.emoji, + r.url, + r.link, + r.math, + r.text + ).atLeast(1).tryParse(x), {})), + //#endregion + + //#region Quote + quote: r => + newline.then(P((input, i) => { + const text = input.substr(i); + if (!text.match(/^>[\s\S]+?/)) return P.makeFailure(i, 'not a quote'); + const quote = takeWhile(line => line.startsWith('>'), text.split('\n')); + const qInner = quote.join('\n').replace(/^>/gm, '').replace(/^ /gm, ''); + if (qInner == '') return P.makeFailure(i, 'not a quote'); + const contents = r.root.tryParse(qInner); + return P.makeSuccess(i + quote.join('\n').length + 1, createTree('quote', contents, {})); + })), + //#endregion + + //#region Search + search: r => + newline.then(P((input, i) => { + const text = input.substr(i); + const match = text.match(/^(.+?)( | )(検索|\[検索\]|Search|\[Search\])(\n|$)/i); + if (!match) return P.makeFailure(i, 'not a search'); + return P.makeSuccess(i + match[0].length, createLeaf('search', { query: match[1], content: match[0].trim() })); + })), + //#endregion + + //#region Strike + strike: r => + P.regexp(/~~(.+?)~~/, 1) + .map(x => createTree('strike', P.alt( + r.bold, + r.italic, + r.mention, + r.hashtag, + r.url, + r.link, + r.emoji, + r.text + ).atLeast(1).tryParse(x), {})), + //#endregion + + //#region Title + title: r => + newline.then(P((input, i) => { + const text = input.substr(i); + const match = text.match(/^((【|\[)(.+?)(】|]))(\n|$)/); + if (!match) return P.makeFailure(i, 'not a title'); + const q = match[1].trim().substring(1, match[1].length - 1); + const contents = P.alt( + r.big, + r.small, + r.bold, + r.strike, + r.italic, + r.motion, + r.url, + r.link, + r.mention, + r.hashtag, + r.emoji, + r.inlineCode, + r.text + ).atLeast(1).tryParse(q); + return P.makeSuccess(i + match[0].length, createTree('title', contents, {})); + })), + //#endregion + + //#region URL + url: r => + P((input, i) => { + const text = input.substr(i); + const match = text.match(/^https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.,=\+\-]+/); + if (!match) return P.makeFailure(i, 'not a url'); + let url = match[0]; + url = removeOrphanedBrackets(url); + if (url.endsWith('.')) url = url.substr(0, url.lastIndexOf('.')); + if (url.endsWith(',')) url = url.substr(0, url.lastIndexOf(',')); + return P.makeSuccess(i + url.length, url); + }) + .map(x => createLeaf('url', { url: x })), + //#endregion +}); + +export default mfm; diff --git a/src/mfm/parse/core/syntax-highlighter.ts b/src/mfm/syntax-highlight.ts index 83aac89f1b..109923fb71 100644 --- a/src/mfm/parse/core/syntax-highlighter.ts +++ b/src/mfm/syntax-highlight.ts @@ -1,4 +1,4 @@ -import { capitalize, toUpperCase } from "../../../prelude/string"; +import { capitalize, toUpperCase } from '../prelude/string'; function escape(text: string) { return text @@ -307,8 +307,8 @@ const elements: Element[] = [ } ]; -// specify lang is todo -export default (source: string, lang?: string) => { +// TODO: specify lang +export default (source: string, lang?: string): string => { let code = source; let html = ''; diff --git a/src/misc/acct/parse.ts b/src/misc/acct/parse.ts index 0c00fccef6..b120746650 100644 --- a/src/misc/acct/parse.ts +++ b/src/misc/acct/parse.ts @@ -1,4 +1,5 @@ export default (acct: string) => { - const splitted = acct.split('@', 2); - return { username: splitted[0], host: splitted[1] || null }; + if (acct.startsWith('@')) acct = acct.substr(1); + const split = acct.split('@', 2); + return { username: split[0], host: split[1] || null }; }; diff --git a/src/misc/cafy-id.ts b/src/misc/cafy-id.ts index 621f7e7948..b99a27ee4a 100644 --- a/src/misc/cafy-id.ts +++ b/src/misc/cafy-id.ts @@ -5,7 +5,8 @@ import isObjectId from './is-objectid'; export const isAnId = (x: any) => mongo.ObjectID.isValid(x); export const isNotAnId = (x: any) => !isAnId(x); export const transform = (x: string | mongo.ObjectID): mongo.ObjectID => { - if (x == null) return null; + if (x === undefined) return undefined; + if (x === null) return null; if (isAnId(x) && !isObjectId(x)) { return new mongo.ObjectID(x); diff --git a/src/misc/environmentInfo.ts b/src/misc/environmentInfo.ts deleted file mode 100644 index cee42ef9c0..0000000000 --- a/src/misc/environmentInfo.ts +++ /dev/null @@ -1,17 +0,0 @@ -import Logger from './logger'; -import isRoot = require('is-root'); - -export default class { - public static show(): void { - const env = process.env.NODE_ENV; - const logger = new Logger('Env'); - logger.info(typeof env == 'undefined' ? 'NODE_ENV is not set' : `NODE_ENV: ${env}`); - - if (env !== 'production') { - logger.warn('The environment is not in production mode'); - logger.warn('Do not use for production purpose'); - } - - logger.info(`You ${isRoot() ? '' : 'do not '}have root privileges`); - } -} diff --git a/src/misc/extract-emojis.ts b/src/misc/extract-emojis.ts new file mode 100644 index 0000000000..a7b949f4f7 --- /dev/null +++ b/src/misc/extract-emojis.ts @@ -0,0 +1,9 @@ +import { EmojiNode, MfmForest } from '../mfm/parser'; +import { preorderF } from '../prelude/tree'; +import { unique } from '../prelude/array'; + +export default function(mfmForest: MfmForest): string[] { + const emojiNodes = preorderF(mfmForest).filter(x => x.type === 'emoji') as EmojiNode[]; + const emojis = emojiNodes.filter(x => x.props.name && x.props.name.length <= 100).map(x => x.props.name); + return unique(emojis); +} diff --git a/src/misc/extract-hashtags.ts b/src/misc/extract-hashtags.ts new file mode 100644 index 0000000000..43eaa45909 --- /dev/null +++ b/src/misc/extract-hashtags.ts @@ -0,0 +1,9 @@ +import { HashtagNode, MfmForest } from '../mfm/parser'; +import { preorderF } from '../prelude/tree'; +import { unique } from '../prelude/array'; + +export default function(mfmForest: MfmForest): string[] { + const hashtagNodes = preorderF(mfmForest).filter(x => x.type === 'hashtag') as HashtagNode[]; + const hashtags = hashtagNodes.map(x => x.props.hashtag); + return unique(hashtags); +} diff --git a/src/misc/extract-mentions.ts b/src/misc/extract-mentions.ts new file mode 100644 index 0000000000..a53a25ffc4 --- /dev/null +++ b/src/misc/extract-mentions.ts @@ -0,0 +1,10 @@ +// test is located in test/extract-mentions + +import { MentionNode, MfmForest } from '../mfm/parser'; +import { preorderF } from '../prelude/tree'; + +export default function(mfmForest: MfmForest): MentionNode['props'][] { + // TODO: 重複を削除 + const mentionNodes = preorderF(mfmForest).filter(x => x.type === 'mention') as MentionNode[]; + return mentionNodes.map(x => x.props); +} diff --git a/src/misc/fetch-meta.ts b/src/misc/fetch-meta.ts index e855097c42..e6488da395 100644 --- a/src/misc/fetch-meta.ts +++ b/src/misc/fetch-meta.ts @@ -15,7 +15,13 @@ const defaultMeta: any = { maxNoteTextLength: 1000, enableTwitterIntegration: false, enableGithubIntegration: false, - enableDiscordIntegration: false + enableDiscordIntegration: false, + enableExternalUserRecommendation: false, + externalUserRecommendationEngine: 'https://vinayaka.distsn.org/cgi-bin/vinayaka-user-match-misskey-api.cgi?{{host}}+{{user}}+{{limit}}+{{offset}}', + externalUserRecommendationTimeout: 300000, + mascotImageUrl: '/assets/ai.png', + errorImageUrl: 'https://ai.misskey.xyz/aiart/yubitun.png', + enableServiceWorker: false }; export default async function(): Promise<IMeta> { diff --git a/src/misc/get-drive-file-url.ts b/src/misc/get-drive-file-url.ts index 0fe467261e..6ab7bfdb1b 100644 --- a/src/misc/get-drive-file-url.ts +++ b/src/misc/get-drive-file-url.ts @@ -6,15 +6,24 @@ export default function(file: IDriveFile, thumbnail = false): string { if (file.metadata.withoutChunks) { if (thumbnail) { - return file.metadata.thumbnailUrl || file.metadata.url; + return file.metadata.thumbnailUrl || file.metadata.webpublicUrl || file.metadata.url; } else { - return file.metadata.url; + return file.metadata.webpublicUrl || file.metadata.url; } } else { if (thumbnail) { return `${config.drive_url}/${file._id}?thumbnail`; } else { - return `${config.drive_url}/${file._id}`; + return `${config.drive_url}/${file._id}?web`; } } } + +export function getOriginalUrl(file: IDriveFile) { + if (file.metadata && file.metadata.url) { + return file.metadata.url; + } + + const accessKey = file.metadata ? file.metadata.accessKey : null; + return `${config.drive_url}/${file._id}${accessKey ? '?original=' + accessKey : ''}`; +} diff --git a/src/misc/get-note-summary.ts b/src/misc/get-note-summary.ts index 3f96483032..e3458cb189 100644 --- a/src/misc/get-note-summary.ts +++ b/src/misc/get-note-summary.ts @@ -14,7 +14,11 @@ const summarize = (note: any): string => { let summary = ''; // 本文 - summary += note.text ? note.text : ''; + if (note.cw != null) { + summary += note.cw; + } else { + summary += note.text ? note.text : ''; + } // ファイルが添付されているとき if ((note.files || []).length != 0) { @@ -29,18 +33,18 @@ const summarize = (note: any): string => { // 返信のとき if (note.replyId) { if (note.reply) { - summary += ` RE: ${summarize(note.reply)}`; + summary += `\n\nRE: ${summarize(note.reply)}`; } else { - summary += ' RE: ...'; + summary += '\n\nRE: ...'; } } // Renoteのとき if (note.renoteId) { if (note.renote) { - summary += ` RN: ${summarize(note.renote)}`; + summary += `\n\nRN: ${summarize(note.renote)}`; } else { - summary += ' RN: ...'; + summary += '\n\nRN: ...'; } } diff --git a/src/misc/machineInfo.ts b/src/misc/machineInfo.ts deleted file mode 100644 index 7d8a52ff9a..0000000000 --- a/src/misc/machineInfo.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as os from 'os'; -import Logger from './logger'; -import * as sysUtils from 'systeminformation'; - -export default class { - public static async show() { - const logger = new Logger('Machine'); - logger.info(`Hostname: ${os.hostname()}`); - logger.info(`Platform: ${process.platform}`); - logger.info(`Architecture: ${process.arch}`); - logger.info(`CPU: ${os.cpus().length} core`); - const mem = await sysUtils.mem(); - const totalmem = (mem.total / 1024 / 1024 / 1024).toFixed(1); - const availmem = (mem.available / 1024 / 1024 / 1024).toFixed(1); - logger.info(`MEM: ${totalmem}GB (available: ${availmem}GB)`); - } -} diff --git a/src/models/abuse-user-report.ts b/src/models/abuse-user-report.ts new file mode 100644 index 0000000000..1fe33f0342 --- /dev/null +++ b/src/models/abuse-user-report.ts @@ -0,0 +1,52 @@ +import * as mongo from 'mongodb'; +const deepcopy = require('deepcopy'); +import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; +import { pack as packUser } from './user'; + +const AbuseUserReport = db.get<IAbuseUserReport>('abuseUserReports'); +AbuseUserReport.createIndex('userId'); +AbuseUserReport.createIndex('reporterId'); +AbuseUserReport.createIndex(['userId', 'reporterId'], { unique: true }); +export default AbuseUserReport; + +export interface IAbuseUserReport { + _id: mongo.ObjectID; + createdAt: Date; + userId: mongo.ObjectID; + reporterId: mongo.ObjectID; + comment: string; +} + +export const packMany = ( + reports: (string | mongo.ObjectID | IAbuseUserReport)[] +) => { + return Promise.all(reports.map(x => pack(x))); +}; + +export const pack = ( + report: any +) => new Promise<any>(async (resolve, reject) => { + let _report: any; + + if (isObjectId(report)) { + _report = await AbuseUserReport.findOne({ + _id: report + }); + } else if (typeof report === 'string') { + _report = await AbuseUserReport.findOne({ + _id: new mongo.ObjectID(report) + }); + } else { + _report = deepcopy(report); + } + + // Rename _id to id + _report.id = _report._id; + delete _report._id; + + _report.reporter = await packUser(_report.reporterId, null, { detail: true }); + _report.user = await packUser(_report.userId, null, { detail: true }); + + resolve(_report); +}); diff --git a/src/models/drive-file-webpublic.ts b/src/models/drive-file-webpublic.ts new file mode 100644 index 0000000000..d087c355d3 --- /dev/null +++ b/src/models/drive-file-webpublic.ts @@ -0,0 +1,29 @@ +import * as mongo from 'mongodb'; +import monkDb, { nativeDbConn } from '../db/mongodb'; + +const DriveFileWebpublic = monkDb.get<IDriveFileWebpublic>('driveFileWebpublics.files'); +DriveFileWebpublic.createIndex('metadata.originalId', { sparse: true, unique: true }); +export default DriveFileWebpublic; + +export const DriveFileWebpublicChunk = monkDb.get('driveFileWebpublics.chunks'); + +export const getDriveFileWebpublicBucket = async (): Promise<mongo.GridFSBucket> => { + const db = await nativeDbConn(); + const bucket = new mongo.GridFSBucket(db, { + bucketName: 'driveFileWebpublics' + }); + return bucket; +}; + +export type IMetadata = { + originalId: mongo.ObjectID; +}; + +export type IDriveFileWebpublic = { + _id: mongo.ObjectID; + uploadDate: Date; + md5: string; + filename: string; + contentType: string; + metadata: IMetadata; +}; diff --git a/src/models/drive-file.ts b/src/models/drive-file.ts index d0c0905fc2..24c7ac75c0 100644 --- a/src/models/drive-file.ts +++ b/src/models/drive-file.ts @@ -1,9 +1,10 @@ import * as mongo from 'mongodb'; const deepcopy = require('deepcopy'); import { pack as packFolder } from './drive-folder'; +import { pack as packUser } from './user'; import monkDb, { nativeDbConn } from '../db/mongodb'; import isObjectId from '../misc/is-objectid'; -import getDriveFileUrl from '../misc/get-drive-file-url'; +import getDriveFileUrl, { getOriginalUrl } from '../misc/get-drive-file-url'; const DriveFile = monkDb.get<IDriveFile>('driveFiles.files'); DriveFile.createIndex('md5'); @@ -28,21 +29,48 @@ export type IMetadata = { _user: any; folderId: mongo.ObjectID; comment: string; + + /** + * リモートインスタンスから取得した場合の元URL + */ uri?: string; + + /** + * URL for web(生成されている場合) or original + * * オブジェクトストレージを利用している or リモートサーバーへの直リンクである 場合のみ + */ url?: string; + + /** + * URL for thumbnail (thumbnailがなければなし) + * * オブジェクトストレージを利用している or リモートサーバーへの直リンクである 場合のみ + */ thumbnailUrl?: string; + + /** + * URL for original (web用が生成されてない場合はurlがoriginalを指す) + * * オブジェクトストレージを利用している or リモートサーバーへの直リンクである 場合のみ + */ + webpublicUrl?: string; + + accessKey?: string; + src?: string; deletedAt?: Date; /** - * このファイルの中身データがMongoDB内に保存されているのか否か + * このファイルの中身データがMongoDB内に保存されていないか否か * オブジェクトストレージを利用している or リモートサーバーへの直リンクである - * な場合は false になります + * な場合は true になります */ withoutChunks?: boolean; storage?: string; - storageProps?: any; + + /*** + * ObjectStorage の格納先の情報 + */ + storageProps?: IStorageProps; isSensitive?: boolean; /** @@ -56,6 +84,25 @@ export type IMetadata = { isRemote?: boolean; }; +export type IStorageProps = { + /** + * ObjectStorage key for original + */ + key: string; + + /*** + * ObjectStorage key for thumbnail (thumbnailがなければなし) + */ + thumbnailKey?: string; + + /*** + * ObjectStorage key for webpublic (webpublicがなければなし) + */ + webpublicKey?: string; + + id?: string; +}; + export type IDriveFile = { _id: mongo.ObjectID; uploadDate: Date; @@ -83,7 +130,9 @@ export function validateFileName(name: string): boolean { export const packMany = ( files: any[], options?: { - detail: boolean + detail?: boolean + self?: boolean, + withUser?: boolean, } ) => { return Promise.all(files.map(f => pack(f, options))); @@ -95,11 +144,14 @@ export const packMany = ( export const pack = ( file: any, options?: { - detail: boolean + detail?: boolean, + self?: boolean, + withUser?: boolean, } ) => new Promise<any>(async (resolve, reject) => { const opts = Object.assign({ - detail: false + detail: false, + self: false }, options); let _file: any; @@ -159,11 +211,20 @@ export const pack = ( */ } + if (opts.withUser) { + // Populate user + _target.user = await packUser(_file.metadata.userId); + } + delete _target.withoutChunks; delete _target.storage; delete _target.storageProps; delete _target.isRemote; delete _target._user; + if (opts.self) { + _target.url = getOriginalUrl(_file); + } + resolve(_target); }); diff --git a/src/models/emoji.ts b/src/models/emoji.ts index 8e75868e62..373d5f8602 100644 --- a/src/models/emoji.ts +++ b/src/models/emoji.ts @@ -15,4 +15,6 @@ export type IEmoji = { url: string; aliases?: string[]; updatedAt?: Date; + /** AP object id */ + uri?: string; }; diff --git a/src/models/following.ts b/src/models/following.ts index 58d55bbeef..12cc27211b 100644 --- a/src/models/following.ts +++ b/src/models/following.ts @@ -12,7 +12,6 @@ export type IFollowing = { createdAt: Date; followeeId: mongo.ObjectID; followerId: mongo.ObjectID; - stalk: boolean; // 非正規化 _followee: { diff --git a/src/models/messaging-history.ts b/src/models/messaging-history.ts deleted file mode 100644 index 6864e22d2f..0000000000 --- a/src/models/messaging-history.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as mongo from 'mongodb'; -import db from '../db/mongodb'; - -const MessagingHistory = db.get<IMessagingHistory>('messagingHistories'); -export default MessagingHistory; - -export type IMessagingHistory = { - _id: mongo.ObjectID; - updatedAt: Date; - userId: mongo.ObjectID; - partnerId: mongo.ObjectID; - messageId: mongo.ObjectID; -}; diff --git a/src/models/messaging-message.ts b/src/models/messaging-message.ts index 4c52ae78ca..2c7cf37cd8 100644 --- a/src/models/messaging-message.ts +++ b/src/models/messaging-message.ts @@ -7,6 +7,8 @@ import isObjectId from '../misc/is-objectid'; import { length } from 'stringz'; const MessagingMessage = db.get<IMessagingMessage>('messagingMessages'); +MessagingMessage.createIndex('userId'); +MessagingMessage.createIndex('recipientId'); export default MessagingMessage; export interface IMessagingMessage { diff --git a/src/models/meta.ts b/src/models/meta.ts index 34117afd25..4d4b00be6e 100644 --- a/src/models/meta.ts +++ b/src/models/meta.ts @@ -125,6 +125,32 @@ if ((config as any).github) { } }); } +if ((config as any).user_recommendation) { + Meta.findOne({}).then(m => { + if (m != null && m.enableExternalUserRecommendation == null) { + Meta.update({}, { + $set: { + enableExternalUserRecommendation: true, + externalUserRecommendationEngine: (config as any).user_recommendation.engine, + externalUserRecommendationTimeout: (config as any).user_recommendation.timeout + } + }); + } + }); +} +if ((config as any).sw) { + Meta.findOne({}).then(m => { + if (m != null && m.enableServiceWorker == null) { + Meta.update({}, { + $set: { + enableServiceWorker: true, + swPublicKey: (config as any).sw.public_key, + swPrivateKey: (config as any).sw.private_key + } + }); + } + }); +} export type IMeta = { name?: string; @@ -158,8 +184,11 @@ export type IMeta = { disableRegistration?: boolean; disableLocalTimeline?: boolean; + disableGlobalTimeline?: boolean; hidedTags?: string[]; + mascotImageUrl?: string; bannerUrl?: string; + errorImageUrl?: string; cacheRemoteFiles?: boolean; @@ -184,6 +213,8 @@ export type IMeta = { */ maxNoteTextLength?: number; + summalyProxy?: string; + enableTwitterIntegration?: boolean; twitterConsumerKey?: string; twitterConsumerSecret?: string; @@ -195,4 +226,20 @@ export type IMeta = { enableDiscordIntegration?: boolean; discordClientId?: string; discordClientSecret?: string; + + enableExternalUserRecommendation?: boolean; + externalUserRecommendationEngine?: string; + externalUserRecommendationTimeout?: number; + + enableEmail?: boolean; + email?: string; + smtpSecure?: boolean; + smtpHost?: string; + smtpPort?: number; + smtpUser?: string; + smtpPass?: string; + + enableServiceWorker?: boolean; + swPublicKey?: string; + swPrivateKey?: string; }; diff --git a/src/models/note.ts b/src/models/note.ts index 717960bb23..8ca65bb423 100644 --- a/src/models/note.ts +++ b/src/models/note.ts @@ -18,6 +18,7 @@ Note.createIndex('uri', { sparse: true, unique: true }); Note.createIndex('userId'); Note.createIndex('mentions'); Note.createIndex('visibleUserIds'); +Note.createIndex('replyId'); Note.createIndex('tagsLower'); Note.createIndex('_user.host'); Note.createIndex('_files._id'); @@ -37,11 +38,7 @@ export type INote = { fileIds: mongo.ObjectID[]; replyId: mongo.ObjectID; renoteId: mongo.ObjectID; - poll: { - choices: Array<{ - id: number; - }> - }; + poll: IPoll; text: string; tags: string[]; tagsLower: string[]; @@ -66,9 +63,8 @@ export type INote = { * home ... ホームタイムライン(ユーザーページのタイムライン含む)のみに流す * followers ... フォロワーのみ * specified ... visibleUserIds で指定したユーザーのみ - * private ... 自分のみ */ - visibility: 'public' | 'home' | 'followers' | 'specified' | 'private'; + visibility: 'public' | 'home' | 'followers' | 'specified'; visibleUserIds: mongo.ObjectID[]; @@ -99,14 +95,23 @@ export type INote = { host: string; inbox?: string; }; - _replyIds?: mongo.ObjectID[]; _files?: IDriveFile[]; }; +export type IPoll = { + choices: IChoice[] +}; + +export type IChoice = { + id: number; + text: string; + votes: number; +}; + export const hideNote = async (packedNote: any, meId: mongo.ObjectID) => { let hide = false; - // visibility が private かつ投稿者のIDが自分のIDではなかったら非表示 + // visibility が private かつ投稿者のIDが自分のIDではなかったら非表示(後方互換性のため) if (packedNote.visibility == 'private' && (meId == null || !meId.equals(packedNote.userId))) { hide = true; } @@ -258,6 +263,8 @@ export const pack = async ( delete _note._reply; delete _note._renote; delete _note._files; + delete _note._replyIds; + if (_note.geo) delete _note.geo.type; // Populate user @@ -271,6 +278,11 @@ export const pack = async ( // Populate files _note.files = packFileMany(_note.fileIds || []); + // Some counts + _note.renoteCount = _note.renoteCount || 0; + _note.repliesCount = _note.repliesCount || 0; + _note.reactionCounts = _note.reactionCounts || {}; + // 後方互換性のため _note.mediaIds = _note.fileIds; _note.media = _note.files; @@ -366,7 +378,14 @@ export const pack = async ( //#endregion if (_note.user.isCat && _note.text) { - _note.text = _note.text.replace(/な/g, 'にゃ').replace(/ナ/g, 'ニャ').replace(/ナ/g, 'ニャ'); + _note.text = (_note.text + // ja-JP + .replace(/な/g, 'にゃ').replace(/ナ/g, 'ニャ').replace(/ナ/g, 'ニャ') + // ko-KR + .replace(/[나-낳]/g, (match: string) => String.fromCharCode( + match.codePointAt(0) + '냐'.charCodeAt(0) - '나'.charCodeAt(0) + )) + ); } if (!opts.skipHide) { diff --git a/src/models/notification.ts b/src/models/notification.ts index 394e49f36a..5cf1e140c8 100644 --- a/src/models/notification.ts +++ b/src/models/notification.ts @@ -48,7 +48,7 @@ export interface INotification { /** * 通知が読まれたかどうか */ - isRead: Boolean; + isRead: boolean; } export const packMany = ( diff --git a/src/models/poll-vote.ts b/src/models/poll-vote.ts index 4390e000c3..b8aceae3b0 100644 --- a/src/models/poll-vote.ts +++ b/src/models/poll-vote.ts @@ -4,6 +4,7 @@ import db from '../db/mongodb'; const PollVote = db.get<IPollVote>('pollVotes'); PollVote.createIndex('userId'); PollVote.createIndex('noteId'); +PollVote.createIndex(['userId', 'noteId'], { unique: true }); export default PollVote; export interface IPollVote { diff --git a/src/models/user.ts b/src/models/user.ts index 22eecb571b..6987bd3da8 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -11,6 +11,7 @@ import { getFriendIds } from '../server/api/common/get-friends'; import config from '../config'; import FollowRequest from './follow-request'; import fetchMeta from '../misc/fetch-meta'; +import Emoji from './emoji'; const User = db.get<IUser>('users'); @@ -26,6 +27,7 @@ export default User; type IUserBase = { _id: mongo.ObjectID; createdAt: Date; + updatedAt?: Date; deletedAt?: Date; followersCount: number; followingCount: number; @@ -37,11 +39,15 @@ type IUserBase = { bannerId: mongo.ObjectID; avatarUrl?: string; bannerUrl?: string; + avatarColor?: any; + bannerColor?: any; wallpaperId: mongo.ObjectID; wallpaperUrl?: string; data: any; description: string; + lang?: string; pinnedNoteIds: mongo.ObjectID[]; + emojis?: string[]; /** * 凍結されているか否か @@ -64,6 +70,11 @@ type IUserBase = { carefulBot: boolean; /** + * フォローしているユーザーからのフォローリクエストを自動承認するか + */ + autoAcceptFollowed: boolean; + + /** * このアカウントに届いているフォローリクエストの数 */ pendingReceivedFollowRequestsCount: number; @@ -75,6 +86,8 @@ export interface ILocalUser extends IUserBase { host: null; keypair: string; email: string; + emailVerified?: boolean; + emailVerifyCode?: string; password: string; token: string; twitter: { @@ -96,15 +109,15 @@ export interface ILocalUser extends IUserBase { username: string; discriminator: string; }; - line: { - userId: string; - }; profile: { location: string; birthday: string; // 'YYYY-MM-DD' tags: string[]; }; - lastUsedAt: Date; + fields?: { + name: string; + value: string; + }[]; isCat: boolean; isAdmin?: boolean; isModerator?: boolean; @@ -132,7 +145,7 @@ export interface IRemoteUser extends IUserBase { id: string; publicKeyPem: string; }; - updatedAt: Date; + lastFetchedAt: Date; isAdmin: false; isModerator: false; } @@ -146,8 +159,8 @@ export const isRemoteUser = (user: any): user is IRemoteUser => !isLocalUser(user); //#region Validators -export function validateUsername(username: string): boolean { - return typeof username == 'string' && /^[a-zA-Z0-9_]{1,20}$/.test(username); +export function validateUsername(username: string, remote?: boolean): boolean { + return typeof username == 'string' && (remote ? /^\w([\w-]*\w)?$/ : /^\w{1,20}$/).test(username); } export function validatePassword(password: string): boolean { @@ -204,8 +217,8 @@ export async function getRelation(me: mongo.ObjectId, target: mongo.ObjectId) { ]); return { + id: target, isFollowing: following1 !== null, - isStalking: following1 ? following1.stalk : false, hasPendingFollowRequestFromYou: followReq1 !== null, hasPendingFollowRequestToYou: followReq2 !== null, isFollowed: following2 !== null, @@ -245,6 +258,7 @@ export const pack = ( host: true, avatarColor: true, avatarUrl: true, + emojis: true, isCat: true, isBot: true, isAdmin: true, @@ -284,6 +298,7 @@ export const pack = ( delete _user._id; delete _user.usernameLower; + delete _user.emailVerifyCode; if (_user.host == null) { // Remove private properties @@ -304,11 +319,11 @@ export const pack = ( delete _user.discord.refreshToken; delete _user.discord.expiresDate; } - delete _user.line; // Visible via only the official client if (!opts.includeSecrets) { delete _user.email; + delete _user.emailVerified; delete _user.settings; delete _user.clientSettings; } @@ -336,7 +351,6 @@ export const pack = ( _user.isFollowing = relation.isFollowing; _user.isFollowed = relation.isFollowed; - _user.isStalking = relation.isStalking; _user.hasPendingFollowRequestFromYou = relation.hasPendingFollowRequestFromYou; _user.hasPendingFollowRequestToYou = relation.hasPendingFollowRequestToYou; _user.isBlocking = relation.isBlocking; @@ -374,6 +388,16 @@ export const pack = ( delete _user.hasUnreadMentions; } + // カスタム絵文字添付 + if (_user.emojis) { + _user.emojis = Emoji.find({ + name: { $in: _user.emojis }, + host: _user.host + }, { + fields: { _id: false } + }); + } + // resolve promises in _user object _user = await rap(_user); diff --git a/src/prelude/array.ts b/src/prelude/array.ts index 09457d2d0a..560dfa080d 100644 --- a/src/prelude/array.ts +++ b/src/prelude/array.ts @@ -1,31 +1,53 @@ -export function countIf<T>(f: (x: T) => boolean, xs: T[]): number { +import { EndoRelation, Predicate } from './relation'; + +/** + * Count the number of elements that satisfy the predicate + */ + +export function countIf<T>(f: Predicate<T>, xs: T[]): number { return xs.filter(f).length; } -export function count<T>(x: T, xs: T[]): number { - return countIf(y => x === y, xs); +/** + * Count the number of elements that is equal to the element + */ +export function count<T>(a: T, xs: T[]): number { + return countIf(x => x === a, xs); } +/** + * Concatenate an array of arrays + */ export function concat<T>(xss: T[][]): T[] { return ([] as T[]).concat(...xss); } +/** + * Intersperse the element between the elements of the array + * @param sep The element to be interspersed + */ export function intersperse<T>(sep: T, xs: T[]): T[] { return concat(xs.map(x => [sep, x])).slice(1); } -export function erase<T>(x: T, xs: T[]): T[] { - return xs.filter(y => x !== y); +/** + * Returns the array of elements that is not equal to the element + */ +export function erase<T>(a: T, xs: T[]): T[] { + return xs.filter(x => x !== a); } /** * Finds the array of all elements in the first array not contained in the second array. * The order of result values are determined by the first array. */ -export function difference<T>(includes: T[], excludes: T[]): T[] { - return includes.filter(x => !excludes.includes(x)); +export function difference<T>(xs: T[], ys: T[]): T[] { + return xs.filter(x => !ys.includes(x)); } +/** + * Remove all but the first element from every group of equivalent elements + */ export function unique<T>(xs: T[]): T[] { return [...new Set(xs)]; } @@ -34,7 +56,15 @@ export function sum(xs: number[]): number { return xs.reduce((a, b) => a + b, 0); } -export function groupBy<T>(f: (x: T, y: T) => boolean, xs: T[]): T[][] { +export function maximum(xs: number[]): number { + return Math.max(...xs); +} + +/** + * Splits an array based on the equivalence relation. + * The concatenation of the result is equal to the argument. + */ +export function groupBy<T>(f: EndoRelation<T>, xs: T[]): T[][] { const groups = [] as T[][]; for (const x of xs) { if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) { @@ -46,10 +76,17 @@ export function groupBy<T>(f: (x: T, y: T) => boolean, xs: T[]): T[][] { return groups; } +/** + * Splits an array based on the equivalence relation induced by the function. + * The concatenation of the result is equal to the argument. + */ export function groupOn<T, S>(f: (x: T) => S, xs: T[]): T[][] { return groupBy((a, b) => f(a) === f(b), xs); } +/** + * Compare two arrays by lexicographical order + */ export function lessThan(xs: number[], ys: number[]): boolean { for (let i = 0; i < Math.min(xs.length, ys.length); i++) { if (xs[i] < ys[i]) return true; @@ -57,3 +94,24 @@ export function lessThan(xs: number[], ys: number[]): boolean { } return xs.length < ys.length; } + +/** + * Returns the longest prefix of elements that satisfy the predicate + */ +export function takeWhile<T>(f: Predicate<T>, xs: T[]): T[] { + const ys = []; + for (const x of xs) { + if (f(x)) { + ys.push(x); + } else { + break; + } + } + return ys; +} + +export function cumulativeSum(xs: number[]): number[] { + const ys = Array.from(xs); // deep copy + for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1]; + return ys; +} diff --git a/src/prelude/relation.ts b/src/prelude/relation.ts new file mode 100644 index 0000000000..1f4703f52f --- /dev/null +++ b/src/prelude/relation.ts @@ -0,0 +1,5 @@ +export type Predicate<T> = (a: T) => boolean; + +export type Relation<T, U> = (a: T, b: U) => boolean; + +export type EndoRelation<T> = Relation<T, T>; diff --git a/src/prelude/string.ts b/src/prelude/string.ts index 6149235e47..b907e0a2e1 100644 --- a/src/prelude/string.ts +++ b/src/prelude/string.ts @@ -1,5 +1,5 @@ export function concat(xs: string[]): string { - return xs.reduce((a, b) => a + b, ""); + return xs.join(''); } export function capitalize(s: string): string { diff --git a/src/prelude/tree.ts b/src/prelude/tree.ts new file mode 100644 index 0000000000..519234a0b0 --- /dev/null +++ b/src/prelude/tree.ts @@ -0,0 +1,36 @@ +import { concat, sum } from './array'; + +export type Tree<T> = { + node: T, + children: Forest<T>; +}; + +export type Forest<T> = Tree<T>[]; + +export function createLeaf<T>(node: T): Tree<T> { + return { node, children: [] }; +} + +export function createTree<T>(node: T, children: Forest<T>): Tree<T> { + return { node, children }; +} + +export function hasChildren<T>(t: Tree<T>): boolean { + return t.children.length !== 0; +} + +export function preorder<T>(t: Tree<T>): T[] { + return [t.node, ...preorderF(t.children)]; +} + +export function preorderF<T>(ts: Forest<T>): T[] { + return concat(ts.map(preorder)); +} + +export function countNodes<T>(t: Tree<T>): number { + return preorder(t).length; +} + +export function countNodesF<T>(ts: Forest<T>): number { + return sum(ts.map(countNodes)); +} diff --git a/src/push-sw.ts b/src/push-sw.ts index d30965f800..58e695813d 100644 --- a/src/push-sw.ts +++ b/src/push-sw.ts @@ -2,17 +2,26 @@ const push = require('web-push'); import * as mongo from 'mongodb'; import Subscription from './models/sw-subscription'; import config from './config'; +import fetchMeta from './misc/fetch-meta'; +import { IMeta } from './models/meta'; -if (config.sw) { - // アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録 - push.setVapidDetails( - config.url, - config.sw.public_key, - config.sw.private_key); -} +let meta: IMeta = null; + +setInterval(() => { + fetchMeta().then(m => { + meta = m; + + if (meta.enableServiceWorker) { + // アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録 + push.setVapidDetails(config.url, + meta.swPublicKey, + meta.swPrivateKey); + } + }); +}, 3000); export default async function(userId: mongo.ObjectID | string, type: string, body?: any) { - if (!config.sw) return; + if (!meta.enableServiceWorker) return; if (typeof userId === 'string') { userId = new mongo.ObjectID(userId); @@ -23,7 +32,7 @@ export default async function(userId: mongo.ObjectID | string, type: string, bod userId: userId }); - subscriptions.forEach(subscription => { + for (const subscription of subscriptions) { const pushSubscription = { endpoint: subscription.endpoint, keys: { @@ -48,5 +57,5 @@ export default async function(userId: mongo.ObjectID | string, type: string, bod }); } }); - }); + } } diff --git a/src/remote/activitypub/kernel/announce/note.ts b/src/remote/activitypub/kernel/announce/note.ts index 19ea6306e3..5eaeaf7918 100644 --- a/src/remote/activitypub/kernel/announce/note.ts +++ b/src/remote/activitypub/kernel/announce/note.ts @@ -52,7 +52,7 @@ export default async function(resolver: Resolver, actor: IRemoteUser, activity: }); } -type visibility = 'public' | 'home' | 'followers' | 'specified' | 'private'; +type visibility = 'public' | 'home' | 'followers' | 'specified'; function getVisibility(to: string[], cc: string[], actor: IRemoteUser): visibility { const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'; diff --git a/src/remote/activitypub/kernel/like.ts b/src/remote/activitypub/kernel/like.ts index 17ec73f12b..d36f63c79e 100644 --- a/src/remote/activitypub/kernel/like.ts +++ b/src/remote/activitypub/kernel/like.ts @@ -18,13 +18,11 @@ export default async (actor: IRemoteUser, activity: ILike) => { throw new Error(); } - let reaction = 'pudding'; + let reaction = 'like'; // 他のMisskeyインスタンスからのリアクション - if (activity._misskey_reaction) { - if (validateReaction.ok(activity._misskey_reaction)) { - reaction = activity._misskey_reaction; - } + if (activity._misskey_reaction && validateReaction.ok(activity._misskey_reaction)) { + reaction = activity._misskey_reaction; } await create(actor, note, reaction); diff --git a/src/remote/activitypub/kernel/undo/follow.ts b/src/remote/activitypub/kernel/undo/follow.ts index f112ee8bfb..af06aa5b31 100644 --- a/src/remote/activitypub/kernel/undo/follow.ts +++ b/src/remote/activitypub/kernel/undo/follow.ts @@ -5,6 +5,7 @@ import unfollow from '../../../../services/following/delete'; import cancelRequest from '../../../../services/following/requests/cancel'; import { IFollow } from '../../type'; import FollowRequest from '../../../../models/follow-request'; +import Following from '../../../../models/following'; export default async (actor: IRemoteUser, activity: IFollow): Promise<void> => { const id = typeof activity.object == 'string' ? activity.object : activity.object.id; @@ -30,9 +31,16 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise<void> => { followeeId: followee._id }); + const following = await Following.findOne({ + followerId: actor._id, + followeeId: followee._id + }); + if (req) { await cancelRequest(followee, actor); - } else { + } + + if (following) { await unfollow(actor, followee); } }; diff --git a/src/remote/activitypub/kernel/undo/index.ts b/src/remote/activitypub/kernel/undo/index.ts index ba56dd6328..471988f052 100644 --- a/src/remote/activitypub/kernel/undo/index.ts +++ b/src/remote/activitypub/kernel/undo/index.ts @@ -1,9 +1,10 @@ import * as debug from 'debug'; import { IRemoteUser } from '../../../../models/user'; -import { IUndo, IFollow, IBlock } from '../../type'; +import { IUndo, IFollow, IBlock, ILike } from '../../type'; import unfollow from './follow'; import unblock from './block'; +import undoLike from './like'; import Resolver from '../../resolver'; const log = debug('misskey:activitypub'); @@ -35,6 +36,9 @@ export default async (actor: IRemoteUser, activity: IUndo): Promise<void> => { case 'Block': unblock(actor, object as IBlock); break; + case 'Like': + undoLike(actor, object as ILike); + break; } return null; diff --git a/src/remote/activitypub/kernel/undo/like.ts b/src/remote/activitypub/kernel/undo/like.ts new file mode 100644 index 0000000000..b324ec854c --- /dev/null +++ b/src/remote/activitypub/kernel/undo/like.ts @@ -0,0 +1,21 @@ +import * as mongo from 'mongodb'; +import { IRemoteUser } from '../../../../models/user'; +import { ILike } from '../../type'; +import Note from '../../../../models/note'; +import deleteReaction from '../../../../services/note/reaction/delete'; + +/** + * Process Undo.Like activity + */ +export default async (actor: IRemoteUser, activity: ILike): Promise<void> => { + const id = typeof activity.object == 'string' ? activity.object : activity.object.id; + + const noteId = new mongo.ObjectID(id.split('/').pop()); + + const note = await Note.findOne({ _id: noteId }); + if (note === null) { + throw 'note not found'; + } + + await deleteReaction(actor, note); +}; diff --git a/src/remote/activitypub/models/note.ts b/src/remote/activitypub/models/note.ts index 48a02e79bd..dd0083340c 100644 --- a/src/remote/activitypub/models/note.ts +++ b/src/remote/activitypub/models/note.ts @@ -10,10 +10,12 @@ import { resolvePerson, updatePerson } from './person'; import { resolveImage } from './image'; import { IRemoteUser, IUser } from '../../../models/user'; import htmlToMFM from '../../../mfm/html-to-mfm'; -import Emoji from '../../../models/emoji'; +import Emoji, { IEmoji } from '../../../models/emoji'; import { ITag } from './tag'; import { toUnicode } from 'punycode'; import { unique, concat, difference } from '../../../prelude/array'; +import { extractPollFromQuestion } from './question'; +import vote from '../../../services/note/polls/vote'; const log = debug('misskey:activitypub'); @@ -84,6 +86,8 @@ export async function createNote(value: any, resolver?: Resolver, silent = false const apMentions = await extractMentionedUsers(actor, note.to, note.cc, resolver); + const apHashtags = await extractHashtags(note.tag); + // 添付ファイル // TODO: attachmentは必ずしもImageではない // TODO: attachmentは必ずしも配列ではない @@ -96,15 +100,40 @@ export async function createNote(value: any, resolver?: Resolver, silent = false // リプライ const reply = note.inReplyTo ? await resolveNote(note.inReplyTo, resolver) : null; + // 引用 + let quote: INote; + + if (note._misskey_quote && typeof note._misskey_quote == 'string') { + quote = await resolveNote(note._misskey_quote).catch(() => null); + } + + const cw = note.summary === '' ? null : note.summary; + // テキストのパース const text = note._misskey_content ? note._misskey_content : htmlToMFM(note.content); - await extractEmojis(note.tag, actor.host).catch(e => { + // vote + if (reply && reply.poll && text != null) { + const m = text.match(/([0-9])$/); + if (m) { + log(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${m[0]}`); + await vote(actor, reply, Number(m[1])); + return null; + } + } + + const emojis = await extractEmojis(note.tag, actor.host).catch(e => { console.log(`extractEmojis: ${e}`); + return [] as IEmoji[]; }); + const apEmojis = emojis.map(emoji => emoji.name); + + const questionUri = note._misskey_question; + const poll = questionUri ? await extractPollFromQuestion(questionUri).catch(() => undefined) : undefined; + // ユーザーの情報が古かったらついでに更新しておく - if (actor.updatedAt == null || Date.now() - actor.updatedAt.getTime() > 1000 * 60 * 60 * 24) { + if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { updatePerson(note.attributedTo); } @@ -112,8 +141,8 @@ export async function createNote(value: any, resolver?: Resolver, silent = false createdAt: new Date(note.published), files: files, reply, - renote: undefined, - cw: note.summary, + renote: quote, + cw: cw, text: text, viaMobile: false, localOnly: false, @@ -121,6 +150,10 @@ export async function createNote(value: any, resolver?: Resolver, silent = false visibility, visibleUsers, apMentions, + apHashtags, + apEmojis, + questionUri, + poll, uri: note.id }, silent); } @@ -148,7 +181,7 @@ export async function resolveNote(value: string | IObject, resolver?: Resolver): return await createNote(uri, resolver); } -async function extractEmojis(tags: ITag[], host_: string) { +export async function extractEmojis(tags: ITag[], host_: string) { const host = toUnicode(host_.toLowerCase()); if (!tags) return []; @@ -165,6 +198,20 @@ async function extractEmojis(tags: ITag[], host_: string) { }); if (exists) { + if ((tag.updated != null && exists.updatedAt == null) + || (tag.id != null && exists.uri == null) + || (tag.updated != null && exists.updatedAt != null && new Date(tag.updated) > exists.updatedAt)) { + return await Emoji.findOneAndUpdate({ + host, + name, + }, { + $set: { + uri: tag.id, + url: tag.icon.url, + updatedAt: new Date(tag.updated), + } + }); + } return exists; } @@ -173,8 +220,10 @@ async function extractEmojis(tags: ITag[], host_: string) { return await Emoji.insert({ host, name, + uri: tag.id, url: tag.icon.url, - aliases: [], + updatedAt: tag.updated ? new Date(tag.updated) : undefined, + aliases: [] }); }) ); @@ -190,3 +239,14 @@ async function extractMentionedUsers(actor: IRemoteUser, to: string[], cc: strin return users.filter(x => x != null); } + +function extractHashtags(tags: ITag[]) { + if (!tags) return []; + + const hashtags = tags.filter(tag => tag.type === 'Hashtag' && typeof tag.name == 'string'); + + return hashtags.map(tag => { + const m = tag.name.match(/^#(.+)/); + return m ? m[1] : null; + }).filter(x => x != null); +} diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts index d78bc15c95..cbde5dc698 100644 --- a/src/remote/activitypub/models/person.ts +++ b/src/remote/activitypub/models/person.ts @@ -12,10 +12,13 @@ import Meta from '../../../models/meta'; import htmlToMFM from '../../../mfm/html-to-mfm'; import usersChart from '../../../chart/users'; import { URL } from 'url'; -import { resolveNote } from './note'; +import { resolveNote, extractEmojis } from './note'; import registerInstance from '../../../services/register-instance'; import Instance from '../../../models/instance'; import getDriveFileUrl from '../../../misc/get-drive-file-url'; +import { IEmoji } from '../../../models/emoji'; +import { ITag } from './tag'; +import Following from '../../../models/following'; const log = debug('misskey:activitypub'); @@ -43,7 +46,7 @@ function validatePerson(x: any, uri: string) { return new Error('invalid person: inbox is not a string'); } - if (!validateUsername(x.preferredUsername)) { + if (!validateUsername(x.preferredUsername, true)) { return new Error('invalid person: invalid username'); } @@ -134,6 +137,10 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU const host = toUnicode(new URL(object.id).hostname.toLowerCase()); + const fields = await extractFields(person.attachment).catch(e => { + console.log(`cat not extract fields: ${e}`); + }); + const isBot = object.type == 'Service'; // Create user @@ -143,7 +150,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU avatarId: null, bannerId: null, createdAt: Date.parse(person.published) || null, - updatedAt: new Date(), + lastFetchedAt: new Date(), description: htmlToMFM(person.summary), followersCount, followingCount, @@ -158,13 +165,14 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU publicKeyPem: person.publicKey.publicKeyPem }, inbox: person.inbox, - sharedInbox: person.sharedInbox, + sharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined), featured: person.featured, endpoints: person.endpoints, uri: person.id, url: person.url, + fields, isBot: isBot, - isCat: (person as any).isCat === true ? true : false + isCat: (person as any).isCat === true }) as IRemoteUser; } catch (e) { // duplicate key error @@ -212,13 +220,17 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU const bannerId = banner ? banner._id : null; const avatarUrl = getDriveFileUrl(avatar, true); const bannerUrl = getDriveFileUrl(banner, false); + const avatarColor = avatar && avatar.metadata.properties.avgColor ? avatar.metadata.properties.avgColor : null; + const bannerColor = banner && avatar.metadata.properties.avgColor ? banner.metadata.properties.avgColor : null; await User.update({ _id: user._id }, { $set: { avatarId, bannerId, avatarUrl, - bannerUrl + bannerUrl, + avatarColor, + bannerColor } }); @@ -226,6 +238,23 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU user.bannerId = bannerId; user.avatarUrl = avatarUrl; user.bannerUrl = bannerUrl; + user.avatarColor = avatarColor; + user.bannerColor = bannerColor; + //#endregion + + //#region カスタム絵文字取得 + const emojis = await extractEmojis(person.tag, host).catch(e => { + console.log(`extractEmojis: ${e}`); + return [] as IEmoji[]; + }); + + const emojiNames = emojis.map(emoji => emoji.name); + + await User.update({ _id: user._id }, { + $set: { + emojis: emojiNames + } + }); //#endregion await updateFeatured(user._id).catch(err => console.log(err)); @@ -295,33 +324,68 @@ export async function updatePerson(uri: string, resolver?: Resolver, hint?: obje : resolveImage(exist, img).catch(() => null) ))); + // カスタム絵文字取得 + const emojis = await extractEmojis(person.tag, exist.host).catch(e => { + console.log(`extractEmojis: ${e}`); + return [] as IEmoji[]; + }); + + const emojiNames = emojis.map(emoji => emoji.name); + + const fields = await extractFields(person.attachment).catch(e => { + console.log(`cat not extract fields: ${e}`); + }); + + const updates = { + lastFetchedAt: new Date(), + inbox: person.inbox, + sharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined), + featured: person.featured, + emojis: emojiNames, + description: htmlToMFM(person.summary), + followersCount, + followingCount, + notesCount, + name: person.name, + url: person.url, + endpoints: person.endpoints, + fields, + isBot: object.type == 'Service', + isCat: (person as any).isCat === true, + isLocked: person.manuallyApprovesFollowers, + createdAt: Date.parse(person.published) || null, + publicKey: { + id: person.publicKey.id, + publicKeyPem: person.publicKey.publicKeyPem + }, + } as any; + + if (avatar) { + updates.avatarId = avatar._id; + updates.avatarUrl = getDriveFileUrl(avatar, true); + updates.avatarColor = avatar.metadata.properties.avgColor ? avatar.metadata.properties.avgColor : null; + } + + if (banner) { + updates.bannerId = banner._id; + updates.bannerUrl = getDriveFileUrl(banner, true); + updates.bannerColor = banner.metadata.properties.avgColor ? banner.metadata.properties.avgColor : null; + } + // Update user await User.update({ _id: exist._id }, { + $set: updates + }); + + // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする + await Following.update({ + followerId: exist._id + }, { $set: { - updatedAt: new Date(), - inbox: person.inbox, - sharedInbox: person.sharedInbox, - featured: person.featured, - avatarId: avatar ? avatar._id : null, - bannerId: banner ? banner._id : null, - avatarUrl: getDriveFileUrl(avatar, true), - bannerUrl: getDriveFileUrl(banner, false), - description: htmlToMFM(person.summary), - followersCount, - followingCount, - notesCount, - name: person.name, - url: person.url, - endpoints: person.endpoints, - isBot: object.type == 'Service', - isCat: (person as any).isCat === true ? true : false, - isLocked: person.manuallyApprovesFollowers, - createdAt: Date.parse(person.published) || null, - publicKey: { - id: person.publicKey.id, - publicKeyPem: person.publicKey.publicKeyPem - }, + '_follower.sharedInbox': person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined) } + }, { + multi: true }); await updateFeatured(exist._id).catch(err => console.log(err)); @@ -349,6 +413,18 @@ export async function resolvePerson(uri: string, verifier?: string, resolver?: R return await createPerson(uri, resolver); } +export async function extractFields(attachments: ITag[]) { + if (!attachments) return []; + + return attachments.filter(a => a.type === 'PropertyValue' && a.name && a.value) + .map(a => { + return { + name: a.name, + value: htmlToMFM(a.value) + }; + }); +} + export async function updateFeatured(userId: mongo.ObjectID) { const user = await User.findOne({ _id: userId }); if (!isRemoteUser(user)) return; @@ -375,7 +451,7 @@ export async function updateFeatured(userId: mongo.ObjectID) { await User.update({ _id: user._id }, { $set: { - pinnedNoteIds: featuredNotes.map(note => note._id) + pinnedNoteIds: featuredNotes.filter(note => note != null).map(note => note._id) } }); } diff --git a/src/remote/activitypub/models/question.ts b/src/remote/activitypub/models/question.ts new file mode 100644 index 0000000000..53892a409e --- /dev/null +++ b/src/remote/activitypub/models/question.ts @@ -0,0 +1,19 @@ +import { IChoice, IPoll } from '../../../models/note'; +import Resolver from '../resolver'; + +export async function extractPollFromQuestion(questionUri: string): Promise<IPoll> { + const resolver = new Resolver(); + const question = await resolver.resolve(questionUri) as any; + + const choices: IChoice[] = question.oneOf.map((x: any, i: number) => { + return { + id: i, + text: x.name, + votes: x._misskey_votes || 0, + } as IChoice; + }); + + return { + choices + }; +} diff --git a/src/remote/activitypub/models/tag.ts b/src/remote/activitypub/models/tag.ts index 5cdbfa43b1..347e001aec 100644 --- a/src/remote/activitypub/models/tag.ts +++ b/src/remote/activitypub/models/tag.ts @@ -1,4 +1,4 @@ -import { IIcon } from "./icon"; +import { IIcon } from './icon'; /*** * tag (ActivityPub) @@ -7,6 +7,7 @@ export type ITag = { id: string; type: string; name?: string; + value?: string; updated?: Date; icon?: IIcon; }; diff --git a/src/remote/activitypub/renderer/block.ts b/src/remote/activitypub/renderer/block.ts index 316fc13c05..694f3a1418 100644 --- a/src/remote/activitypub/renderer/block.ts +++ b/src/remote/activitypub/renderer/block.ts @@ -1,5 +1,5 @@ import config from '../../../config'; -import { ILocalUser, IRemoteUser } from "../../../models/user"; +import { ILocalUser, IRemoteUser } from '../../../models/user'; export default (blocker?: ILocalUser, blockee?: IRemoteUser) => ({ type: 'Block', diff --git a/src/remote/activitypub/renderer/delete.ts b/src/remote/activitypub/renderer/delete.ts index 2a4e70e25e..e090e1c886 100644 --- a/src/remote/activitypub/renderer/delete.ts +++ b/src/remote/activitypub/renderer/delete.ts @@ -1,5 +1,5 @@ import config from '../../../config'; -import { ILocalUser } from "../../../models/user"; +import { ILocalUser } from '../../../models/user'; export default (object: any, user: ILocalUser) => ({ type: 'Delete', diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts index ec66fe41ff..910e4dba76 100644 --- a/src/remote/activitypub/renderer/note.ts +++ b/src/remote/activitypub/renderer/note.ts @@ -7,7 +7,6 @@ import DriveFile, { IDriveFile } from '../../../models/drive-file'; import Note, { INote } from '../../../models/note'; import User from '../../../models/user'; import toHtml from '../misc/get-note-html'; -import parseMfm from '../../../mfm/parse'; import Emoji, { IEmoji } from '../../../models/emoji'; export default async function renderNote(note: INote, dive = true): Promise<any> { @@ -43,6 +42,18 @@ export default async function renderNote(note: INote, dive = true): Promise<any> inReplyTo = null; } + let quote; + + if (note.renoteId) { + const renote = await Note.findOne({ + _id: note.renoteId, + }); + + if (renote) { + quote = renote.uri ? renote.uri : `${config.url}/notes/${renote._id}`; + } + } + const user = await User.findOne({ _id: note.userId }); @@ -82,31 +93,37 @@ export default async function renderNote(note: INote, dive = true): Promise<any> let text = note.text; + let question: string; if (note.poll != null) { if (text == null) text = ''; const url = `${config.url}/notes/${note._id}`; // TODO: i18n - text += `\n\n[投票を見る](${url})`; + text += `\n[リモートで結果を表示](${url})`; + + question = `${config.url}/questions/${note._id}`; } - if (note.renoteId != null) { - if (text == null) text = ''; - const url = `${config.url}/notes/${note.renoteId}`; - text += `\n\nRE: ${url}`; + let apText = text; + if (apText == null) apText = ''; + + // Provides choices as text for AP + if (note.poll != null) { + const cs = note.poll.choices.map(c => `${c.id}: ${c.text}`); + apText += '\n----------------------------------------\n'; + apText += cs.join('\n'); + apText += '\n----------------------------------------\n'; + apText += '番号を返信して投票'; } - // 省略されたメンションのホストを復元する - if (text != null && text != '') { - text = parseMfm(text).map(x => { - if (x.type == 'mention' && x.host == null) { - return `${x.content}@${config.host}`; - } else { - return x.content; - } - }).join(''); + if (quote) { + apText += `\n\nRE: ${quote}`; } - const content = toHtml(Object.assign({}, note, { text })); + const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; + + const content = toHtml(Object.assign({}, note, { + text: apText + })); const emojis = await getEmojis(note.emojis); const apemojis = emojis.map(emoji => renderEmoji(emoji)); @@ -121,9 +138,11 @@ export default async function renderNote(note: INote, dive = true): Promise<any> id: `${config.url}/notes/${note._id}`, type: 'Note', attributedTo, - summary: note.cw, + summary, content, _misskey_content: text, + _misskey_quote: quote, + _misskey_question: question, published: note.createdAt.toISOString(), to, cc, @@ -134,7 +153,7 @@ export default async function renderNote(note: INote, dive = true): Promise<any> }; } -async function getEmojis(names: string[]): Promise<IEmoji[]> { +export async function getEmojis(names: string[]): Promise<IEmoji[]> { if (names == null || names.length < 1) return []; const emojis = await Promise.all( diff --git a/src/remote/activitypub/renderer/person.ts b/src/remote/activitypub/renderer/person.ts index 52485e6959..aaf78444d4 100644 --- a/src/remote/activitypub/renderer/person.ts +++ b/src/remote/activitypub/renderer/person.ts @@ -5,6 +5,8 @@ import { ILocalUser } from '../../../models/user'; import toHtml from '../../../mfm/html'; import parse from '../../../mfm/parse'; import DriveFile from '../../../models/drive-file'; +import { getEmojis } from './note'; +import renderEmoji from './emoji'; export default async (user: ILocalUser) => { const id = `${config.url}/users/${user._id}`; @@ -14,6 +16,44 @@ export default async (user: ILocalUser) => { DriveFile.findOne({ _id: user.bannerId }) ]); + const attachment: { + type: string, + name: string, + value: string, + verified_at?: string + }[] = []; + + if (user.twitter) { + attachment.push({ + type: 'PropertyValue', + name: 'Twitter', + value: `<a href="https://twitter.com/intent/user?user_id=${user.twitter.userId}" rel="me nofollow noopener" target="_blank"><span>@${user.twitter.screenName}</span></a>` + }); + } + + if (user.github) { + attachment.push({ + type: 'PropertyValue', + name: 'GitHub', + value: `<a href="https://github.com/${user.github.login}" rel="me nofollow noopener" target="_blank"><span>@${user.github.login}</span></a>` + }); + } + + if (user.discord) { + attachment.push({ + type: 'PropertyValue', + name: 'Discord', + value: `<a href="https://discordapp.com/users/${user.discord.id}" rel="me nofollow noopener" target="_blank"><span>${user.discord.username}#${user.discord.discriminator}</span></a>` + }); + } + + const emojis = await getEmojis(user.emojis); + const apemojis = emojis.map(emoji => renderEmoji(emoji)); + + const tag = [ + ...apemojis, + ]; + return { type: user.isBot ? 'Service' : 'Person', id, @@ -23,14 +63,17 @@ export default async (user: ILocalUser) => { following: `${id}/following`, featured: `${id}/collections/featured`, sharedInbox: `${config.url}/inbox`, + endpoints: { sharedInbox: `${config.url}/inbox` }, url: `${config.url}/@${user.username}`, preferredUsername: user.username, name: user.name, summary: toHtml(parse(user.description)), icon: user.avatarId && renderImage(avatar), image: user.bannerId && renderImage(banner), + tag, manuallyApprovesFollowers: user.isLocked, publicKey: renderKey(user), - isCat: user.isCat + isCat: user.isCat, + attachment: attachment.length ? attachment : undefined }; }; diff --git a/src/remote/activitypub/renderer/question.ts b/src/remote/activitypub/renderer/question.ts new file mode 100644 index 0000000000..9df4daca3b --- /dev/null +++ b/src/remote/activitypub/renderer/question.ts @@ -0,0 +1,20 @@ +import config from '../../../config'; +import { ILocalUser } from '../../../models/user'; +import { INote } from '../../../models/note'; + +export default async function renderQuestion(user: ILocalUser, note: INote) { + const question = { + type: 'Question', + id: `${config.url}/questions/${note._id}`, + actor: `${config.url}/users/${user._id}`, + content: note.text != null ? note.text : '', + oneOf: note.poll.choices.map(c => { + return { + name: c.text, + _misskey_votes: c.votes, + }; + }), + }; + + return question; +} diff --git a/src/remote/activitypub/renderer/undo.ts b/src/remote/activitypub/renderer/undo.ts index bf90a3f281..dbcf5732be 100644 --- a/src/remote/activitypub/renderer/undo.ts +++ b/src/remote/activitypub/renderer/undo.ts @@ -1,5 +1,5 @@ import config from '../../../config'; -import { ILocalUser, IUser } from "../../../models/user"; +import { ILocalUser, IUser } from '../../../models/user'; export default (object: any, user: ILocalUser | IUser) => ({ type: 'Undo', diff --git a/src/remote/activitypub/request.ts b/src/remote/activitypub/request.ts index 68c53e0c6c..d98e36b3d7 100644 --- a/src/remote/activitypub/request.ts +++ b/src/remote/activitypub/request.ts @@ -3,6 +3,8 @@ const { sign } = require('http-signature'); import { URL } from 'url'; import * as debug from 'debug'; const crypto = require('crypto'); +const { lookup } = require('lookup-dns-cache'); +const promiseAny = require('promise-any'); import config from '../../config'; import { ILocalUser } from '../../models/user'; @@ -10,12 +12,12 @@ import { publishApLogStream } from '../../stream'; const log = debug('misskey:activitypub:deliver'); -export default (user: ILocalUser, url: string, object: any) => new Promise((resolve, reject) => { +export default (user: ILocalUser, url: string, object: any) => new Promise(async (resolve, reject) => { log(`--> ${url}`); const timeout = 10 * 1000; - const { protocol, hostname, port, pathname, search } = new URL(url); + const { protocol, host, hostname, port, pathname, search } = new URL(url); const data = JSON.stringify(object); @@ -23,14 +25,19 @@ export default (user: ILocalUser, url: string, object: any) => new Promise((reso sha256.update(data); const hash = sha256.digest('base64'); + const addr = await resolveAddr(hostname).catch(e => reject(e)); + if (!addr) return; + const req = request({ protocol, - hostname, + hostname: addr, + setHost: false, port, method: 'POST', path: pathname + search, timeout, headers: { + 'Host': host, 'User-Agent': config.user_agent, 'Content-Type': 'application/activity+json', 'Digest': `SHA-256=${hash}` @@ -75,3 +82,23 @@ export default (user: ILocalUser, url: string, object: any) => new Promise((reso }); //#endregion }); + +/** + * Resolve host (with cached, asynchrony) + */ +async function resolveAddr(domain: string) { + // v4/v6で先に取得できた方を採用する + return await promiseAny([ + resolveAddrInner(domain, { ipv6: false }), + resolveAddrInner(domain, { ipv6: true }) + ]); +} + +function resolveAddrInner(domain: string, options = { }): Promise<string> { + return new Promise((res, rej) => { + lookup(domain, options, (error: any, address: string) => { + if (error) return rej(error); + return res(address); + }); + }); +} diff --git a/src/remote/activitypub/type.ts b/src/remote/activitypub/type.ts index 2344035013..b902abea23 100644 --- a/src/remote/activitypub/type.ts +++ b/src/remote/activitypub/type.ts @@ -41,6 +41,8 @@ export interface IOrderedCollection extends IObject { export interface INote extends IObject { type: 'Note'; _misskey_content: string; + _misskey_quote: string; + _misskey_question: string; } export interface IPerson extends IObject { @@ -55,7 +57,7 @@ export interface IPerson extends IObject { following: any; featured?: any; outbox: any; - endpoints: string[]; + endpoints: any; } export const isCollection = (object: IObject): object is ICollection => diff --git a/src/server/activitypub.ts b/src/server/activitypub.ts index 888feb08ce..ac8d3d4e26 100644 --- a/src/server/activitypub.ts +++ b/src/server/activitypub.ts @@ -1,4 +1,4 @@ -import * as mongo from 'mongodb'; +import { ObjectID } from 'mongodb'; import * as Router from 'koa-router'; const json = require('koa-json-body'); const httpSignature = require('http-signature'); @@ -7,13 +7,16 @@ import { createHttpJob } from '../queue'; import pack from '../remote/activitypub/renderer'; import Note from '../models/note'; import User, { isLocalUser, ILocalUser, IUser } from '../models/user'; +import Emoji from '../models/emoji'; import renderNote from '../remote/activitypub/renderer/note'; import renderKey from '../remote/activitypub/renderer/key'; import renderPerson from '../remote/activitypub/renderer/person'; +import renderEmoji from '../remote/activitypub/renderer/emoji'; import Outbox, { packActivity } from './activitypub/outbox'; import Followers from './activitypub/followers'; import Following from './activitypub/following'; import Featured from './activitypub/featured'; +import renderQuestion from '../remote/activitypub/renderer/question'; // Init router const router = new Router(); @@ -64,8 +67,13 @@ router.post('/users/:user/inbox', json(), inbox); router.get('/notes/:note', async (ctx, next) => { if (!isActivityPubReq(ctx)) return await next(); + if (!ObjectID.isValid(ctx.params.note)) { + ctx.status = 404; + return; + } + const note = await Note.findOne({ - _id: new mongo.ObjectID(ctx.params.note), + _id: new ObjectID(ctx.params.note), visibility: { $in: ['public', 'home'] }, localOnly: { $ne: true } }); @@ -82,8 +90,13 @@ router.get('/notes/:note', async (ctx, next) => { // note activity router.get('/notes/:note/activity', async ctx => { + if (!ObjectID.isValid(ctx.params.note)) { + ctx.status = 404; + return; + } + const note = await Note.findOne({ - _id: new mongo.ObjectID(ctx.params.note), + _id: new ObjectID(ctx.params.note), visibility: { $in: ['public', 'home'] }, localOnly: { $ne: true } }); @@ -98,6 +111,36 @@ router.get('/notes/:note/activity', async ctx => { setResponseType(ctx); }); +// question +router.get('/questions/:question', async (ctx, next) => { + if (!ObjectID.isValid(ctx.params.question)) { + ctx.status = 404; + return; + } + + const poll = await Note.findOne({ + _id: new ObjectID(ctx.params.question), + visibility: { $in: ['public', 'home'] }, + localOnly: { $ne: true }, + poll: { + $exists: true, + $ne: null + }, + }); + + if (poll === null) { + ctx.status = 404; + return; + } + + const user = await User.findOne({ + _id: poll.userId + }); + + ctx.body = pack(await renderQuestion(user as ILocalUser, poll)); + setResponseType(ctx); +}); + // outbox router.get('/users/:user/outbox', Outbox); @@ -112,7 +155,12 @@ router.get('/users/:user/collections/featured', Featured); // publickey router.get('/users/:user/publickey', async ctx => { - const userId = new mongo.ObjectID(ctx.params.user); + if (!ObjectID.isValid(ctx.params.user)) { + ctx.status = 404; + return; + } + + const userId = new ObjectID(ctx.params.user); const user = await User.findOne({ _id: userId, @@ -145,8 +193,15 @@ async function userInfo(ctx: Router.IRouterContext, user: IUser) { setResponseType(ctx); } -router.get('/users/:user', async ctx => { - const userId = new mongo.ObjectID(ctx.params.user); +router.get('/users/:user', async (ctx, next) => { + if (!isActivityPubReq(ctx)) return await next(); + + if (!ObjectID.isValid(ctx.params.user)) { + ctx.status = 404; + return; + } + + const userId = new ObjectID(ctx.params.user); const user = await User.findOne({ _id: userId, @@ -168,4 +223,21 @@ router.get('/@:user', async (ctx, next) => { }); //#endregion +// emoji +router.get('/emojis/:emoji', async ctx => { + const emoji = await Emoji.findOne({ + host: null, + name: ctx.params.emoji + }); + + if (emoji === null) { + ctx.status = 404; + return; + } + + ctx.body = pack(await renderEmoji(emoji)); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); +}); + export default router; diff --git a/src/server/activitypub/featured.ts b/src/server/activitypub/featured.ts index f400cc416f..12613b3ecf 100644 --- a/src/server/activitypub/featured.ts +++ b/src/server/activitypub/featured.ts @@ -1,4 +1,4 @@ -import * as mongo from 'mongodb'; +import { ObjectID } from 'mongodb'; import * as Router from 'koa-router'; import config from '../../config'; import User from '../../models/user'; @@ -9,7 +9,12 @@ import Note from '../../models/note'; import renderNote from '../../remote/activitypub/renderer/note'; export default async (ctx: Router.IRouterContext) => { - const userId = new mongo.ObjectID(ctx.params.user); + if (!ObjectID.isValid(ctx.params.user)) { + ctx.status = 404; + return; + } + + const userId = new ObjectID(ctx.params.user); // Verify user const user = await User.findOne({ @@ -24,7 +29,7 @@ export default async (ctx: Router.IRouterContext) => { const pinnedNoteIds = user.pinnedNoteIds || []; - const pinnedNotes = await Promise.all(pinnedNoteIds.map(id => Note.findOne({ _id: id }))); + const pinnedNotes = await Promise.all(pinnedNoteIds.filter(ObjectID.isValid).map(id => Note.findOne({ _id: id }))); const renderedNotes = await Promise.all(pinnedNotes.map(note => renderNote(note))); diff --git a/src/server/activitypub/followers.ts b/src/server/activitypub/followers.ts index 5c809424cc..9c28c98cd8 100644 --- a/src/server/activitypub/followers.ts +++ b/src/server/activitypub/followers.ts @@ -1,4 +1,4 @@ -import * as mongo from 'mongodb'; +import { ObjectID } from 'mongodb'; import * as Router from 'koa-router'; import config from '../../config'; import $ from 'cafy'; import ID, { transform } from '../../misc/cafy-id'; @@ -11,7 +11,12 @@ import renderFollowUser from '../../remote/activitypub/renderer/follow-user'; import { setResponseType } from '../activitypub'; export default async (ctx: Router.IRouterContext) => { - const userId = new mongo.ObjectID(ctx.params.user); + if (!ObjectID.isValid(ctx.params.user)) { + ctx.status = 404; + return; + } + + const userId = new ObjectID(ctx.params.user); // Get 'cursor' parameter const [cursor = null, cursorErr] = $.type(ID).optional.get(ctx.request.query.cursor); diff --git a/src/server/activitypub/following.ts b/src/server/activitypub/following.ts index a46bb9c7ff..97245245ad 100644 --- a/src/server/activitypub/following.ts +++ b/src/server/activitypub/following.ts @@ -1,7 +1,8 @@ -import * as mongo from 'mongodb'; +import { ObjectID } from 'mongodb'; import * as Router from 'koa-router'; import config from '../../config'; -import $ from 'cafy'; import ID, { transform } from '../../misc/cafy-id'; +import $ from 'cafy'; +import ID, { transform } from '../../misc/cafy-id'; import User from '../../models/user'; import Following from '../../models/following'; import pack from '../../remote/activitypub/renderer'; @@ -11,7 +12,12 @@ import renderFollowUser from '../../remote/activitypub/renderer/follow-user'; import { setResponseType } from '../activitypub'; export default async (ctx: Router.IRouterContext) => { - const userId = new mongo.ObjectID(ctx.params.user); + if (!ObjectID.isValid(ctx.params.user)) { + ctx.status = 404; + return; + } + + const userId = new ObjectID(ctx.params.user); // Get 'cursor' parameter const [cursor = null, cursorErr] = $.type(ID).optional.get(ctx.request.query.cursor); diff --git a/src/server/activitypub/outbox.ts b/src/server/activitypub/outbox.ts index 6b917ef843..c35298e3a8 100644 --- a/src/server/activitypub/outbox.ts +++ b/src/server/activitypub/outbox.ts @@ -1,7 +1,8 @@ -import * as mongo from 'mongodb'; +import { ObjectID } from 'mongodb'; import * as Router from 'koa-router'; import config from '../../config'; -import $ from 'cafy'; import ID, { transform } from '../../misc/cafy-id'; +import $ from 'cafy'; +import ID, { transform } from '../../misc/cafy-id'; import User from '../../models/user'; import pack from '../../remote/activitypub/renderer'; import renderOrderedCollection from '../../remote/activitypub/renderer/ordered-collection'; @@ -15,7 +16,12 @@ import renderAnnounce from '../../remote/activitypub/renderer/announce'; import { countIf } from '../../prelude/array'; export default async (ctx: Router.IRouterContext) => { - const userId = new mongo.ObjectID(ctx.params.user); + if (!ObjectID.isValid(ctx.params.user)) { + ctx.status = 404; + return; + } + + const userId = new ObjectID(ctx.params.user); // Get 'sinceId' parameter const [sinceId, sinceIdErr] = $.type(ID).optional.get(ctx.request.query.since_id); diff --git a/src/server/api/call.ts b/src/server/api/call.ts index a3953d0439..c19e045cd5 100644 --- a/src/server/api/call.ts +++ b/src/server/api/call.ts @@ -1,5 +1,5 @@ import { performance } from 'perf_hooks'; -import limitter from './limitter'; +import limiter from './limiter'; import { IUser } from '../../models/user'; import { IApp } from '../../models/app'; import endpoints from './endpoints'; @@ -39,7 +39,7 @@ export default (endpoint: string, user: IUser, app: IApp, data: any, file?: any) if (ep.meta.requireCredential && ep.meta.limit) { try { - await limitter(ep, user); // Rate limit + await limiter(ep, user); // Rate limit } catch (e) { // drop request if limit exceeded return rej('RATE_LIMIT_EXCEEDED'); diff --git a/src/server/api/common/get-friends.ts b/src/server/api/common/get-friends.ts index e0b05f73df..876aa399f7 100644 --- a/src/server/api/common/get-friends.ts +++ b/src/server/api/common/get-friends.ts @@ -36,14 +36,12 @@ export const getFriends = async (me: mongodb.ObjectID, includeMe = true, remoteO // ID list of other users who the I follows const myfollowings = followings.map(following => ({ - id: following.followeeId, - stalk: following.stalk + id: following.followeeId })); if (includeMe) { myfollowings.push({ - id: me, - stalk: true + id: me }); } diff --git a/src/server/api/common/getters.ts b/src/server/api/common/getters.ts new file mode 100644 index 0000000000..1fce58b20a --- /dev/null +++ b/src/server/api/common/getters.ts @@ -0,0 +1,18 @@ +import * as mongo from 'mongodb'; +import Note from "../../../models/note"; + +/** + * Get valied note for API processing + */ +export async function getValiedNote(noteId: mongo.ObjectID) { + const note = await Note.findOne({ + _id: noteId, + deletedAt: { $exists: false } + }); + + if (note === null) { + throw 'note not found'; + } + + return note; +} diff --git a/src/server/api/common/signin.ts b/src/server/api/common/signin.ts index 8d44b377fe..45a42e288d 100644 --- a/src/server/api/common/signin.ts +++ b/src/server/api/common/signin.ts @@ -4,21 +4,24 @@ import config from '../../../config'; import { ILocalUser } from '../../../models/user'; export default function(ctx: Koa.Context, user: ILocalUser, redirect = false) { - const expires = 1000 * 60 * 60 * 24 * 365; // One Year - ctx.cookies.set('i', user.token, { - path: '/', - domain: config.hostname, - // SEE: https://github.com/koajs/koa/issues/974 - //secure: config.url.startsWith('https'), - secure: false, - httpOnly: false, - expires: new Date(Date.now() + expires), - maxAge: expires - }); - if (redirect) { + //#region Cookie + const expires = 1000 * 60 * 60 * 24 * 365; // One Year + ctx.cookies.set('i', user.token, { + path: '/', + domain: config.hostname, + // SEE: https://github.com/koajs/koa/issues/974 + // When using a SSL proxy it should be configured to add the "X-Forwarded-Proto: https" header + secure: config.url.startsWith('https'), + httpOnly: false, + expires: new Date(Date.now() + expires), + maxAge: expires + }); + //#endregion + ctx.redirect(config.url); } else { - ctx.status = 204; + ctx.body = { i: user.token }; + ctx.status = 200; } } diff --git a/src/server/api/endpoints/admin/abuse-user-reports.ts b/src/server/api/endpoints/admin/abuse-user-reports.ts new file mode 100644 index 0000000000..c88174f13f --- /dev/null +++ b/src/server/api/endpoints/admin/abuse-user-reports.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; import ID, { transform } from '../../../../misc/cafy-id'; +import Report, { packMany } from '../../../../models/abuse-user-report'; +import define from '../../define'; + +export const meta = { + requireCredential: true, + requireModerator: true, + + params: { + limit: { + validator: $.num.optional.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.type(ID).optional, + transform: transform, + }, + + untilId: { + validator: $.type(ID).optional, + transform: transform, + }, + } +}; + +export default define(meta, (ps) => new Promise(async (res, rej) => { + if (ps.sinceId && ps.untilId) { + return rej('cannot set sinceId and untilId'); + } + + const sort = { + _id: -1 + }; + const query = {} as any; + if (ps.sinceId) { + sort._id = 1; + query._id = { + $gt: ps.sinceId + }; + } else if (ps.untilId) { + query._id = { + $lt: ps.untilId + }; + } + + const reports = await Report + .find(query, { + limit: ps.limit, + sort: sort + }); + + res(await packMany(reports)); +})); diff --git a/src/server/api/endpoints/admin/drive/files.ts b/src/server/api/endpoints/admin/drive/files.ts new file mode 100644 index 0000000000..177a808cbf --- /dev/null +++ b/src/server/api/endpoints/admin/drive/files.ts @@ -0,0 +1,81 @@ +import $ from 'cafy'; +import File, { packMany } from '../../../../../models/drive-file'; +import define from '../../../define'; + +export const meta = { + requireCredential: false, + requireModerator: true, + + params: { + limit: { + validator: $.num.optional.range(1, 100), + default: 10 + }, + + offset: { + validator: $.num.optional.min(0), + default: 0 + }, + + sort: { + validator: $.str.optional.or([ + '+createdAt', + '-createdAt', + '+size', + '-size', + ]), + }, + + origin: { + validator: $.str.optional.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'local' + } + } +}; + +export default define(meta, (ps, me) => new Promise(async (res, rej) => { + let _sort; + if (ps.sort) { + if (ps.sort == '+createdAt') { + _sort = { + uploadDate: -1 + }; + } else if (ps.sort == '-createdAt') { + _sort = { + uploadDate: 1 + }; + } else if (ps.sort == '+size') { + _sort = { + length: -1 + }; + } else if (ps.sort == '-size') { + _sort = { + length: 1 + }; + } + } else { + _sort = { + _id: -1 + }; + } + + const q = { + 'metadata.deletedAt': { $exists: false }, + } as any; + + if (ps.origin == 'local') q['metadata._user.host'] = null; + if (ps.origin == 'remote') q['metadata._user.host'] = { $ne: null }; + + const files = await File + .find(q, { + limit: ps.limit, + sort: _sort, + skip: ps.offset + }); + + res(await packMany(files, { detail: true, withUser: true, self: true })); +})); diff --git a/src/server/api/endpoints/admin/drive/show-file.ts b/src/server/api/endpoints/admin/drive/show-file.ts new file mode 100644 index 0000000000..6dfab19643 --- /dev/null +++ b/src/server/api/endpoints/admin/drive/show-file.ts @@ -0,0 +1,28 @@ +import $ from 'cafy'; +import ID, { transform } from '../../../../../misc/cafy-id'; +import define from '../../../define'; +import DriveFile from '../../../../../models/drive-file'; + +export const meta = { + requireCredential: true, + requireModerator: true, + + params: { + fileId: { + validator: $.type(ID), + transform: transform, + }, + } +}; + +export default define(meta, (ps, me) => new Promise(async (res, rej) => { + const file = await DriveFile.findOne({ + _id: ps.fileId + }); + + if (file == null) { + return rej('file not found'); + } + + res(file); +})); diff --git a/src/server/api/endpoints/admin/remove-abuse-user-report.ts b/src/server/api/endpoints/admin/remove-abuse-user-report.ts new file mode 100644 index 0000000000..4d068a410e --- /dev/null +++ b/src/server/api/endpoints/admin/remove-abuse-user-report.ts @@ -0,0 +1,32 @@ +import $ from 'cafy'; +import ID, { transform } from '../../../../misc/cafy-id'; +import define from '../../define'; +import AbuseUserReport from '../../../../models/abuse-user-report'; + +export const meta = { + requireCredential: true, + requireModerator: true, + + params: { + reportId: { + validator: $.type(ID), + transform: transform + }, + } +}; + +export default define(meta, (ps) => new Promise(async (res, rej) => { + const report = await AbuseUserReport.findOne({ + _id: ps.reportId + }); + + if (report == null) { + return rej('report not found'); + } + + await AbuseUserReport.remove({ + _id: report._id + }); + + res(); +})); diff --git a/src/server/api/endpoints/admin/reset-password.ts b/src/server/api/endpoints/admin/reset-password.ts new file mode 100644 index 0000000000..c072c12e0d --- /dev/null +++ b/src/server/api/endpoints/admin/reset-password.ts @@ -0,0 +1,57 @@ +import $ from 'cafy'; +import ID, { transform } from '../../../../misc/cafy-id'; +import define from '../../define'; +import User from '../../../../models/user'; +import * as bcrypt from 'bcryptjs'; +import rndstr from 'rndstr'; + +export const meta = { + desc: { + 'ja-JP': '指定したユーザーのパスワードをリセットします。', + }, + + requireCredential: true, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + transform: transform, + desc: { + 'ja-JP': '対象のユーザーID', + 'en-US': 'The user ID which you want to suspend' + } + }, + } +}; + +export default define(meta, (ps) => new Promise(async (res, rej) => { + const user = await User.findOne({ + _id: ps.userId + }); + + if (user == null) { + return rej('user not found'); + } + + if (user.isAdmin) { + return rej('cannot reset password of admin'); + } + + const passwd = rndstr('a-zA-Z0-9', 8); + + // Generate hash of password + const hash = bcrypt.hashSync(passwd); + + await User.findOneAndUpdate({ + _id: user._id + }, { + $set: { + password: hash + } + }); + + res({ + password: passwd + }); +})); diff --git a/src/server/api/endpoints/admin/show-user.ts b/src/server/api/endpoints/admin/show-user.ts new file mode 100644 index 0000000000..490b685352 --- /dev/null +++ b/src/server/api/endpoints/admin/show-user.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import ID, { transform } from '../../../../misc/cafy-id'; +import define from '../../define'; +import User from '../../../../models/user'; + +export const meta = { + desc: { + 'ja-JP': '指定したユーザーの情報を取得します。', + }, + + requireCredential: true, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + transform: transform, + desc: { + 'ja-JP': '対象のユーザーID', + 'en-US': 'The user ID which you want to suspend' + } + }, + } +}; + +export default define(meta, (ps, me) => new Promise(async (res, rej) => { + const user = await User.findOne({ + _id: ps.userId + }); + + if (user == null) { + return rej('user not found'); + } + + if (me.isModerator && user.isAdmin) { + return rej('cannot show info of admin'); + } + + res(user); +})); diff --git a/src/server/api/endpoints/admin/show-users.ts b/src/server/api/endpoints/admin/show-users.ts new file mode 100644 index 0000000000..20ccfbd7f3 --- /dev/null +++ b/src/server/api/endpoints/admin/show-users.ts @@ -0,0 +1,123 @@ +import $ from 'cafy'; +import User, { pack } from '../../../../models/user'; +import define from '../../define'; + +export const meta = { + requireCredential: true, + requireModerator: true, + + params: { + limit: { + validator: $.num.optional.range(1, 100), + default: 10 + }, + + offset: { + validator: $.num.optional.min(0), + default: 0 + }, + + sort: { + validator: $.str.optional.or([ + '+follower', + '-follower', + '+createdAt', + '-createdAt', + '+updatedAt', + '-updatedAt', + ]), + }, + + state: { + validator: $.str.optional.or([ + 'all', + 'admin', + 'moderator', + 'adminOrModerator', + 'verified', + 'suspended', + ]), + default: 'all' + }, + + origin: { + validator: $.str.optional.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'local' + } + } +}; + +export default define(meta, (ps, me) => new Promise(async (res, rej) => { + let _sort; + if (ps.sort) { + if (ps.sort == '+follower') { + _sort = { + followersCount: -1 + }; + } else if (ps.sort == '-follower') { + _sort = { + followersCount: 1 + }; + } else if (ps.sort == '+createdAt') { + _sort = { + createdAt: -1 + }; + } else if (ps.sort == '+updatedAt') { + _sort = { + updatedAt: -1 + }; + } else if (ps.sort == '-createdAt') { + _sort = { + createdAt: 1 + }; + } else if (ps.sort == '-updatedAt') { + _sort = { + updatedAt: 1 + }; + } + } else { + _sort = { + _id: -1 + }; + } + + const q = { + $and: [] + } as any; + + // state + q.$and.push( + ps.state == 'admin' ? { isAdmin: true } : + ps.state == 'moderator' ? { isModerator: true } : + ps.state == 'adminOrModerator' ? { + $or: [{ + isAdmin: true + }, { + isModerator: true + }] + } : + ps.state == 'verified' ? { isVerified: true } : + ps.state == 'suspended' ? { isSuspended: true } : + {} + ); + + // origin + q.$and.push( + ps.origin == 'local' ? { host: null } : + ps.origin == 'remote' ? { host: { $ne: null } } : + {} + ); + + const users = await User + .find(q, { + limit: ps.limit, + sort: _sort, + skip: ps.offset + }); + + res(await Promise.all(users.map(user => pack(user, me, { detail: true })))); +})); diff --git a/src/server/api/endpoints/admin/suspend-user.ts b/src/server/api/endpoints/admin/suspend-user.ts index 5bbd387a20..2ec5196880 100644 --- a/src/server/api/endpoints/admin/suspend-user.ts +++ b/src/server/api/endpoints/admin/suspend-user.ts @@ -37,6 +37,10 @@ export default define(meta, (ps) => new Promise(async (res, rej) => { return rej('cannot suspend admin'); } + if (user.isModerator) { + return rej('cannot suspend moderator'); + } + await User.findOneAndUpdate({ _id: user._id }, { diff --git a/src/server/api/endpoints/admin/update-meta.ts b/src/server/api/endpoints/admin/update-meta.ts index bbae212bd7..13663243a2 100644 --- a/src/server/api/endpoints/admin/update-meta.ts +++ b/src/server/api/endpoints/admin/update-meta.ts @@ -32,6 +32,13 @@ export const meta = { } }, + disableGlobalTimeline: { + validator: $.bool.optional.nullable, + desc: { + 'ja-JP': 'グローバルタイムラインを無効にするか否か' + } + }, + hidedTags: { validator: $.arr($.str).optional.nullable, desc: { @@ -39,6 +46,13 @@ export const meta = { } }, + mascotImageUrl: { + validator: $.str.optional.nullable, + desc: { + 'ja-JP': 'インスタンスキャラクター画像のURL' + } + }, + bannerUrl: { validator: $.str.optional.nullable, desc: { @@ -46,6 +60,13 @@ export const meta = { } }, + errorImageUrl: { + validator: $.str.optional.nullable, + desc: { + 'ja-JP': 'インスタンスのエラー画像URL' + } + }, + name: { validator: $.str.optional.nullable, desc: { @@ -139,6 +160,13 @@ export const meta = { } }, + summalyProxy: { + validator: $.str.optional.nullable, + desc: { + 'ja-JP': 'summalyプロキシURL' + } + }, + enableTwitterIntegration: { validator: $.bool.optional, desc: { @@ -200,7 +228,98 @@ export const meta = { desc: { 'ja-JP': 'DiscordアプリのClient Secret' } - } + }, + + enableExternalUserRecommendation: { + validator: $.bool.optional, + desc: { + 'ja-JP': '外部ユーザーレコメンデーションを有効にする' + } + }, + + externalUserRecommendationEngine: { + validator: $.str.optional.nullable, + desc: { + 'ja-JP': '外部ユーザーレコメンデーションのサードパーティエンジン' + } + }, + + externalUserRecommendationTimeout: { + validator: $.num.optional.nullable.min(0), + desc: { + 'ja-JP': '外部ユーザーレコメンデーションのタイムアウト (ミリ秒)' + } + }, + + enableEmail: { + validator: $.bool.optional, + desc: { + 'ja-JP': 'メール配信を有効にするか否か' + } + }, + + email: { + validator: $.str.optional.nullable, + desc: { + 'ja-JP': 'メール配信する際に利用するメールアドレス' + } + }, + + smtpSecure: { + validator: $.bool.optional, + desc: { + 'ja-JP': 'SMTPサーバがSSLを使用しているか否か' + } + }, + + smtpHost: { + validator: $.str.optional.nullable, + desc: { + 'ja-JP': 'SMTPサーバのホスト' + } + }, + + smtpPort: { + validator: $.num.optional.nullable, + desc: { + 'ja-JP': 'SMTPサーバのポート' + } + }, + + smtpUser: { + validator: $.str.optional.nullable, + desc: { + 'ja-JP': 'SMTPサーバのユーザー名' + } + }, + + smtpPass: { + validator: $.str.optional.nullable, + desc: { + 'ja-JP': 'SMTPサーバのパスワード' + } + }, + + enableServiceWorker: { + validator: $.bool.optional, + desc: { + 'ja-JP': 'ServiceWorkerを有効にするか否か' + } + }, + + swPublicKey: { + validator: $.str.optional.nullable, + desc: { + 'ja-JP': 'ServiceWorkerのVAPIDキーペアの公開鍵' + } + }, + + swPrivateKey: { + validator: $.str.optional.nullable, + desc: { + 'ja-JP': 'ServiceWorkerのVAPIDキーペアの秘密鍵' + } + }, } }; @@ -219,10 +338,18 @@ export default define(meta, (ps) => new Promise(async (res, rej) => { set.disableLocalTimeline = ps.disableLocalTimeline; } + if (typeof ps.disableGlobalTimeline === 'boolean') { + set.disableGlobalTimeline = ps.disableGlobalTimeline; + } + if (Array.isArray(ps.hidedTags)) { set.hidedTags = ps.hidedTags; } + if (ps.mascotImageUrl !== undefined) { + set.mascotImageUrl = ps.mascotImageUrl; + } + if (ps.bannerUrl !== undefined) { set.bannerUrl = ps.bannerUrl; } @@ -279,6 +406,10 @@ export default define(meta, (ps) => new Promise(async (res, rej) => { set.langs = ps.langs; } + if (ps.summalyProxy !== undefined) { + set.summalyProxy = ps.summalyProxy; + } + if (ps.enableTwitterIntegration !== undefined) { set.enableTwitterIntegration = ps.enableTwitterIntegration; } @@ -315,6 +446,62 @@ export default define(meta, (ps) => new Promise(async (res, rej) => { set.discordClientSecret = ps.discordClientSecret; } + if (ps.enableExternalUserRecommendation !== undefined) { + set.enableExternalUserRecommendation = ps.enableExternalUserRecommendation; + } + + if (ps.externalUserRecommendationEngine !== undefined) { + set.externalUserRecommendationEngine = ps.externalUserRecommendationEngine; + } + + if (ps.externalUserRecommendationTimeout !== undefined) { + set.externalUserRecommendationTimeout = ps.externalUserRecommendationTimeout; + } + + if (ps.enableEmail !== undefined) { + set.enableEmail = ps.enableEmail; + } + + if (ps.email !== undefined) { + set.email = ps.email; + } + + if (ps.smtpSecure !== undefined) { + set.smtpSecure = ps.smtpSecure; + } + + if (ps.smtpHost !== undefined) { + set.smtpHost = ps.smtpHost; + } + + if (ps.smtpPort !== undefined) { + set.smtpPort = ps.smtpPort; + } + + if (ps.smtpUser !== undefined) { + set.smtpUser = ps.smtpUser; + } + + if (ps.smtpPass !== undefined) { + set.smtpPass = ps.smtpPass; + } + + if (ps.errorImageUrl !== undefined) { + set.errorImageUrl = ps.errorImageUrl; + } + + if (ps.enableServiceWorker !== undefined) { + set.enableServiceWorker = ps.enableServiceWorker; + } + + if (ps.swPublicKey !== undefined) { + set.swPublicKey = ps.swPublicKey; + } + + if (ps.swPrivateKey !== undefined) { + set.swPrivateKey = ps.swPrivateKey; + } + await Meta.update({}, { $set: set }, { upsert: true }); diff --git a/src/server/api/endpoints/aggregation/hashtags.ts b/src/server/api/endpoints/aggregation/hashtags.ts index f8fc7162f5..7577711b5f 100644 --- a/src/server/api/endpoints/aggregation/hashtags.ts +++ b/src/server/api/endpoints/aggregation/hashtags.ts @@ -47,10 +47,7 @@ export default define(meta, (ps) => new Promise(async (res, rej) => { }> = []; // カウント - data.map(x => x._id).forEach(x => { - // ブラックリストに登録されているタグなら弾く - if (hidedTags.includes(x.tag)) return; - + for (const x of data.map(x => x._id).filter(x => !hidedTags.includes(x.tag))) { const i = tags.findIndex(tag => tag.name == x.tag); if (i != -1) { tags[i].count++; @@ -60,10 +57,10 @@ export default define(meta, (ps) => new Promise(async (res, rej) => { count: 1 }); } - }); + } // タグを人気順に並べ替え - tags = tags.sort((a, b) => b.count - a.count); + tags.sort((a, b) => b.count - a.count); tags = tags.slice(0, 30); diff --git a/src/server/api/endpoints/ap/show.ts b/src/server/api/endpoints/ap/show.ts index 778d1e5099..3b4021e0a7 100644 --- a/src/server/api/endpoints/ap/show.ts +++ b/src/server/api/endpoints/ap/show.ts @@ -25,11 +25,10 @@ export const meta = { }, }; -export default define(meta, (ps) => new Promise(async (res, rej) => { - const object = await fetchAny(ps.uri); - if (object == null) return rej('object not found'); - - res(object); +export default define(meta, (ps) => new Promise((res, rej) => { + fetchAny(ps.uri) + .then(object => object != null ? res(object) : rej('object not found')) + .catch(e => rej(e)); })); /*** @@ -80,7 +79,7 @@ async function fetchAny(uri: string) { const user = await createPerson(object.id); return { type: 'User', - object: user + object: await packUser(user, null, { detail: true }) }; } @@ -88,7 +87,7 @@ async function fetchAny(uri: string) { const note = await createNote(object.id); return { type: 'Note', - object: note + object: await packNote(note, null, { detail: true }) }; } diff --git a/src/server/api/endpoints/charts/active-users.ts b/src/server/api/endpoints/charts/active-users.ts new file mode 100644 index 0000000000..5187e5b353 --- /dev/null +++ b/src/server/api/endpoints/charts/active-users.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import define from '../../define'; +import activeUsersChart from '../../../../chart/active-users'; + +export const meta = { + stability: 'stable', + + desc: { + 'ja-JP': 'アクティブユーザーのチャートを取得します。' + }, + + params: { + span: { + validator: $.str.or(['day', 'hour']), + desc: { + 'ja-JP': '集計のスパン (day または hour)' + } + }, + + limit: { + validator: $.num.optional.range(1, 500), + default: 30, + desc: { + 'ja-JP': '最大数。例えば 30 を指定したとすると、スパンが"day"の場合は30日分のデータが、スパンが"hour"の場合は30時間分のデータが返ります。' + } + }, + } +}; + +export default define(meta, (ps) => new Promise(async (res, rej) => { + const stats = await activeUsersChart.getChart(ps.span as any, ps.limit); + + res(stats); +})); diff --git a/src/server/api/endpoints/charts/drive.ts b/src/server/api/endpoints/charts/drive.ts index 25ede16102..2fd0139e8e 100644 --- a/src/server/api/endpoints/charts/drive.ts +++ b/src/server/api/endpoints/charts/drive.ts @@ -3,6 +3,8 @@ import define from '../../define'; import driveChart from '../../../../chart/drive'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': 'ドライブのチャートを取得します。' }, diff --git a/src/server/api/endpoints/charts/federation.ts b/src/server/api/endpoints/charts/federation.ts index fb5fa4426e..f7d12e5c33 100644 --- a/src/server/api/endpoints/charts/federation.ts +++ b/src/server/api/endpoints/charts/federation.ts @@ -3,6 +3,8 @@ import define from '../../define'; import federationChart from '../../../../chart/federation'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': 'フェデレーションのチャートを取得します。' }, diff --git a/src/server/api/endpoints/charts/hashtag.ts b/src/server/api/endpoints/charts/hashtag.ts index b2140068a5..3df2dd8500 100644 --- a/src/server/api/endpoints/charts/hashtag.ts +++ b/src/server/api/endpoints/charts/hashtag.ts @@ -3,6 +3,8 @@ import define from '../../define'; import hashtagChart from '../../../../chart/hashtag'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': 'ハッシュタグごとのチャートを取得します。' }, diff --git a/src/server/api/endpoints/charts/network.ts b/src/server/api/endpoints/charts/network.ts index 6d7468f950..af5b799a80 100644 --- a/src/server/api/endpoints/charts/network.ts +++ b/src/server/api/endpoints/charts/network.ts @@ -3,6 +3,8 @@ import define from '../../define'; import networkChart from '../../../../chart/network'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': 'ネットワークのチャートを取得します。' }, diff --git a/src/server/api/endpoints/charts/notes.ts b/src/server/api/endpoints/charts/notes.ts index c9889d1840..906cef1fa9 100644 --- a/src/server/api/endpoints/charts/notes.ts +++ b/src/server/api/endpoints/charts/notes.ts @@ -3,6 +3,8 @@ import define from '../../define'; import notesChart from '../../../../chart/notes'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': '投稿のチャートを取得します。' }, diff --git a/src/server/api/endpoints/charts/user/drive.ts b/src/server/api/endpoints/charts/user/drive.ts index 7e806c151b..1ef133c6bf 100644 --- a/src/server/api/endpoints/charts/user/drive.ts +++ b/src/server/api/endpoints/charts/user/drive.ts @@ -4,6 +4,8 @@ import perUserDriveChart from '../../../../../chart/per-user-drive'; import ID, { transform } from '../../../../../misc/cafy-id'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': 'ユーザーごとのドライブのチャートを取得します。' }, diff --git a/src/server/api/endpoints/charts/user/following.ts b/src/server/api/endpoints/charts/user/following.ts index ee20b3c39c..c8a64455e5 100644 --- a/src/server/api/endpoints/charts/user/following.ts +++ b/src/server/api/endpoints/charts/user/following.ts @@ -4,6 +4,8 @@ import perUserFollowingChart from '../../../../../chart/per-user-following'; import ID, { transform } from '../../../../../misc/cafy-id'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': 'ユーザーごとのフォロー/フォロワーのチャートを取得します。' }, diff --git a/src/server/api/endpoints/charts/user/notes.ts b/src/server/api/endpoints/charts/user/notes.ts index 1b6a312a01..f8a3c726ef 100644 --- a/src/server/api/endpoints/charts/user/notes.ts +++ b/src/server/api/endpoints/charts/user/notes.ts @@ -4,6 +4,8 @@ import perUserNotesChart from '../../../../../chart/per-user-notes'; import ID, { transform } from '../../../../../misc/cafy-id'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': 'ユーザーごとの投稿のチャートを取得します。' }, diff --git a/src/server/api/endpoints/charts/user/reactions.ts b/src/server/api/endpoints/charts/user/reactions.ts index 78bd22cc2f..f24c84593c 100644 --- a/src/server/api/endpoints/charts/user/reactions.ts +++ b/src/server/api/endpoints/charts/user/reactions.ts @@ -4,6 +4,8 @@ import perUserReactionsChart from '../../../../../chart/per-user-reactions'; import ID, { transform } from '../../../../../misc/cafy-id'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': 'ユーザーごとの被リアクション数のチャートを取得します。' }, diff --git a/src/server/api/endpoints/charts/users.ts b/src/server/api/endpoints/charts/users.ts index 733b7b8178..00c2871148 100644 --- a/src/server/api/endpoints/charts/users.ts +++ b/src/server/api/endpoints/charts/users.ts @@ -3,6 +3,8 @@ import define from '../../define'; import usersChart from '../../../../chart/users'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': 'ユーザーのチャートを取得します。' }, diff --git a/src/server/api/endpoints/drive/files.ts b/src/server/api/endpoints/drive/files.ts index 27f101562d..20955e0e4e 100644 --- a/src/server/api/endpoints/drive/files.ts +++ b/src/server/api/endpoints/drive/files.ts @@ -77,5 +77,5 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { sort: sort }); - res(await packMany(files)); + res(await packMany(files, { detail: false, self: true })); })); diff --git a/src/server/api/endpoints/drive/files/check_existence.ts b/src/server/api/endpoints/drive/files/check_existence.ts index d3ba4b386d..6e986d4170 100644 --- a/src/server/api/endpoints/drive/files/check_existence.ts +++ b/src/server/api/endpoints/drive/files/check_existence.ts @@ -32,6 +32,6 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { if (file === null) { res({ file: null }); } else { - res({ file: await pack(file) }); + res({ file: await pack(file, { self: true }) }); } })); diff --git a/src/server/api/endpoints/drive/files/create.ts b/src/server/api/endpoints/drive/files/create.ts index 53c62dd868..0660627f08 100644 --- a/src/server/api/endpoints/drive/files/create.ts +++ b/src/server/api/endpoints/drive/files/create.ts @@ -74,7 +74,7 @@ export default define(meta, (ps, user, app, file, cleanup) => new Promise(async cleanup(); - res(pack(driveFile)); + res(pack(driveFile, { self: true })); } catch (e) { console.error(e); diff --git a/src/server/api/endpoints/drive/files/delete.ts b/src/server/api/endpoints/drive/files/delete.ts index 7367c8fbb6..0c2799c708 100644 --- a/src/server/api/endpoints/drive/files/delete.ts +++ b/src/server/api/endpoints/drive/files/delete.ts @@ -32,14 +32,17 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { // Fetch file const file = await DriveFile .findOne({ - _id: ps.fileId, - 'metadata.userId': user._id + _id: ps.fileId }); if (file === null) { return rej('file-not-found'); } + if (!user.isAdmin && !user.isModerator && !file.metadata.userId.equals(user._id)) { + return rej('access denied'); + } + // Delete await del(file); diff --git a/src/server/api/endpoints/drive/files/find.ts b/src/server/api/endpoints/drive/files/find.ts index 8bc392fefe..25135e83a2 100644 --- a/src/server/api/endpoints/drive/files/find.ts +++ b/src/server/api/endpoints/drive/files/find.ts @@ -31,5 +31,5 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { 'metadata.folderId': ps.folderId }); - res(await Promise.all(files.map(file => pack(file)))); + res(await Promise.all(files.map(file => pack(file, { self: true })))); })); diff --git a/src/server/api/endpoints/drive/files/show.ts b/src/server/api/endpoints/drive/files/show.ts index 450a97065b..e6d85a5efb 100644 --- a/src/server/api/endpoints/drive/files/show.ts +++ b/src/server/api/endpoints/drive/files/show.ts @@ -1,6 +1,9 @@ -import $ from 'cafy'; import ID, { transform } from '../../../../../misc/cafy-id'; -import DriveFile, { pack } from '../../../../../models/drive-file'; +import $ from 'cafy'; +import * as mongo from 'mongodb'; +import ID, { transform } from '../../../../../misc/cafy-id'; +import DriveFile, { pack, IDriveFile } from '../../../../../models/drive-file'; import define from '../../../define'; +import config from '../../../../../config'; export const meta = { stability: 'stable', @@ -16,24 +19,62 @@ export const meta = { params: { fileId: { - validator: $.type(ID), + validator: $.type(ID).optional, transform: transform, desc: { 'ja-JP': '対象のファイルID', 'en-US': 'Target file ID' } + }, + + url: { + validator: $.str.optional, + desc: { + 'ja-JP': '対象のファイルのURL', + 'en-US': 'Target file URL' + } } } }; export default define(meta, (ps, user) => new Promise(async (res, rej) => { - // Fetch file - const file = await DriveFile - .findOne({ + let file: IDriveFile; + + if (ps.fileId) { + file = await DriveFile.findOne({ _id: ps.fileId, - 'metadata.userId': user._id, 'metadata.deletedAt': { $exists: false } }); + } else if (ps.url) { + const isInternalStorageUrl = ps.url.startsWith(config.drive_url); + if (isInternalStorageUrl) { + // Extract file ID from url + // e.g. + // http://misskey.local/files/foo?original=bar --> foo + const fileId = new mongo.ObjectID(ps.url.replace(config.drive_url, '').replace(/\?(.*)$/, '').replace(/\//g, '')); + file = await DriveFile.findOne({ + _id: fileId, + 'metadata.deletedAt': { $exists: false } + }); + } else { + file = await DriveFile.findOne({ + $or: [{ + 'metadata.url': ps.url + }, { + 'metadata.webpublicUrl': ps.url + }, { + 'metadata.thumbnailUrl': ps.url + }], + 'metadata.deletedAt': { $exists: false } + }); + } + } else { + return rej('fileId or url required'); + } + + if (!user.isAdmin && !user.isModerator && !file.metadata.userId.equals(user._id)) { + return rej('access denied'); + } if (file === null) { return rej('file-not-found'); @@ -41,7 +82,8 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { // Serialize const _file = await pack(file, { - detail: true + detail: true, + self: true }); res(_file); diff --git a/src/server/api/endpoints/drive/files/update.ts b/src/server/api/endpoints/drive/files/update.ts index 4efec3dc2a..a17ff2bf34 100644 --- a/src/server/api/endpoints/drive/files/update.ts +++ b/src/server/api/endpoints/drive/files/update.ts @@ -57,14 +57,17 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { // Fetch file const file = await DriveFile .findOne({ - _id: ps.fileId, - 'metadata.userId': user._id + _id: ps.fileId }); if (file === null) { return rej('file-not-found'); } + if (!user.isAdmin && !user.isModerator && !file.metadata.userId.equals(user._id)) { + return rej('access denied'); + } + if (ps.name) file.filename = ps.name; if (ps.isSensitive !== undefined) file.metadata.isSensitive = ps.isSensitive; @@ -100,18 +103,18 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { Note.find({ '_files._id': file._id }).then(notes => { - notes.forEach(note => { + for (const note of notes) { note._files[note._files.findIndex(f => f._id.equals(file._id))] = file; Note.update({ _id: note._id }, { $set: { _files: note._files } }); - }); + } }); // Serialize - const fileObj = await pack(file); + const fileObj = await pack(file, { self: true }); // Response res(fileObj); diff --git a/src/server/api/endpoints/drive/files/upload_from_url.ts b/src/server/api/endpoints/drive/files/upload_from_url.ts index a8faab1d73..fc386e1638 100644 --- a/src/server/api/endpoints/drive/files/upload_from_url.ts +++ b/src/server/api/endpoints/drive/files/upload_from_url.ts @@ -26,7 +26,7 @@ export const meta = { folderId: { validator: $.type(ID).optional.nullable, - default: null as any as any, + default: null as any, transform: transform }, @@ -50,5 +50,5 @@ export const meta = { }; export default define(meta, (ps, user) => new Promise(async (res, rej) => { - res(pack(await uploadFromUrl(ps.url, user, ps.folderId, null, ps.isSensitive, ps.force))); + res(pack(await uploadFromUrl(ps.url, user, ps.folderId, null, ps.isSensitive, ps.force), { self: true })); })); diff --git a/src/server/api/endpoints/drive/stream.ts b/src/server/api/endpoints/drive/stream.ts index 804ecf50d9..c8342c66b5 100644 --- a/src/server/api/endpoints/drive/stream.ts +++ b/src/server/api/endpoints/drive/stream.ts @@ -65,5 +65,5 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { sort: sort }); - res(await packMany(files)); + res(await packMany(files, { self: true })); })); diff --git a/src/server/api/endpoints/following/stalk.ts b/src/server/api/endpoints/following/stalk.ts deleted file mode 100644 index 3a58e2192f..0000000000 --- a/src/server/api/endpoints/following/stalk.ts +++ /dev/null @@ -1,50 +0,0 @@ -import $ from 'cafy'; import ID, { transform } from '../../../../misc/cafy-id'; -import Following from '../../../../models/following'; -import define from '../../define'; - -export const meta = { - desc: { - 'ja-JP': '指定したユーザーをストーキングします。', - 'en-US': 'Stalk a user.' - }, - - requireCredential: true, - - kind: 'following-write', - - params: { - userId: { - validator: $.type(ID), - transform: transform, - desc: { - 'ja-JP': '対象のユーザーのID', - 'en-US': 'Target user ID' - } - } - } -}; - -export default define(meta, (ps, user) => new Promise(async (res, rej) => { - const follower = user; - - // Fetch following - const following = await Following.findOne({ - followerId: follower._id, - followeeId: ps.userId - }); - - if (following === null) { - return rej('following not found'); - } - - // Stalk - await Following.update({ _id: following._id }, { - $set: { - stalk: true - } - }); - - res(); - - // TODO: イベント -})); diff --git a/src/server/api/endpoints/following/unstalk.ts b/src/server/api/endpoints/following/unstalk.ts deleted file mode 100644 index ad07ec38ba..0000000000 --- a/src/server/api/endpoints/following/unstalk.ts +++ /dev/null @@ -1,50 +0,0 @@ -import $ from 'cafy'; import ID, { transform } from '../../../../misc/cafy-id'; -import Following from '../../../../models/following'; -import define from '../../define'; - -export const meta = { - desc: { - 'ja-JP': '指定したユーザーのストーキングをやめます。', - 'en-US': 'Unstalk a user.' - }, - - requireCredential: true, - - kind: 'following-write', - - params: { - userId: { - validator: $.type(ID), - transform: transform, - desc: { - 'ja-JP': '対象のユーザーのID', - 'en-US': 'Target user ID' - } - } - } -}; - -export default define(meta, (ps, user) => new Promise(async (res, rej) => { - const follower = user; - - // Fetch following - const following = await Following.findOne({ - followerId: follower._id, - followeeId: ps.userId - }); - - if (following === null) { - return rej('following not found'); - } - - // Stalk - await Following.update({ _id: following._id }, { - $set: { - stalk: false - } - }); - - res(); - - // TODO: イベント -})); diff --git a/src/server/api/endpoints/games/reversi/games/show.ts b/src/server/api/endpoints/games/reversi/games/show.ts index c747202354..0882456102 100644 --- a/src/server/api/endpoints/games/reversi/games/show.ts +++ b/src/server/api/endpoints/games/reversi/games/show.ts @@ -25,9 +25,8 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { loopedBoard: game.settings.loopedBoard }); - game.logs.forEach(log => { + for (const log of game.logs) o.put(log.color, log.pos); - }); const packed = await pack(game, user); diff --git a/src/server/api/endpoints/games/reversi/match.ts b/src/server/api/endpoints/games/reversi/match.ts index 43b6fc8eed..a3cc523a8c 100644 --- a/src/server/api/endpoints/games/reversi/match.ts +++ b/src/server/api/endpoints/games/reversi/match.ts @@ -66,7 +66,7 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { }); if (other == 0) { - publishMainStream(user._id, 'reversi_no_invites'); + publishMainStream(user._id, 'reversiNoInvites'); } } else { // Fetch child diff --git a/src/server/api/endpoints/hashtags/trend.ts b/src/server/api/endpoints/hashtags/trend.ts index ed4c8e337f..a26dbf0941 100644 --- a/src/server/api/endpoints/hashtags/trend.ts +++ b/src/server/api/endpoints/hashtags/trend.ts @@ -58,10 +58,7 @@ export default define(meta, () => new Promise(async (res, rej) => { }> = []; // カウント - data.map(x => x._id).forEach(x => { - // ブラックリストに登録されているタグなら弾く - if (hidedTags.includes(x.tag)) return; - + for (const x of data.map(x => x._id).filter(x => !hidedTags.includes(x.tag))) { const i = tags.findIndex(tag => tag.name == x.tag); if (i != -1) { tags[i].count++; @@ -71,7 +68,7 @@ export default define(meta, () => new Promise(async (res, rej) => { count: 1 }); } - }); + } // 最低要求投稿者数を下回るならカットする const limitedTags = tags.filter(tag => tag.count >= requiredUsers); diff --git a/src/server/api/endpoints/i.ts b/src/server/api/endpoints/i.ts index aea47ad795..ab85b17d09 100644 --- a/src/server/api/endpoints/i.ts +++ b/src/server/api/endpoints/i.ts @@ -1,4 +1,4 @@ -import User, { pack } from '../../../models/user'; +import { pack } from '../../../models/user'; import define from '../define'; export const meta = { @@ -27,11 +27,4 @@ export default define(meta, (ps, user, app) => new Promise(async (res, rej) => { includeHasUnreadNotes: true, includeSecrets: isSecure })); - - // Update lastUsedAt - User.update({ _id: user._id }, { - $set: { - lastUsedAt: new Date() - } - }); })); diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts index b1ddd40d13..028d67a018 100644 --- a/src/server/api/endpoints/i/notifications.ts +++ b/src/server/api/endpoints/i/notifications.ts @@ -40,6 +40,16 @@ export const meta = { markAsRead: { validator: $.bool.optional, default: true + }, + + includeTypes: { + validator: $.arr($.str.or(['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'poll_vote', 'receiveFollowRequest'])).optional, + default: [] as string[] + }, + + excludeTypes: { + validator: $.arr($.str.or(['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'poll_vote', 'receiveFollowRequest'])).optional, + default: [] as string[] } } }; @@ -89,6 +99,16 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { }; } + if (ps.includeTypes.length > 0) { + query.type = { + $in: ps.includeTypes + }; + } else if (ps.excludeTypes.length > 0) { + query.type = { + $nin: ps.excludeTypes + }; + } + const notifications = await Notification .find(query, { limit: ps.limit, diff --git a/src/server/api/endpoints/i/read_all_messaging_messages.ts b/src/server/api/endpoints/i/read_all_messaging_messages.ts new file mode 100644 index 0000000000..a1fe82c4cb --- /dev/null +++ b/src/server/api/endpoints/i/read_all_messaging_messages.ts @@ -0,0 +1,42 @@ +import User from '../../../../models/user'; +import { publishMainStream } from '../../../../stream'; +import Message from '../../../../models/messaging-message'; +import define from '../../define'; + +export const meta = { + desc: { + 'ja-JP': 'トークメッセージをすべて既読にします。', + 'en-US': 'Mark all talk messages as read.' + }, + + requireCredential: true, + + kind: 'account-write', + + params: { + } +}; + +export default define(meta, (ps, user) => new Promise(async (res, rej) => { + // Update documents + await Message.update({ + recipientId: user._id, + isRead: false + }, { + $set: { + isRead: true + } + }, { + multi: true + }); + + User.update({ _id: user._id }, { + $set: { + hasUnreadMessagingMessage: false + } + }); + + publishMainStream(user._id, 'readAllMessagingMessages'); + + res(); +})); diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts index 4952b2f010..ec6aaa04da 100644 --- a/src/server/api/endpoints/i/update.ts +++ b/src/server/api/endpoints/i/update.ts @@ -6,6 +6,9 @@ import acceptAllFollowRequests from '../../../../services/following/requests/acc import { publishToFollowers } from '../../../../services/i/update'; import define from '../../define'; import getDriveFileUrl from '../../../../misc/get-drive-file-url'; +import parse from '../../../../mfm/parse'; +import extractEmojis from '../../../../misc/extract-emojis'; +const langmap = require('langmap'); export const meta = { desc: { @@ -32,6 +35,13 @@ export const meta = { } }, + lang: { + validator: $.str.optional.nullable.or(Object.keys(langmap)), + desc: { + 'ja-JP': '言語' + } + }, + location: { validator: $.str.optional.nullable.pipe(isValidLocation), desc: { @@ -84,6 +94,13 @@ export const meta = { } }, + autoAcceptFollowed: { + validator: $.bool.optional, + desc: { + 'ja-JP': 'フォローしているユーザーからのフォローリクエストを自動承認するか' + } + }, + isBot: { validator: $.bool.optional, desc: { @@ -121,6 +138,7 @@ export default define(meta, (ps, user, app) => new Promise(async (res, rej) => { if (ps.name !== undefined) updates.name = ps.name; if (ps.description !== undefined) updates.description = ps.description; + if (ps.lang !== undefined) updates.lang = ps.lang; if (ps.location !== undefined) updates['profile.location'] = ps.location; if (ps.birthday !== undefined) updates['profile.birthday'] = ps.birthday; if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; @@ -129,6 +147,7 @@ export default define(meta, (ps, user, app) => new Promise(async (res, rej) => { if (typeof ps.isLocked == 'boolean') updates.isLocked = ps.isLocked; if (typeof ps.isBot == 'boolean') updates.isBot = ps.isBot; if (typeof ps.carefulBot == 'boolean') updates.carefulBot = ps.carefulBot; + if (typeof ps.autoAcceptFollowed == 'boolean') updates.autoAcceptFollowed = ps.autoAcceptFollowed; if (typeof ps.isCat == 'boolean') updates.isCat = ps.isCat; if (typeof ps.autoWatch == 'boolean') updates['settings.autoWatch'] = ps.autoWatch; if (typeof ps.alwaysMarkNsfw == 'boolean') updates['settings.alwaysMarkNsfw'] = ps.alwaysMarkNsfw; @@ -182,6 +201,24 @@ export default define(meta, (ps, user, app) => new Promise(async (res, rej) => { } } + //#region emojis + if (updates.name != null || updates.description != null) { + let emojis = [] as string[]; + + if (updates.name != null) { + const tokens = parse(updates.name, true); + emojis = emojis.concat(extractEmojis(tokens)); + } + + if (updates.description != null) { + const tokens = parse(updates.description); + emojis = emojis.concat(extractEmojis(tokens)); + } + + updates.emojis = emojis; + } + //#endregion + await User.update(user._id, { $set: updates }); diff --git a/src/server/api/endpoints/i/update_email.ts b/src/server/api/endpoints/i/update_email.ts new file mode 100644 index 0000000000..e08d1fba05 --- /dev/null +++ b/src/server/api/endpoints/i/update_email.ts @@ -0,0 +1,100 @@ +import $ from 'cafy'; +import User, { pack } from '../../../../models/user'; +import { publishMainStream } from '../../../../stream'; +import define from '../../define'; +import * as nodemailer from 'nodemailer'; +import fetchMeta from '../../../../misc/fetch-meta'; +import rndstr from 'rndstr'; +import config from '../../../../config'; +const ms = require('ms'); +import * as bcrypt from 'bcryptjs'; + +export const meta = { + requireCredential: true, + + secure: true, + + limit: { + duration: ms('1hour'), + max: 3 + }, + + params: { + password: { + validator: $.str + }, + + email: { + validator: $.str.optional.nullable + }, + } +}; + +export default define(meta, (ps, user) => new Promise(async (res, rej) => { + // Compare password + const same = await bcrypt.compare(ps.password, user.password); + + if (!same) { + return rej('incorrect password'); + } + + await User.update(user._id, { + $set: { + email: ps.email, + emailVerified: false, + emailVerifyCode: null + } + }); + + // Serialize + const iObj = await pack(user._id, user, { + detail: true, + includeSecrets: true + }); + + // Send response + res(iObj); + + // Publish meUpdated event + publishMainStream(user._id, 'meUpdated', iObj); + + if (ps.email != null) { + const code = rndstr('a-z0-9', 16); + + await User.update(user._id, { + $set: { + emailVerifyCode: code + } + }); + + const meta = await fetchMeta(); + + const enableAuth = meta.smtpUser != null && meta.smtpUser !== ''; + + const transporter = nodemailer.createTransport({ + host: meta.smtpHost, + port: meta.smtpPort, + secure: meta.smtpSecure, + ignoreTLS: !enableAuth, + auth: enableAuth ? { + user: meta.smtpUser, + pass: meta.smtpPass + } : undefined + }); + + const link = `${config.url}/verify-email/${code}`; + + transporter.sendMail({ + from: meta.email, + to: ps.email, + subject: meta.name, + text: `To verify email, please click this link: ${link}` + }, (error, info) => { + if (error) { + return console.error(error); + } + + console.log('Message sent: %s', info.messageId); + }); + } +})); diff --git a/src/server/api/endpoints/i/update_widget.ts b/src/server/api/endpoints/i/update_widget.ts index 90fe8fbe23..da96ec6fc1 100644 --- a/src/server/api/endpoints/i/update_widget.ts +++ b/src/server/api/endpoints/i/update_widget.ts @@ -59,11 +59,11 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { //#region Deck if (widget == null && user.clientSettings.deck && user.clientSettings.deck.columns) { const deck = user.clientSettings.deck; - deck.columns.filter((c: any) => c.type == 'widgets').forEach((c: any) => { - c.widgets.forEach((w: any) => { - if (w.id == ps.id) widget = w; - }); - }); + for (const c of deck.columns.filter((c: any) => c.type == 'widgets')) { + for (const w of c.widgets.filter((w: any) => w.id == ps.id)) { + widget = w; + } + } if (widget) { widget.data = ps.data; diff --git a/src/server/api/endpoints/messaging/history.ts b/src/server/api/endpoints/messaging/history.ts index c026e5dd91..78abea269a 100644 --- a/src/server/api/endpoints/messaging/history.ts +++ b/src/server/api/endpoints/messaging/history.ts @@ -1,7 +1,6 @@ import $ from 'cafy'; -import History from '../../../../models/messaging-history'; import Mute from '../../../../models/mute'; -import { pack } from '../../../../models/messaging-message'; +import Message, { pack, IMessagingMessage } from '../../../../models/messaging-message'; import define from '../../define'; export const meta = { @@ -28,19 +27,36 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { deletedAt: { $exists: false } }); - // Get history - const history = await History - .find({ - userId: user._id, - partnerId: { - $nin: mute.map(m => m.muteeId) - } + const history: IMessagingMessage[] = []; + + for (let i = 0; i < ps.limit; i++) { + const found = history.map(m => m.userId.equals(user._id) ? m.recipientId : m.userId); + + const message = await Message.findOne({ + $or: [{ + userId: user._id + }, { + recipientId: user._id + }], + $and: [{ + userId: { $nin: found }, + recipientId: { $nin: found } + }, { + userId: { $nin: mute.map(m => m.muteeId) }, + recipientId: { $nin: mute.map(m => m.muteeId) } + }] }, { - limit: ps.limit, sort: { - updatedAt: -1 + createdAt: -1 } }); - res(await Promise.all(history.map(h => pack(h.messageId, user)))); + if (message) { + history.push(message); + } else { + break; + } + } + + res(await Promise.all(history.map(h => pack(h._id, user)))); })); diff --git a/src/server/api/endpoints/messaging/messages/create.ts b/src/server/api/endpoints/messaging/messages/create.ts index f8901449fe..3630dc0d54 100644 --- a/src/server/api/endpoints/messaging/messages/create.ts +++ b/src/server/api/endpoints/messaging/messages/create.ts @@ -1,7 +1,6 @@ import $ from 'cafy'; import ID, { transform } from '../../../../../misc/cafy-id'; import Message from '../../../../../models/messaging-message'; import { isValidText } from '../../../../../models/messaging-message'; -import History from '../../../../../models/messaging-history'; import User from '../../../../../models/user'; import Mute from '../../../../../models/mute'; import DriveFile from '../../../../../models/drive-file'; @@ -114,6 +113,7 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { // 2秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する setTimeout(async () => { const freshMessage = await Message.findOne({ _id: message._id }, { isRead: true }); + if (freshMessage == null) return; // メッセージが削除されている場合もある if (!freshMessage.isRead) { //#region ただしミュートされているなら発行しない const mute = await Mute.find({ @@ -130,30 +130,4 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { pushSw(message.recipientId, 'unreadMessagingMessage', messageObj); } }, 2000); - - // 履歴作成(自分) - History.update({ - userId: user._id, - partnerId: recipient._id - }, { - updatedAt: new Date(), - userId: user._id, - partnerId: recipient._id, - messageId: message._id - }, { - upsert: true - }); - - // 履歴作成(相手) - History.update({ - userId: recipient._id, - partnerId: user._id - }, { - updatedAt: new Date(), - userId: recipient._id, - partnerId: user._id, - messageId: message._id - }, { - upsert: true - }); })); diff --git a/src/server/api/endpoints/messaging/messages/delete.ts b/src/server/api/endpoints/messaging/messages/delete.ts new file mode 100644 index 0000000000..26161896ce --- /dev/null +++ b/src/server/api/endpoints/messaging/messages/delete.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import ID, { transform } from '../../../../../misc/cafy-id'; +import Message from '../../../../../models/messaging-message'; +import define from '../../../define'; +import { publishMessagingStream } from '../../../../../stream'; +const ms = require('ms'); + +export const meta = { + stability: 'stable', + + desc: { + 'ja-JP': '指定したメッセージを削除します。', + 'en-US': 'Delete a message.' + }, + + requireCredential: true, + + kind: 'messaging-write', + + limit: { + duration: ms('1hour'), + max: 300, + minInterval: ms('1sec') + }, + + params: { + messageId: { + validator: $.type(ID), + transform: transform, + desc: { + 'ja-JP': '対象のメッセージのID', + 'en-US': 'Target message ID.' + } + } + } +}; + +export default define(meta, (ps, user) => new Promise(async (res, rej) => { + const message = await Message.findOne({ + _id: ps.messageId, + userId: user._id + }); + + if (message === null) { + return rej('message not found'); + } + + await Message.remove({ _id: message._id }); + + publishMessagingStream(message.userId, message.recipientId, 'deleted', message._id); + publishMessagingStream(message.recipientId, message.userId, 'deleted', message._id); + + res(); +})); diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts index 9846e95959..3b2a49dbb0 100644 --- a/src/server/api/endpoints/meta.ts +++ b/src/server/api/endpoints/meta.ts @@ -42,6 +42,7 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { clientVersion: client.version, name: instance.name, + uri: config.url, description: instance.description, langs: instance.langs, @@ -58,16 +59,19 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { broadcasts: instance.broadcasts || [], disableRegistration: instance.disableRegistration, disableLocalTimeline: instance.disableLocalTimeline, + disableGlobalTimeline: instance.disableGlobalTimeline, driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, cacheRemoteFiles: instance.cacheRemoteFiles, enableRecaptcha: instance.enableRecaptcha, recaptchaSiteKey: instance.recaptchaSiteKey, - swPublickey: config.sw ? config.sw.public_key : null, + swPublickey: instance.swPublicKey, + mascotImageUrl: instance.mascotImageUrl, bannerUrl: instance.bannerUrl, + errorImageUrl: instance.errorImageUrl, maxNoteTextLength: instance.maxNoteTextLength, - emojis: emojis, + enableEmail: instance.enableEmail, enableTwitterIntegration: instance.enableTwitterIntegration, enableGithubIntegration: instance.enableGithubIntegration, @@ -78,14 +82,19 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { response.features = { registration: !instance.disableRegistration, localTimeLine: !instance.disableLocalTimeline, + globalTimeLine: !instance.disableGlobalTimeline, elasticsearch: config.elasticsearch ? true : false, recaptcha: instance.enableRecaptcha, objectStorage: config.drive && config.drive.storage === 'minio', twitter: instance.enableTwitterIntegration, github: instance.enableGithubIntegration, discord: instance.enableDiscordIntegration, - serviceWorker: config.sw ? true : false, - userRecommendation: config.user_recommendation ? config.user_recommendation : {} + serviceWorker: instance.enableServiceWorker, + userRecommendation: { + external: instance.enableExternalUserRecommendation, + engine: instance.externalUserRecommendationEngine, + timeout: instance.externalUserRecommendationTimeout + } }; } @@ -99,6 +108,17 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { response.githubClientSecret = instance.githubClientSecret; response.discordClientId = instance.discordClientId; response.discordClientSecret = instance.discordClientSecret; + response.enableExternalUserRecommendation = instance.enableExternalUserRecommendation; + response.externalUserRecommendationEngine = instance.externalUserRecommendationEngine; + response.externalUserRecommendationTimeout = instance.externalUserRecommendationTimeout; + response.summalyProxy = instance.summalyProxy; + response.email = instance.email; + response.smtpSecure = instance.smtpSecure; + response.smtpHost = instance.smtpHost; + response.smtpPort = instance.smtpPort; + response.smtpUser = instance.smtpUser; + response.smtpPass = instance.smtpPass; + response.swPrivateKey = instance.swPrivateKey; } res(response); diff --git a/src/server/api/endpoints/notes.ts b/src/server/api/endpoints/notes.ts index 5edc56165e..b489197076 100644 --- a/src/server/api/endpoints/notes.ts +++ b/src/server/api/endpoints/notes.ts @@ -78,6 +78,7 @@ export default define(meta, (ps) => new Promise(async (res, rej) => { _id: -1 }; const query = { + deletedAt: null, visibility: 'public' } as any; if (ps.sinceId) { @@ -105,9 +106,7 @@ export default define(meta, (ps) => new Promise(async (res, rej) => { const withFiles = ps.withFiles != undefined ? ps.withFiles : ps.media; - if (withFiles) { - query.fileIds = withFiles ? { $exists: true, $ne: null } : []; - } + if (withFiles) query.fileIds = { $exists: true, $ne: null }; if (ps.poll != undefined) { query.poll = ps.poll ? { $exists: true, $ne: null } : null; diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts index 4f8d6a4f4f..ec84d64975 100644 --- a/src/server/api/endpoints/notes/create.ts +++ b/src/server/api/endpoints/notes/create.ts @@ -42,7 +42,7 @@ export const meta = { }, visibleUserIds: { - validator: $.arr($.type(ID)).optional.unique().min(1), + validator: $.arr($.type(ID)).optional.unique().min(0), transform: transformMany, desc: { 'ja-JP': '(投稿の公開範囲が specified の場合)投稿を閲覧できるユーザー' @@ -82,6 +82,30 @@ export const meta = { } }, + noExtractMentions: { + validator: $.bool.optional, + default: false, + desc: { + 'ja-JP': '本文からメンションを展開しないか否か。' + } + }, + + noExtractHashtags: { + validator: $.bool.optional, + default: false, + desc: { + 'ja-JP': '本文からハッシュタグを展開しないか否か。' + } + }, + + noExtractEmojis: { + validator: $.bool.optional, + default: false, + desc: { + 'ja-JP': '本文からカスタム絵文字を展開しないか否か。' + } + }, + geo: { validator: $.obj({ coordinates: $.arr().length(2) @@ -219,10 +243,15 @@ export default define(meta, (ps, user, app) => new Promise(async (res, rej) => { } // テキストが無いかつ添付ファイルが無いかつRenoteも無いかつ投票も無かったらエラー - if ((ps.text == null) && files === null && renote === null && ps.poll == null) { + if (!(ps.text || files.length || renote || ps.poll)) { return rej('text, fileIds, renoteId or poll is required'); } + // 後方互換性のため + if (ps.visibility == 'private') { + ps.visibility = 'specified'; + } + // 投稿を作成 create(user, { createdAt: new Date(), @@ -237,6 +266,9 @@ export default define(meta, (ps, user, app) => new Promise(async (res, rej) => { localOnly: ps.localOnly, visibility: ps.visibility, visibleUsers, + apMentions: ps.noExtractMentions ? [] : undefined, + apHashtags: ps.noExtractHashtags ? [] : undefined, + apEmojis: ps.noExtractEmojis ? [] : undefined, geo: ps.geo }) .then(note => pack(note, user)) diff --git a/src/server/api/endpoints/notes/delete.ts b/src/server/api/endpoints/notes/delete.ts index aa11f7bf19..1923aed9ba 100644 --- a/src/server/api/endpoints/notes/delete.ts +++ b/src/server/api/endpoints/notes/delete.ts @@ -3,6 +3,7 @@ import Note from '../../../../models/note'; import deleteNote from '../../../../services/note/delete'; import User from '../../../../models/user'; import define from '../../define'; +const ms = require('ms'); export const meta = { stability: 'stable', @@ -16,6 +17,12 @@ export const meta = { kind: 'note-write', + limit: { + duration: ms('1hour'), + max: 300, + minInterval: ms('1sec') + }, + params: { noteId: { validator: $.type(ID), diff --git a/src/server/api/endpoints/notes/global-timeline.ts b/src/server/api/endpoints/notes/global-timeline.ts index b7f765f27d..f0d052ff98 100644 --- a/src/server/api/endpoints/notes/global-timeline.ts +++ b/src/server/api/endpoints/notes/global-timeline.ts @@ -4,6 +4,7 @@ import Mute from '../../../../models/mute'; import { packMany } from '../../../../models/note'; import define from '../../define'; import { countIf } from '../../../../prelude/array'; +import fetchMeta from '../../../../misc/fetch-meta'; export const meta = { desc: { @@ -51,6 +52,13 @@ export const meta = { }; export default define(meta, (ps, user) => new Promise(async (res, rej) => { + const meta = await fetchMeta(); + if (meta.disableGlobalTimeline) { + if (user == null || (!user.isAdmin && !user.isModerator)) { + return rej('global timeline disabled'); + } + } + // Check if only one of sinceId, untilId, sinceDate, untilDate specified if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) { return rej('only one of sinceId, untilId, sinceDate, untilDate can be specified'); diff --git a/src/server/api/endpoints/notes/hybrid-timeline.ts b/src/server/api/endpoints/notes/hybrid-timeline.ts index 4af182cb5c..8318479d13 100644 --- a/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -5,6 +5,8 @@ import { getFriends } from '../../common/get-friends'; import { packMany } from '../../../../models/note'; import define from '../../define'; import { countIf } from '../../../../prelude/array'; +import fetchMeta from '../../../../misc/fetch-meta'; +import activeUsersChart from '../../../../chart/active-users'; export const meta = { desc: { @@ -91,6 +93,11 @@ export const meta = { }; export default define(meta, (ps, user) => new Promise(async (res, rej) => { + const meta = await fetchMeta(); + if (meta.disableLocalTimeline && !user.isAdmin && !user.isModerator) { + return rej('local timeline disabled'); + } + // Check if only one of sinceId, untilId, sinceDate, untilDate specified if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) { return rej('only one of sinceId, untilId, sinceDate, untilDate can be specified'); @@ -112,12 +119,10 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { _id: -1 }; - const followQuery = followings.map(f => f.stalk ? { - userId: f.id - } : { + const followQuery = followings.map(f => ({ userId: f.id, - // ストーキングしてないならリプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める) + /*// リプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める) $or: [{ // リプライでない replyId: null @@ -132,20 +137,40 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { }, { // または // 自分(フォロワー)が送信したリプライ userId: user._id - }] - }); + }]*/ + })); + + const visibleQuery = user == null ? [{ + visibility: { $in: ['public', 'home'] } + }] : [{ + visibility: { $in: ['public', 'home', 'followers'] } + }, { + // myself (for specified/private) + userId: user._id + }, { + // to me (for specified) + visibleUserIds: { $in: [ user._id ] } + }]; const query = { $and: [{ deletedAt: null, $or: [{ - // フォローしている人の投稿 - $or: followQuery + $and: [{ + // フォローしている人の投稿 + $or: followQuery + }, { + // visible for me + $or: visibleQuery + }] }, { // public only visibility: 'public', + // リプライでない + //replyId: null, + // local '_user.host': null }], @@ -249,4 +274,6 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { }); res(await packMany(timeline, user)); + + activeUsersChart.update(user); })); diff --git a/src/server/api/endpoints/notes/local-timeline.ts b/src/server/api/endpoints/notes/local-timeline.ts index 4446f52cdc..d5b622d1f0 100644 --- a/src/server/api/endpoints/notes/local-timeline.ts +++ b/src/server/api/endpoints/notes/local-timeline.ts @@ -4,6 +4,8 @@ import Mute from '../../../../models/mute'; import { packMany } from '../../../../models/note'; import define from '../../define'; import { countIf } from '../../../../prelude/array'; +import fetchMeta from '../../../../misc/fetch-meta'; +import activeUsersChart from '../../../../chart/active-users'; export const meta = { desc: { @@ -66,6 +68,13 @@ export const meta = { }; export default define(meta, (ps, user) => new Promise(async (res, rej) => { + const meta = await fetchMeta(); + if (meta.disableLocalTimeline) { + if (user == null || (!user.isAdmin && !user.isModerator)) { + return rej('local timeline disabled'); + } + } + // Check if only one of sinceId, untilId, sinceDate, untilDate specified if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) { return rej('only one of sinceId, untilId, sinceDate, untilDate can be specified'); @@ -87,6 +96,9 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { // public only visibility: 'public', + // リプライでない + //replyId: null, + // local '_user.host': null } as any; @@ -153,4 +165,8 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { }); res(await packMany(timeline, user)); + + if (user) { + activeUsersChart.update(user); + } })); diff --git a/src/server/api/endpoints/notes/mentions.ts b/src/server/api/endpoints/notes/mentions.ts index 718f5e4403..4c7c397c77 100644 --- a/src/server/api/endpoints/notes/mentions.ts +++ b/src/server/api/endpoints/notes/mentions.ts @@ -4,6 +4,7 @@ import { getFriendIds } from '../../common/get-friends'; import { packMany } from '../../../../models/note'; import define from '../../define'; import read from '../../../../services/note/read'; +import Mute from '../../../../models/mute'; export const meta = { desc: { @@ -56,6 +57,25 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { }] } as any; + // ミュートしているユーザーを取得 + const mutedUserIds = (await Mute.find({ + muterId: user._id + })).map(m => m.muteeId); + + if (mutedUserIds && mutedUserIds.length > 0) { + query.userId = { + $nin: mutedUserIds + }; + + query['_reply.userId'] = { + $nin: mutedUserIds + }; + + query['_renote.userId'] = { + $nin: mutedUserIds + }; + } + const sort = { _id: -1 }; @@ -91,5 +111,7 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { res(await packMany(mentions, user)); - mentions.forEach(note => read(user._id, note._id)); + for (const note of mentions) { + read(user._id, note._id); + } })); diff --git a/src/server/api/endpoints/notes/polls/recommendation.ts b/src/server/api/endpoints/notes/polls/recommendation.ts index cfcba788ec..2bc1a4f913 100644 --- a/src/server/api/endpoints/notes/polls/recommendation.ts +++ b/src/server/api/endpoints/notes/polls/recommendation.ts @@ -39,6 +39,7 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { const notes = await Note .find({ + '_user.host': null, _id: { $nin: nin }, diff --git a/src/server/api/endpoints/notes/polls/vote.ts b/src/server/api/endpoints/notes/polls/vote.ts index dc012152c6..f99fb099c7 100644 --- a/src/server/api/endpoints/notes/polls/vote.ts +++ b/src/server/api/endpoints/notes/polls/vote.ts @@ -6,6 +6,8 @@ import watch from '../../../../../services/note/watch'; import { publishNoteStream } from '../../../../../stream'; import notify from '../../../../../notify'; import define from '../../../define'; +import createNote from '../../../../../services/note/create'; +import User from '../../../../../models/user'; export const meta = { desc: { @@ -102,16 +104,31 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { } }) .then(watchers => { - watchers.forEach(watcher => { + for (const watcher of watchers) { notify(watcher.userId, user._id, 'poll_vote', { noteId: note._id, choice: ps.choice }); - }); + } }); // この投稿をWatchする if (user.settings.autoWatch !== false) { watch(user._id, note); } + + // リモート投票の場合リプライ送信 + if (note._user.host != null) { + const pollOwner = await User.findOne({ + _id: note.userId + }); + + createNote(user, { + createdAt: new Date(), + text: ps.choice.toString(), + reply: note, + visibility: 'specified', + visibleUsers: [ pollOwner ], + }); + } })); diff --git a/src/server/api/endpoints/notes/reactions/create.ts b/src/server/api/endpoints/notes/reactions/create.ts index c9f70d9658..4037e89943 100644 --- a/src/server/api/endpoints/notes/reactions/create.ts +++ b/src/server/api/endpoints/notes/reactions/create.ts @@ -1,8 +1,10 @@ +import * as mongo from 'mongodb'; import $ from 'cafy'; import ID, { transform } from '../../../../../misc/cafy-id'; -import Note from '../../../../../models/note'; -import create from '../../../../../services/note/reaction/create'; +import createReaction from '../../../../../services/note/reaction/create'; import { validateReaction } from '../../../../../models/note-reaction'; import define from '../../../define'; +import { IUser } from '../../../../../models/user'; +import { getValiedNote } from '../../../common/getters'; export const meta = { stability: 'stable', @@ -34,25 +36,12 @@ export const meta = { } }; -export default define(meta, (ps, user) => new Promise(async (res, rej) => { - // Fetch reactee - const note = await Note.findOne({ - _id: ps.noteId - }); - - if (note === null) { - return rej('note not found'); - } - - if (note.deletedAt != null) { - return rej('this not is already deleted'); - } - - try { - await create(user, note, ps.reaction); - } catch (e) { - rej(e); - } - - res(); +export default define(meta, (ps, user) => new Promise((res, rej) => { + createReactionById(user, ps.noteId, ps.reaction) + .then(r => res(r)).catch(e => rej(e)); })); + +async function createReactionById(user: IUser, noteId: mongo.ObjectID, reaction: string) { + const note = await getValiedNote(noteId); + await createReaction(user, note, reaction); +} diff --git a/src/server/api/endpoints/notes/reactions/delete.ts b/src/server/api/endpoints/notes/reactions/delete.ts index 367538bed5..9ff4edb7f5 100644 --- a/src/server/api/endpoints/notes/reactions/delete.ts +++ b/src/server/api/endpoints/notes/reactions/delete.ts @@ -1,7 +1,10 @@ +import * as mongo from 'mongodb'; import $ from 'cafy'; import ID, { transform } from '../../../../../misc/cafy-id'; -import Reaction from '../../../../../models/note-reaction'; -import Note from '../../../../../models/note'; import define from '../../../define'; +const ms = require('ms'); +import deleteReaction from '../../../../../services/note/reaction/delete'; +import { IUser } from '../../../../../models/user'; +import { getValiedNote } from '../../../common/getters'; export const meta = { desc: { @@ -13,6 +16,12 @@ export const meta = { kind: 'reaction-write', + limit: { + duration: ms('1hour'), + max: 5, + minInterval: ms('3sec') + }, + params: { noteId: { validator: $.type(ID), @@ -25,39 +34,12 @@ export const meta = { } }; -export default define(meta, (ps, user) => new Promise(async (res, rej) => { - // Fetch unreactee - const note = await Note.findOne({ - _id: ps.noteId - }); - - if (note === null) { - return rej('note not found'); - } - - // if already unreacted - const exist = await Reaction.findOne({ - noteId: note._id, - userId: user._id, - deletedAt: { $exists: false } - }); - - if (exist === null) { - return rej('never reacted'); - } - - // Delete reaction - await Reaction.remove({ - _id: exist._id - }); - - res(); - - const dec: any = {}; - dec[`reactionCounts.${exist.reaction}`] = -1; - - // Decrement reactions count - Note.update({ _id: note._id }, { - $inc: dec - }); +export default define(meta, (ps, user) => new Promise((res, rej) => { + deleteReactionById(user, ps.noteId) + .then(r => res(r)).catch(e => rej(e)); })); + +async function deleteReactionById(user: IUser, noteId: mongo.ObjectID) { + const note = await getValiedNote(noteId); + await deleteReaction(user, note); +} diff --git a/src/server/api/endpoints/notes/replies.ts b/src/server/api/endpoints/notes/replies.ts index 6046b9b310..6c2b690ab2 100644 --- a/src/server/api/endpoints/notes/replies.ts +++ b/src/server/api/endpoints/notes/replies.ts @@ -33,16 +33,13 @@ export const meta = { }; export default define(meta, (ps, user) => new Promise(async (res, rej) => { - // Lookup note - const note = await Note.findOne({ - _id: ps.noteId - }); - if (note === null) { - return rej('note not found'); - } - - const ids = (note._replyIds || []).slice(ps.offset, ps.offset + ps.limit); + const notes = await Note.find({ + replyId: ps.noteId + }, { + limit: ps.limit, + skip: ps.offset + }); - res(await packMany(ids, user)); + res(await packMany(notes, user)); })); diff --git a/src/server/api/endpoints/notes/search_by_tag.ts b/src/server/api/endpoints/notes/search_by_tag.ts index fcc33d14f3..db2f716497 100644 --- a/src/server/api/endpoints/notes/search_by_tag.ts +++ b/src/server/api/endpoints/notes/search_by_tag.ts @@ -103,6 +103,18 @@ export const meta = { }; export default define(meta, (ps, me) => new Promise(async (res, rej) => { + const visibleQuery = me == null ? [{ + visibility: { $in: [ 'public', 'home' ] } + }] : [{ + visibility: { $in: [ 'public', 'home' ] } + }, { + // myself (for specified/private) + userId: me._id + }, { + // to me (for specified) + visibleUserIds: { $in: [ me._id ] } + }]; + const q: any = { $and: [ps.tag ? { tagsLower: ps.tag.toLowerCase() @@ -113,7 +125,8 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { })) })) }], - deletedAt: { $exists: false } + deletedAt: { $exists: false }, + $or: visibleQuery }; const push = (x: any) => q.$and.push(x); diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts index 3c970c03a1..94f4fb72d3 100644 --- a/src/server/api/endpoints/notes/timeline.ts +++ b/src/server/api/endpoints/notes/timeline.ts @@ -5,6 +5,7 @@ import { getFriends } from '../../common/get-friends'; import { packMany } from '../../../../models/note'; import define from '../../define'; import { countIf } from '../../../../prelude/array'; +import activeUsersChart from '../../../../chart/active-users'; export const meta = { desc: { @@ -116,12 +117,10 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { _id: -1 }; - const followQuery = followings.map(f => f.stalk ? { - userId: f.id - } : { + const followQuery = followings.map(f => ({ userId: f.id, - // ストーキングしてないならリプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める) + /*// リプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める) $or: [{ // リプライでない replyId: null @@ -136,15 +135,32 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { }, { // または // 自分(フォロワー)が送信したリプライ userId: user._id - }] - }); + }]*/ + })); + + const visibleQuery = user == null ? [{ + visibility: { $in: [ 'public', 'home' ] } + }] : [{ + visibility: { $in: [ 'public', 'home' ] } + }, { + // myself (for specified/private) + userId: user._id + }, { + // to me (for specified) + visibleUserIds: { $in: [ user._id ] } + }]; const query = { $and: [{ deletedAt: null, - // フォローしている人の投稿 - $or: followQuery, + $and: [{ + // フォローしている人の投稿 + $or: followQuery + }, { + // visible for me + $or: visibleQuery + }], // mute userId: { @@ -249,4 +265,6 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { // Serialize res(await packMany(timeline, user)); + + activeUsersChart.update(user); })); diff --git a/src/server/api/endpoints/notes/user-list-timeline.ts b/src/server/api/endpoints/notes/user-list-timeline.ts index 156ffbbc32..861bbd9b29 100644 --- a/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/src/server/api/endpoints/notes/user-list-timeline.ts @@ -4,6 +4,7 @@ import Mute from '../../../../models/mute'; import { packMany } from '../../../../models/note'; import UserList from '../../../../models/user-list'; import define from '../../define'; +import { getFriends } from '../../common/get-friends'; export const meta = { desc: { @@ -101,7 +102,7 @@ export const meta = { }; export default define(meta, (ps, user) => new Promise(async (res, rej) => { - const [list, mutedUserIds] = await Promise.all([ + const [list, followings, mutedUserIds] = await Promise.all([ // リストを取得 // Fetch the list UserList.findOne({ @@ -109,6 +110,10 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { userId: user._id }), + // フォローを取得 + // Fetch following + getFriends(user._id, true, false), + // ミュートしているユーザーを取得 Mute.find({ muterId: user._id @@ -128,7 +133,7 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { const listQuery = list.userIds.map(u => ({ userId: u, - // リプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める) + /*// リプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める) $or: [{ // リプライでない replyId: null @@ -143,15 +148,33 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { }, { // または // 自分(フォロワー)が送信したリプライ userId: user._id - }] + }]*/ })); + const visibleQuery = [{ + visibility: { $in: ['public', 'home'] } + }, { + // myself (for specified/private) + userId: user._id + }, { + // to me (for specified) + visibleUserIds: { $in: [user._id] } + }, { + visibility: 'followers', + userId: { $in: followings.map(f => f.id) } + }]; + const query = { $and: [{ deletedAt: null, - // リストに入っている人のタイムラインへの投稿 - $or: listQuery, + $and: [{ + // リストに入っている人のタイムラインへの投稿 + $or: listQuery + }, { + // visible for me + $or: visibleQuery + }], // mute userId: { diff --git a/src/server/api/endpoints/sw/register.ts b/src/server/api/endpoints/sw/register.ts index 26427452fd..095c1b765d 100644 --- a/src/server/api/endpoints/sw/register.ts +++ b/src/server/api/endpoints/sw/register.ts @@ -1,7 +1,7 @@ import $ from 'cafy'; import Subscription from '../../../../models/sw-subscription'; -import config from '../../../../config'; import define from '../../define'; +import fetchMeta from '../../../../misc/fetch-meta'; export const meta = { requireCredential: true, @@ -31,10 +31,12 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { deletedAt: { $exists: false } }); + const instance = await fetchMeta(); + if (exist != null) { return res({ state: 'already-subscribed', - key: config.sw.public_key + key: instance.swPublicKey }); } @@ -47,6 +49,6 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { res({ state: 'subscribed', - key: config.sw.public_key + key: instance.swPublicKey }); })); diff --git a/src/server/api/endpoints/users.ts b/src/server/api/endpoints/users.ts index 203b4a53c8..aef5bd8507 100644 --- a/src/server/api/endpoints/users.ts +++ b/src/server/api/endpoints/users.ts @@ -17,7 +17,23 @@ export const meta = { }, sort: { - validator: $.str.optional.or('+follower|-follower'), + validator: $.str.optional.or([ + '+follower', + '-follower', + '+createdAt', + '-createdAt', + '+updatedAt', + '-updatedAt', + ]), + }, + + origin: { + validator: $.str.optional.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'local' } } }; @@ -33,6 +49,22 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { _sort = { followersCount: 1 }; + } else if (ps.sort == '+createdAt') { + _sort = { + createdAt: -1 + }; + } else if (ps.sort == '+updatedAt') { + _sort = { + updatedAt: -1 + }; + } else if (ps.sort == '-createdAt') { + _sort = { + createdAt: 1 + }; + } else if (ps.sort == '-updatedAt') { + _sort = { + updatedAt: 1 + }; } } else { _sort = { @@ -40,14 +72,17 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { }; } + const q = + ps.origin == 'local' ? { host: null } : + ps.origin == 'remote' ? { host: { $ne: null } } : + {}; + const users = await User - .find({ - host: null - }, { + .find(q, { limit: ps.limit, sort: _sort, skip: ps.offset }); - res(await Promise.all(users.map(user => pack(user, me)))); + res(await Promise.all(users.map(user => pack(user, me, { detail: true })))); })); diff --git a/src/server/api/endpoints/users/get_frequently_replied_users.ts b/src/server/api/endpoints/users/get_frequently_replied_users.ts index cf123b82ba..353ccef1e9 100644 --- a/src/server/api/endpoints/users/get_frequently_replied_users.ts +++ b/src/server/api/endpoints/users/get_frequently_replied_users.ts @@ -2,6 +2,7 @@ import $ from 'cafy'; import ID, { transform } from '../../../../misc/cafy-id'; import Note from '../../../../models/note'; import User, { pack } from '../../../../models/user'; import define from '../../define'; +import { maximum } from '../../../../prelude/array'; export const meta = { requireCredential: false, @@ -77,20 +78,16 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { const repliedUsers: any = {}; // Extract replies from recent notes - replyTargetNotes.forEach(note => { - const userId = note.userId.toString(); + for (const userId of replyTargetNotes.map(x => x.userId.toString())) { if (repliedUsers[userId]) { repliedUsers[userId]++; } else { repliedUsers[userId] = 1; } - }); + } // Calc peak - let peak = 0; - Object.keys(repliedUsers).forEach(user => { - if (repliedUsers[user] > peak) peak = repliedUsers[user]; - }); + const peak = maximum(Object.values(repliedUsers)); // Sort replies by frequency const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]); diff --git a/src/server/api/endpoints/users/lists/pull.ts b/src/server/api/endpoints/users/lists/pull.ts new file mode 100644 index 0000000000..f1b25127b3 --- /dev/null +++ b/src/server/api/endpoints/users/lists/pull.ts @@ -0,0 +1,64 @@ +import $ from 'cafy'; import ID, { transform } from '../../../../../misc/cafy-id'; +import UserList from '../../../../../models/user-list'; +import User, { pack as packUser } from '../../../../../models/user'; +import { publishUserListStream } from '../../../../../stream'; +import define from '../../../define'; + +export const meta = { + desc: { + 'ja-JP': '指定したユーザーリストから指定したユーザーを削除します。', + 'en-US': 'Remove a user to a user list.' + }, + + requireCredential: true, + + kind: 'account-write', + + params: { + listId: { + validator: $.type(ID), + transform: transform, + }, + + userId: { + validator: $.type(ID), + transform: transform, + desc: { + 'ja-JP': '対象のユーザーのID', + 'en-US': 'Target user ID' + } + }, + } +}; + +export default define(meta, (ps, me) => new Promise(async (res, rej) => { + // Fetch the list + const userList = await UserList.findOne({ + _id: ps.listId, + userId: me._id, + }); + + if (userList == null) { + return rej('list not found'); + } + + // Fetch the user + const user = await User.findOne({ + _id: ps.userId + }); + + if (user == null) { + return rej('user not found'); + } + + // Pull the user + await UserList.update({ _id: userList._id }, { + $pull: { + userIds: user._id + } + }); + + res(); + + publishUserListStream(userList._id, 'userRemoved', await packUser(user)); +})); diff --git a/src/server/api/endpoints/users/notes.ts b/src/server/api/endpoints/users/notes.ts index e6df1eeece..fdb862de09 100644 --- a/src/server/api/endpoints/users/notes.ts +++ b/src/server/api/endpoints/users/notes.ts @@ -4,6 +4,7 @@ import Note, { packMany } from '../../../../models/note'; import User from '../../../../models/user'; import define from '../../define'; import { countIf } from '../../../../prelude/array'; +import Following from '../../../../models/following'; export const meta = { desc: { @@ -124,6 +125,14 @@ export const meta = { 'ja-JP': '指定された種類のファイルが添付された投稿のみを取得します' } }, + + excludeNsfw: { + validator: $.bool.optional, + default: false, + desc: { + 'ja-JP': 'true にすると、NSFW指定されたファイルを除外します(fileTypeが指定されている場合のみ有効)' + } + }, } }; @@ -152,12 +161,33 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { return rej('user not found'); } + const isFollowing = me == null ? false : ((await Following.findOne({ + followerId: me._id, + followeeId: user._id + })) != null); + //#region Construct query const sort = { } as any; + const visibleQuery = me == null ? [{ + visibility: { $in: ['public', 'home'] } + }] : [{ + visibility: { + $in: isFollowing ? ['public', 'home', 'followers'] : ['public', 'home'] + } + }, { + // myself (for specified/private) + userId: me._id + }, { + // to me (for specified) + visibleUserIds: { $in: [ me._id ] } + }]; + const query = { + $and: [ {} ], deletedAt: null, - userId: user._id + userId: user._id, + $or: visibleQuery } as any; if (ps.sinceId) { @@ -188,6 +218,22 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { query.replyId = null; } + if (ps.includeMyRenotes === false) { + query.$and.push({ + $or: [{ + userId: { $ne: user._id } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + fileIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly; if (withFiles) { @@ -203,6 +249,12 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { query['_files.contentType'] = { $in: ps.fileType }; + + if (ps.excludeNsfw) { + query['_files.metadata.isSensitive'] = { + $ne: true + }; + } } //#endregion diff --git a/src/server/api/endpoints/users/recommendation.ts b/src/server/api/endpoints/users/recommendation.ts index 127029f83c..bacace6a6a 100644 --- a/src/server/api/endpoints/users/recommendation.ts +++ b/src/server/api/endpoints/users/recommendation.ts @@ -1,11 +1,13 @@ const ms = require('ms'); import $ from 'cafy'; -import User, { pack } from '../../../../models/user'; +import User, { pack, ILocalUser } from '../../../../models/user'; import { getFriendIds } from '../../common/get-friends'; import Mute from '../../../../models/mute'; -import * as request from 'request'; +import * as request from 'request-promise-native'; import config from '../../../../config'; import define from '../../define'; +import fetchMeta from '../../../../misc/fetch-meta'; +import resolveUser from '../../../../remote/resolve-user'; export const meta = { desc: { @@ -30,13 +32,15 @@ export const meta = { }; export default define(meta, (ps, me) => new Promise(async (res, rej) => { - if (config.user_recommendation && config.user_recommendation.external) { + const instance = await fetchMeta(); + + if (instance.enableExternalUserRecommendation) { const userName = me.username; const hostName = config.hostname; const limit = ps.limit; const offset = ps.offset; - const timeout = config.user_recommendation.timeout; - const engine = config.user_recommendation.engine; + const timeout = instance.externalUserRecommendationTimeout; + const engine = instance.externalUserRecommendationEngine; const url = engine .replace('{{host}}', hostName) .replace('{{user}}', userName) @@ -50,13 +54,10 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { json: true, followRedirect: true, followAllRedirects: true - }, (error: any, response: any, body: any) => { - if (!error && response.statusCode == 200) { - res(body); - } else { - res([]); - } - }); + }) + .then(body => convertUsers(body, me)) + .then(packed => res(packed)) + .catch(e => rej(e)); } else { // ID list of the user itself and other users who the user follows const followingIds = await getFriendIds(me._id); @@ -72,7 +73,7 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { $nin: followingIds.concat(mutedUserIds) }, isLocked: { $ne: true }, - lastUsedAt: { + updatedAt: { $gte: new Date(Date.now() - ms('7days')) }, host: null @@ -87,3 +88,30 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { res(await Promise.all(users.map(user => pack(user, me, { detail: true })))); } })); + +type IRecommendUser = { + name: string; + username: string; + host: string; + description: string; + avatarUrl: string; +}; + +/** + * Resolve/Pack dummy users + */ +async function convertUsers(src: IRecommendUser[], me: ILocalUser) { + const packed = await Promise.all(src.map(async x => { + const user = await resolveUser(x.username, x.host) + .catch(() => { + console.warn(`Can't resolve ${x.username}@${x.host}`); + return null; + }); + + if (user == null) return x; + + return await pack(user, me, { detail: true }); + })); + + return packed; +} diff --git a/src/server/api/endpoints/users/report-abuse.ts b/src/server/api/endpoints/users/report-abuse.ts new file mode 100644 index 0000000000..b520b29e23 --- /dev/null +++ b/src/server/api/endpoints/users/report-abuse.ts @@ -0,0 +1,58 @@ +import $ from 'cafy'; import ID, { transform } from '../../../../misc/cafy-id'; +import define from '../../define'; +import User from '../../../../models/user'; +import AbuseUserReport from '../../../../models/abuse-user-report'; + +export const meta = { + desc: { + 'ja-JP': '指定したユーザーを迷惑なユーザーであると報告します。' + }, + + requireCredential: true, + + params: { + userId: { + validator: $.type(ID), + transform: transform, + desc: { + 'ja-JP': '対象のユーザーのID', + 'en-US': 'Target user ID' + } + }, + + comment: { + validator: $.str.range(1, 3000), + desc: { + 'ja-JP': '迷惑行為の詳細' + } + }, + } +}; + +export default define(meta, (ps, me) => new Promise(async (res, rej) => { + // Lookup user + const user = await User.findOne({ + _id: ps.userId + }); + + if (user === null) { + return rej('user not found'); + } + + if (user._id.equals(me._id)) { + return rej('cannot report yourself'); + } + + if (user.isAdmin) { + return rej('cannot report admin'); + } + + await AbuseUserReport.insert({ + createdAt: new Date(), + userId: user._id, + reporterId: me._id, + comment: ps.comment + }); + + res(); +})); diff --git a/src/server/api/endpoints/users/search.ts b/src/server/api/endpoints/users/search.ts index edc4d603ca..86b16dcbb1 100644 --- a/src/server/api/endpoints/users/search.ts +++ b/src/server/api/endpoints/users/search.ts @@ -41,11 +41,19 @@ export const meta = { 'ja-JP': 'ローカルユーザーのみ検索対象にするか否か' } }, + + detail: { + validator: $.bool.optional, + default: true, + desc: { + 'ja-JP': '詳細なユーザー情報を含めるか否か' + } + }, }, }; export default define(meta, (ps, me) => new Promise(async (res, rej) => { - const isUsername = validateUsername(ps.query.replace('@', '')); + const isUsername = validateUsername(ps.query.replace('@', ''), !ps.localOnly); let users: IUser[] = []; @@ -70,86 +78,7 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { users = users.concat(otherUsers); } - - if (users.length < ps.limit) { - const otherUsers = await User - .find({ - _id: { $nin: users.map(u => u._id) }, - host: null, - usernameLower: new RegExp(escapeRegexp(ps.query.replace('@', '').toLowerCase())) - }, { - limit: ps.limit - users.length - }); - - users = users.concat(otherUsers); - } - - if (users.length < ps.limit && !ps.localOnly) { - const otherUsers = await User - .find({ - _id: { $nin: users.map(u => u._id) }, - host: { $ne: null }, - usernameLower: new RegExp(escapeRegexp(ps.query.replace('@', '').toLowerCase())) - }, { - limit: ps.limit - users.length - }); - - users = users.concat(otherUsers); - } - } - - if (users.length < ps.limit) { - const otherUsers = await User - .find({ - _id: { $nin: users.map(u => u._id) }, - host: null, - name: new RegExp('^' + escapeRegexp(ps.query.toLowerCase())) - }, { - limit: ps.limit - users.length - }); - - users = users.concat(otherUsers); - } - - if (users.length < ps.limit && !ps.localOnly) { - const otherUsers = await User - .find({ - _id: { $nin: users.map(u => u._id) }, - host: { $ne: null }, - name: new RegExp('^' + escapeRegexp(ps.query.toLowerCase())) - }, { - limit: ps.limit - users.length - }); - - users = users.concat(otherUsers); - } - - if (users.length < ps.limit) { - const otherUsers = await User - .find({ - _id: { $nin: users.map(u => u._id) }, - host: null, - name: new RegExp(escapeRegexp(ps.query.toLowerCase())) - }, { - limit: ps.limit - users.length - }); - - users = users.concat(otherUsers); - } - - if (users.length < ps.limit && !ps.localOnly) { - const otherUsers = await User - .find({ - _id: { $nin: users.map(u => u._id) }, - host: { $ne: null }, - name: new RegExp(escapeRegexp(ps.query.toLowerCase())) - }, { - limit: ps.limit - users.length - }); - - users = users.concat(otherUsers); } - // Serialize - res(await Promise.all(users.map(user => pack(user, me, { detail: true })))); + res(await Promise.all(users.map(user => pack(user, me, { detail: ps.detail })))); })); diff --git a/src/server/api/endpoints/users/show.ts b/src/server/api/endpoints/users/show.ts index 6e4cf514de..fd26554709 100644 --- a/src/server/api/endpoints/users/show.ts +++ b/src/server/api/endpoints/users/show.ts @@ -80,7 +80,7 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { })); if (isRemoteUser(user)) { - if (user.updatedAt == null || Date.now() - user.updatedAt.getTime() > 1000 * 60 * 60 * 24) { + if (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { resolveRemoteUser(ps.username, ps.host, { }, true); } } diff --git a/src/server/api/index.ts b/src/server/api/index.ts index 1cd0028574..04fc8759f9 100644 --- a/src/server/api/index.ts +++ b/src/server/api/index.ts @@ -19,6 +19,12 @@ app.use(cors({ origin: '*' })); +// No caching +app.use(async (ctx, next) => { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + await next(); +}); + app.use(bodyParser({ // リクエストが multipart/form-data でない限りはJSONだと見なす detectJSON: ctx => !ctx.is('multipart/form-data') @@ -35,17 +41,19 @@ const router = new Router(); /** * Register endpoint handlers */ -endpoints.forEach(endpoint => endpoint.meta.requireFile - ? router.post(`/${endpoint.name}`, upload.single('file'), handler.bind(null, endpoint)) - : router.post(`/${endpoint.name}`, handler.bind(null, endpoint)) -); +for (const endpoint of endpoints) { + if (endpoint.meta.requireFile) { + router.post(`/${endpoint.name}`, upload.single('file'), handler.bind(null, endpoint)); + } else { + router.post(`/${endpoint.name}`, handler.bind(null, endpoint)); + } +} router.post('/signup', require('./private/signup').default); router.post('/signin', require('./private/signin').default); router.use(require('./service/discord').routes()); router.use(require('./service/github').routes()); -router.use(require('./service/github-bot').routes()); router.use(require('./service/twitter').routes()); router.use(require('./mastodon').routes()); diff --git a/src/server/api/limitter.ts b/src/server/api/limiter.ts index abf7627ab8..192aa19770 100644 --- a/src/server/api/limitter.ts +++ b/src/server/api/limiter.ts @@ -5,7 +5,7 @@ import { IEndpoint } from './endpoints'; import getAcct from '../../misc/acct/render'; import { IUser } from '../../models/user'; -const log = debug('misskey:limitter'); +const log = debug('misskey:limiter'); export default (endpoint: IEndpoint, user: IUser) => new Promise((ok, reject) => { // Redisがインストールされてない場合は常に許可 diff --git a/src/server/api/mastodon/index.ts b/src/server/api/mastodon/index.ts index bdd1fdf0f1..881df60cdd 100644 --- a/src/server/api/mastodon/index.ts +++ b/src/server/api/mastodon/index.ts @@ -6,7 +6,6 @@ import { ObjectID } from 'bson'; import Emoji from '../../../models/emoji'; import { toMastodonEmojis } from './emoji'; import fetchMeta from '../../../misc/fetch-meta'; -const pkg = require('../../../../package.json'); // Init router const router = new Router(); @@ -49,7 +48,7 @@ router.get('/v1/instance', async ctx => { // TODO: This is a temporary implement title: meta.name || 'Misskey', description: meta.description || '', email: meta.maintainer.email, - version: `0.0.0:compatible:misskey:${pkg.version}`, // TODO: How to tell about that this is an api for compatibility? + version: `0.0.0 (compatible; Misskey)`, // TODO: commit hash thumbnail: meta.bannerUrl, /* urls: { diff --git a/src/server/api/private/signup.ts b/src/server/api/private/signup.ts index 3a367ff119..8766a4f2dd 100644 --- a/src/server/api/private/signup.ts +++ b/src/server/api/private/signup.ts @@ -108,6 +108,7 @@ export default async (ctx: Koa.Context) => { token: secret, password: hash, isAdmin: config.autoAdmin && usersCount === 0, + autoAcceptFollowed: true, profile: { bio: null, birthday: null, diff --git a/src/server/api/service/github-bot.ts b/src/server/api/service/github-bot.ts deleted file mode 100644 index c201736673..0000000000 --- a/src/server/api/service/github-bot.ts +++ /dev/null @@ -1,163 +0,0 @@ -import * as EventEmitter from 'events'; -import * as Router from 'koa-router'; -import * as request from 'request'; -import User, { IUser } from '../../../models/user'; -import createNote from '../../../services/note/create'; -import config from '../../../config'; -const crypto = require('crypto'); - -const handler = new EventEmitter(); - -let bot: IUser; - -const post = async (text: string, home = true) => { - if (bot == null) { - const account = await User.findOne({ - usernameLower: config.github_bot.username.toLowerCase() - }); - - if (account == null) { - console.warn(`GitHub hook bot specified, but not found: @${config.github_bot.username}`); - return; - } else { - bot = account; - } - } - - createNote(bot, { text, visibility: home ? 'home' : 'public' }); -}; - -// Init router -const router = new Router(); - -if (config.github_bot) { - const secret = config.github_bot.hook_secret; - - router.post('/hooks/github', ctx => { - const body = JSON.stringify(ctx.request.body); - const hash = crypto.createHmac('sha1', secret).update(body).digest('hex'); - const sig1 = new Buffer(ctx.headers['x-hub-signature']); - const sig2 = new Buffer(`sha1=${hash}`); - - // シグネチャ比較 - if (sig1.equals(sig2)) { - handler.emit(ctx.headers['x-github-event'], ctx.request.body); - ctx.status = 204; - } else { - ctx.status = 400; - } - }); -} - -module.exports = router; - -handler.on('status', event => { - const state = event.state; - switch (state) { - case 'error': - case 'failure': - const commit = event.commit; - const parent = commit.parents[0]; - - // Fetch parent status - request({ - url: `${parent.url}/statuses`, - proxy: config.proxy, - headers: { - 'User-Agent': 'misskey' - } - }, (err, res, body) => { - if (err) { - console.error(err); - return; - } - const parentStatuses = JSON.parse(body); - const parentState = parentStatuses[0].state; - const stillFailed = parentState == 'failure' || parentState == 'error'; - if (stillFailed) { - post(`⚠️**BUILD STILL FAILED**⚠️: ?[${commit.commit.message}](${commit.html_url})`); - } else { - post(`🚨**BUILD FAILED**🚨: →→→?[${commit.commit.message}](${commit.html_url})←←←`); - } - }); - break; - } -}); - -handler.on('push', event => { - const ref = event.ref; - switch (ref) { - case 'refs/heads/develop': - const pusher = event.pusher; - const compare = event.compare; - const commits: any[] = event.commits; - post([ - `🆕 Pushed by **${pusher.name}** with ?[${commits.length} commit${commits.length > 1 ? 's' : ''}](${compare}):`, - commits.reverse().map(commit => `・[?[${commit.id.substr(0, 7)}](${commit.url})] ${commit.message.split('\n')[0]}`).join('\n'), - ].join('\n')); - break; - } -}); - -handler.on('issues', event => { - const issue = event.issue; - const action = event.action; - let title: string; - switch (action) { - case 'opened': title = '💥 Issue opened'; break; - case 'closed': title = '💮 Issue closed'; break; - case 'reopened': title = '🔥 Issue reopened'; break; - default: return; - } - post(`${title}: <${issue.number}>「${issue.title}」\n${issue.html_url}`); -}); - -handler.on('issue_comment', event => { - const issue = event.issue; - const comment = event.comment; - const action = event.action; - let text: string; - switch (action) { - case 'created': text = `💬 Commented to「${issue.title}」:${comment.user.login}「${comment.body}」\n${comment.html_url}`; break; - default: return; - } - post(text); -}); - -handler.on('release', event => { - const action = event.action; - const release = event.release; - let text: string; - switch (action) { - case 'published': text = `🎁 **NEW RELEASE**: [${release.tag_name}](${release.html_url}) is out now. Enjoy!`; break; - default: return; - } - post(text); -}); - -handler.on('watch', event => { - const sender = event.sender; - post(`(((⭐️))) Starred by **${sender.login}** (((⭐️)))`, false); -}); - -handler.on('fork', event => { - const repo = event.forkee; - post(`🍴 Forked:\n${repo.html_url} 🍴`); -}); - -handler.on('pull_request', event => { - const pr = event.pull_request; - const action = event.action; - let text: string; - switch (action) { - case 'opened': text = `📦 New Pull Request:「${pr.title}」\n${pr.html_url}`; break; - case 'reopened': text = `🗿 Pull Request Reopened:「${pr.title}」\n${pr.html_url}`; break; - case 'closed': - text = pr.merged - ? `💯 Pull Request Merged!:「${pr.title}」\n${pr.html_url}` - : `🚫 Pull Request Closed:「${pr.title}」\n${pr.html_url}`; - break; - default: return; - } - post(text); -}); diff --git a/src/server/api/stream/channels/games/reversi-game.ts b/src/server/api/stream/channels/games/reversi-game.ts index f287ad0107..1cb077c8c4 100644 --- a/src/server/api/stream/channels/games/reversi-game.ts +++ b/src/server/api/stream/channels/games/reversi-game.ts @@ -242,9 +242,9 @@ export default class extends Channel { loopedBoard: game.settings.loopedBoard }); - game.logs.forEach(log => { + for (const log of game.logs) { o.put(log.color, log.pos); - }); + } const myColor = (game.user1Id.equals(this.user._id) && game.black == 1) || (game.user2Id.equals(this.user._id) && game.black == 2) diff --git a/src/server/api/stream/channels/global-timeline.ts b/src/server/api/stream/channels/global-timeline.ts index c5499801ed..b3689d47f5 100644 --- a/src/server/api/stream/channels/global-timeline.ts +++ b/src/server/api/stream/channels/global-timeline.ts @@ -3,6 +3,7 @@ import Mute from '../../../../models/mute'; import { pack } from '../../../../models/note'; import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; import Channel from '../channel'; +import fetchMeta from '../../../../misc/fetch-meta'; export default class extends Channel { public readonly chName = 'globalTimeline'; @@ -13,6 +14,11 @@ export default class extends Channel { @autobind public async init(params: any) { + const meta = await fetchMeta(); + if (meta.disableGlobalTimeline) { + if (this.user == null || (!this.user.isAdmin && !this.user.isModerator)) return; + } + // Subscribe events this.subscriber.on('globalTimeline', this.onNote); @@ -22,6 +28,12 @@ export default class extends Channel { @autobind private async onNote(note: any) { + // リプライなら再pack + if (note.replyId != null) { + note.reply = await pack(note.replyId, this.user, { + detail: true + }); + } // Renoteなら再pack if (note.renoteId != null) { note.renote = await pack(note.renoteId, this.user, { diff --git a/src/server/api/stream/channels/hashtag.ts b/src/server/api/stream/channels/hashtag.ts index 052240c18c..c8b6c578de 100644 --- a/src/server/api/stream/channels/hashtag.ts +++ b/src/server/api/stream/channels/hashtag.ts @@ -20,7 +20,8 @@ export default class extends Channel { // Subscribe stream this.subscriber.on('hashtag', async note => { - const matched = q.some(tags => tags.every(tag => note.tags.map((t: string) => t.toLowerCase()).includes(tag.toLowerCase()))); + const noteTags = note.tags.map((t: string) => t.toLowerCase()); + const matched = q.some(tags => tags.every(tag => noteTags.includes(tag.toLowerCase()))); if (!matched) return; // Renoteなら再pack diff --git a/src/server/api/stream/channels/home-timeline.ts b/src/server/api/stream/channels/home-timeline.ts index d3477d846a..3c0b238720 100644 --- a/src/server/api/stream/channels/home-timeline.ts +++ b/src/server/api/stream/channels/home-timeline.ts @@ -22,6 +22,12 @@ export default class extends Channel { @autobind private async onNote(note: any) { + // リプライなら再pack + if (note.replyId != null) { + note.reply = await pack(note.replyId, this.user, { + detail: true + }); + } // Renoteなら再pack if (note.renoteId != null) { note.renote = await pack(note.renoteId, this.user, { diff --git a/src/server/api/stream/channels/hybrid-timeline.ts b/src/server/api/stream/channels/hybrid-timeline.ts index 15a516b0c0..35ef17b56b 100644 --- a/src/server/api/stream/channels/hybrid-timeline.ts +++ b/src/server/api/stream/channels/hybrid-timeline.ts @@ -3,6 +3,7 @@ import Mute from '../../../../models/mute'; import { pack } from '../../../../models/note'; import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; import Channel from '../channel'; +import fetchMeta from '../../../../misc/fetch-meta'; export default class extends Channel { public readonly chName = 'hybridTimeline'; @@ -13,6 +14,9 @@ export default class extends Channel { @autobind public async init(params: any) { + const meta = await fetchMeta(); + if (meta.disableLocalTimeline && !this.user.isAdmin && !this.user.isModerator) return; + // Subscribe events this.subscriber.on('hybridTimeline', this.onNewNote); this.subscriber.on(`hybridTimeline:${this.user._id}`, this.onNewNote); @@ -23,6 +27,12 @@ export default class extends Channel { @autobind private async onNewNote(note: any) { + // リプライなら再pack + if (note.replyId != null) { + note.reply = await pack(note.replyId, this.user, { + detail: true + }); + } // Renoteなら再pack if (note.renoteId != null) { note.renote = await pack(note.renoteId, this.user, { diff --git a/src/server/api/stream/channels/local-timeline.ts b/src/server/api/stream/channels/local-timeline.ts index a26f71af8e..3402023192 100644 --- a/src/server/api/stream/channels/local-timeline.ts +++ b/src/server/api/stream/channels/local-timeline.ts @@ -3,6 +3,7 @@ import Mute from '../../../../models/mute'; import { pack } from '../../../../models/note'; import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; import Channel from '../channel'; +import fetchMeta from '../../../../misc/fetch-meta'; export default class extends Channel { public readonly chName = 'localTimeline'; @@ -13,6 +14,11 @@ export default class extends Channel { @autobind public async init(params: any) { + const meta = await fetchMeta(); + if (meta.disableLocalTimeline) { + if (this.user == null || (!this.user.isAdmin && !this.user.isModerator)) return; + } + // Subscribe events this.subscriber.on('localTimeline', this.onNote); @@ -22,6 +28,12 @@ export default class extends Channel { @autobind private async onNote(note: any) { + // リプライなら再pack + if (note.replyId != null) { + note.reply = await pack(note.replyId, this.user, { + detail: true + }); + } // Renoteなら再pack if (note.renoteId != null) { note.renote = await pack(note.renoteId, this.user, { diff --git a/src/server/api/stream/channels/user-list.ts b/src/server/api/stream/channels/user-list.ts index dbdd8afb0a..5debf41770 100644 --- a/src/server/api/stream/channels/user-list.ts +++ b/src/server/api/stream/channels/user-list.ts @@ -1,5 +1,6 @@ import autobind from 'autobind-decorator'; import Channel from '../channel'; +import { pack } from '../../../../models/note'; export default class extends Channel { public readonly chName = 'userList'; @@ -11,7 +12,11 @@ export default class extends Channel { const listId = params.listId as string; // Subscribe stream - this.subscriber.on(`userListStream:${listId}`, data => { + this.subscriber.on(`userListStream:${listId}`, async data => { + // 再パック + if (data.type == 'note') data.body = await pack(data.body.id, this.user, { + detail: true + }); this.send(data); }); } diff --git a/src/server/api/stream/index.ts b/src/server/api/stream/index.ts index 58dbacd688..22f7646cb9 100644 --- a/src/server/api/stream/index.ts +++ b/src/server/api/stream/index.ts @@ -46,7 +46,6 @@ export default class Connection { switch (type) { case 'api': this.onApiRequest(body); break; - case 'alive': this.onAlive(); break; case 'readNotification': this.onReadNotification(body); break; case 'subNote': this.onSubscribeNote(body); break; case 'sn': this.onSubscribeNote(body); break; // alias @@ -78,16 +77,6 @@ export default class Connection { } @autobind - private onAlive() { - // Update lastUsedAt - User.update({ _id: this.user._id }, { - $set: { - 'lastUsedAt': new Date() - } - }); - } - - @autobind private onReadNotification(payload: any) { if (!payload.id) return; readNotification(this.user._id, payload.id); @@ -224,8 +213,8 @@ export default class Connection { */ @autobind public dispose() { - this.channels.forEach(c => { - if (c.dispose) c.dispose(); - }); + for (const c of this.channels.filter(c => c.dispose)) { + c.dispose(); + } } } diff --git a/src/server/file/send-drive-file.ts b/src/server/file/send-drive-file.ts index b904bda91b..c64177d4ee 100644 --- a/src/server/file/send-drive-file.ts +++ b/src/server/file/send-drive-file.ts @@ -3,6 +3,7 @@ import * as send from 'koa-send'; import * as mongodb from 'mongodb'; import DriveFile, { getDriveFileBucket } from '../../models/drive-file'; import DriveFileThumbnail, { getDriveFileThumbnailBucket } from '../../models/drive-file-thumbnail'; +import DriveFileWebpublic, { getDriveFileWebpublicBucket } from '../../models/drive-file-webpublic'; const assets = `${__dirname}/../../server/file/assets/`; @@ -41,6 +42,11 @@ export default async function(ctx: Koa.Context) { } const sendRaw = async () => { + if (file.metadata && file.metadata.accessKey && file.metadata.accessKey != ctx.query['original']) { + ctx.status = 403; + return; + } + const bucket = await getDriveFileBucket(); const readable = bucket.openDownloadStream(fileId); readable.on('error', commonReadableHandlerGenerator(ctx)); @@ -60,6 +66,19 @@ export default async function(ctx: Koa.Context) { } else { await sendRaw(); } + } else if ('web' in ctx.query) { + const web = await DriveFileWebpublic.findOne({ + 'metadata.originalId': fileId + }); + + if (web != null) { + ctx.set('Content-Type', file.contentType); + + const bucket = await getDriveFileWebpublicBucket(); + ctx.body = bucket.openDownloadStream(web._id); + } else { + await sendRaw(); + } } else { if ('download' in ctx.query) { ctx.set('Content-Disposition', 'attachment'); diff --git a/src/server/index.ts b/src/server/index.ts index 77c869bb4e..86dfd4b753 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -20,6 +20,7 @@ import config from '../config'; import networkChart from '../chart/network'; import apiServer from './api'; import { sum } from '../prelude/array'; +import User from '../models/user'; // Init app const app = new Koa(); @@ -59,6 +60,29 @@ const router = new Router(); router.use(activityPub.routes()); router.use(webFinger.routes()); +router.get('/verify-email/:code', async ctx => { + const user = await User.findOne({ emailVerifyCode: ctx.params.code }); + + if (user != null) { + ctx.body = 'Verify succeeded!'; + ctx.status = 200; + + User.update({ _id: user._id }, { + $set: { + emailVerified: true, + emailVerifyCode: null + } + }); + } else { + ctx.status = 404; + } +}); + +// Return 404 for other .well-known +router.all('/.well-known/*', async ctx => { + ctx.status = 404; +}); + // Register router app.use(router.routes()); @@ -67,9 +91,9 @@ app.use(mount(require('./web'))); function createServer() { if (config.https) { const certs: any = {}; - Object.keys(config.https).forEach(k => { + for (const k of Object.keys(config.https)) { certs[k] = fs.readFileSync(config.https[k]); - }); + } certs['allowHTTP1'] = true; return http2.createSecureServer(certs, app.callback()); } else { diff --git a/src/server/web/docs.ts b/src/server/web/docs.ts index d91813c869..f9991fc65c 100644 --- a/src/server/web/docs.ts +++ b/src/server/web/docs.ts @@ -30,13 +30,13 @@ async function genVars(lang: string): Promise<{ [key: string]: any }> { const entities = glob.sync('src/docs/api/entities/**/*.yaml', { cwd }); vars['entities'] = entities.map(x => { - const _x = yaml.safeLoad(fs.readFileSync(cwd + x, 'utf-8')) as any; + const _x = yaml.safeLoad(fs.readFileSync(cwd + x, 'utf-8')); return _x.name; }); const docs = glob.sync(`src/docs/**/*.${lang}.md`, { cwd }); vars['docs'] = {}; - docs.forEach(x => { + for (const x of docs) { const [, name] = x.match(/docs\/(.+?)\.(.+?)\.md$/); if (vars['docs'][name] == null) { vars['docs'][name] = { @@ -45,7 +45,7 @@ async function genVars(lang: string): Promise<{ [key: string]: any }> { }; } vars['docs'][name]['title'][lang] = fs.readFileSync(cwd + x, 'utf-8').match(/^# (.+?)\r?\n/)[1]; - }); + } vars['kebab'] = (string: string) => string.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/\s+/g, '-').toLowerCase(); @@ -121,7 +121,7 @@ const sortParams = (params: Array<{ name: string }>) => { const extractParamDefRef = (params: Context[]) => { let defs: any[] = []; - params.forEach(param => { + for (const param of params) { if (param.data && param.data.ref) { const props = (param as ObjectContext<any>).props; defs.push({ @@ -133,7 +133,7 @@ const extractParamDefRef = (params: Context[]) => { defs = defs.concat(childDefs); } - }); + } return sortParams(defs); }; @@ -141,7 +141,7 @@ const extractParamDefRef = (params: Context[]) => { const extractPropDefRef = (props: any[]) => { let defs: any[] = []; - Object.entries(props).forEach(([k, v]) => { + for (const [k, v] of Object.entries(props)) { if (v.props) { defs.push({ name: k, @@ -152,7 +152,7 @@ const extractPropDefRef = (props: any[]) => { defs = defs.concat(childDefs); } - }); + } return sortParams(defs); }; @@ -189,13 +189,15 @@ router.get('/*/api/endpoints/*', async ctx => { }; await ctx.render('../../../../src/docs/api/endpoints/view', Object.assign(await genVars(lang), vars)); + + ctx.set('Cache-Control', 'public, max-age=300'); }); router.get('/*/api/entities/*', async ctx => { const lang = ctx.params[0]; const entity = ctx.params[1]; - const x = yaml.safeLoad(fs.readFileSync(path.resolve(`${__dirname}/../../../src/docs/api/entities/${entity}.yaml`), 'utf-8')) as any; + const x = yaml.safeLoad(fs.readFileSync(path.resolve(`${__dirname}/../../../src/docs/api/entities/${entity}.yaml`), 'utf-8')); await ctx.render('../../../../src/docs/api/entities/view', Object.assign(await genVars(lang), { id: `api/entities/${entity}`, @@ -204,6 +206,8 @@ router.get('/*/api/entities/*', async ctx => { props: sortParams(Object.entries(x.props).map(([k, v]) => parsePropDefinition(k, v))), propDefs: extractPropDefRef(x.props) })); + + ctx.set('Cache-Control', 'public, max-age=300'); }); router.get('/*/*', async ctx => { @@ -240,6 +244,8 @@ router.get('/*/*', async ctx => { title: md.match(/^# (.+?)\r?\n/)[1], src: `https://github.com/syuilo/misskey/tree/master/src/docs/${doc}.${lang}.md` }, await genVars(lang))); + + ctx.set('Cache-Control', 'public, max-age=300'); }); export default router; diff --git a/src/server/web/feed.ts b/src/server/web/feed.ts new file mode 100644 index 0000000000..09ac10c576 --- /dev/null +++ b/src/server/web/feed.ts @@ -0,0 +1,54 @@ +import { Feed } from 'feed'; +import config from '../../config'; +import Note from '../../models/note'; +import { IUser } from '../../models/user'; +import { getOriginalUrl } from '../../misc/get-drive-file-url'; + +export default async function(user: IUser) { + const author: Author = { + link: `${config.url}/@${user.username}`, + name: user.name || user.username + }; + + const notes = await Note.find({ + userId: user._id, + renoteId: null, + $or: [ + { visibility: 'public' }, + { visibility: 'home' } + ] + }, { + sort: { createdAt: -1 }, + limit: 20 + }); + + const feed = new Feed({ + id: author.link, + title: `${author.name} (@${user.username}@${config.host})`, + updated: notes[0].createdAt, + generator: 'Misskey', + description: `${user.notesCount} Notes, ${user.followingCount} Following, ${user.followersCount} Followers${user.description ? ` · ${user.description}` : ''}`, + link: author.link, + image: user.avatarUrl, + feedLinks: { + json: `${author.link}.json`, + atom: `${author.link}.atom`, + }, + author + } as FeedOptions); + + for (const note of notes) { + const file = note._files && note._files.find(file => file.contentType.startsWith('image/')); + + feed.addItem({ + title: `New note by ${author.name}`, + link: `${config.url}/notes/${note._id}`, + date: note.createdAt, + description: note.cw, + content: note.text, + image: file && getOriginalUrl(file) + }); + } + + return feed; +} diff --git a/src/server/web/index.ts b/src/server/web/index.ts index 42203471a7..945176afd3 100644 --- a/src/server/web/index.ts +++ b/src/server/web/index.ts @@ -2,20 +2,25 @@ * Web Client Server */ +import * as os from 'os'; import ms = require('ms'); import * as Koa from 'koa'; import * as Router from 'koa-router'; import * as send from 'koa-send'; import * as favicon from 'koa-favicon'; import * as views from 'koa-views'; +import { ObjectID } from 'mongodb'; import docs from './docs'; +import packFeed from './feed'; import User from '../../models/user'; import parseAcct from '../../misc/acct/parse'; import config from '../../config'; import Note, { pack as packNote } from '../../models/note'; import getNoteSummary from '../../misc/get-note-summary'; -const consts = require('../../const.json'); +import fetchMeta from '../../misc/fetch-meta'; +import Emoji from '../../models/emoji'; +const pkg = require('../../../package.json'); const client = `${__dirname}/../../client/`; @@ -26,8 +31,7 @@ const app = new Koa(); app.use(views(__dirname + '/views', { extension: 'pug', options: { - config, - themeColor: consts.themeColor + config } })); @@ -83,6 +87,52 @@ router.use('/docs', docs.routes()); // URL preview endpoint router.get('/url', require('./url-preview')); +const getFeed = async (acct: string) => { + const { username, host } = parseAcct(acct); + const user = await User.findOne({ + usernameLower: username.toLowerCase(), + host + }); + + return user && await packFeed(user); +}; + +// Atom +router.get('/@:user.atom', async ctx => { + const feed = await getFeed(ctx.params.user); + + if (feed) { + ctx.set('Content-Type', 'application/atom+xml; charset=utf-8'); + ctx.body = feed.atom1(); + } else { + ctx.status = 404; + } +}); + +// RSS +router.get('/@:user.rss', async ctx => { + const feed = await getFeed(ctx.params.user); + + if (feed) { + ctx.set('Content-Type', 'application/rss+xml; charset=utf-8'); + ctx.body = feed.rss2(); + } else { + ctx.status = 404; + } +}); + +// JSON +router.get('/@:user.json', async ctx => { + const feed = await getFeed(ctx.params.user); + + if (feed) { + ctx.set('Content-Type', 'application/json; charset=utf-8'); + ctx.body = feed.json1(); + } else { + ctx.status = 404; + } +}); + //#region for crawlers // User router.get('/@:user', async (ctx, next) => { @@ -94,34 +144,94 @@ router.get('/@:user', async (ctx, next) => { if (user != null) { await ctx.render('user', { user }); + ctx.set('Cache-Control', 'public, max-age=180'); } else { // リモートユーザーなので await next(); } }); +router.get('/users/:user', async ctx => { + if (!ObjectID.isValid(ctx.params.user)) { + ctx.status = 404; + return; + } + + const userId = new ObjectID(ctx.params.user); + + const user = await User.findOne({ + _id: userId, + host: null + }); + + if (user === null) { + ctx.status = 404; + return; + } + + ctx.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`); +}); + // Note router.get('/notes/:note', async ctx => { - const note = await Note.findOne({ _id: ctx.params.note }); + if (ObjectID.isValid(ctx.params.note)) { + const note = await Note.findOne({ _id: ctx.params.note }); - if (note != null) { - const _note = await packNote(note); - await ctx.render('note', { - note: _note, - summary: getNoteSummary(_note) - }); - } else { - ctx.status = 404; + if (note) { + const _note = await packNote(note); + await ctx.render('note', { + note: _note, + summary: getNoteSummary(_note) + }); + + if (['public', 'home'].includes(note.visibility)) { + ctx.set('Cache-Control', 'public, max-age=180'); + } else { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } + + return; + } } + + ctx.status = 404; }); //#endregion +router.get('/info', async ctx => { + const meta = await fetchMeta(); + const emojis = await Emoji.find({ host: null }, { + fields: { + _id: false + } + }); + await ctx.render('info', { + version: pkg.version, + machine: os.hostname(), + os: os.platform(), + node: process.version, + cpu: { + model: os.cpus()[0].model, + cores: os.cpus().length + }, + emojis: emojis, + meta: meta + }); +}); + +const override = (source: string, target: string, depth: number = 0) => + [, ...target.split('/').filter(x => x), ...source.split('/').filter(x => x).splice(depth)].join('/'); + +router.get('/othello', async ctx => ctx.redirect(override(ctx.URL.pathname, 'games/reversi', 1))); +router.get('/reversi', async ctx => ctx.redirect(override(ctx.URL.pathname, 'games'))); + // Render base html for all requests router.get('*', async ctx => { - await send(ctx, `app/base.html`, { - root: client, - maxage: ms('5m') + const meta = await fetchMeta(); + await ctx.render('base', { + img: meta.bannerUrl }); + ctx.set('Cache-Control', 'public, max-age=86400'); }); // Register router diff --git a/src/server/web/url-preview.ts b/src/server/web/url-preview.ts index eb835b05ac..86ae3fa0a5 100644 --- a/src/server/web/url-preview.ts +++ b/src/server/web/url-preview.ts @@ -1,13 +1,14 @@ import * as Koa from 'koa'; import * as request from 'request-promise-native'; import summaly from 'summaly'; -import config from '../../config'; +import fetchMeta from '../../misc/fetch-meta'; module.exports = async (ctx: Koa.Context) => { + const meta = await fetchMeta(); + try { - const summary = config.summalyProxy ? await request.get({ - url: config.summalyProxy, - proxy: config.proxy, + const summary = meta.summalyProxy ? await request.get({ + url: meta.summalyProxy, qs: { url: ctx.query.url }, diff --git a/src/client/app/base.pug b/src/server/web/views/base.pug index ee9d4b6f6d..dd9660b73f 100644 --- a/src/client/app/base.pug +++ b/src/server/web/views/base.pug @@ -9,7 +9,6 @@ html head meta(charset='utf-8') meta(name='application-name' content='Misskey') - meta(name='theme-color' content=themeColor) meta(name='referrer' content='origin') meta(property='og:site_name' content='Misskey') link(rel='manifest' href='/manifest.json') @@ -23,16 +22,16 @@ html block meta + block og + meta(property='og:image' content=img) + style - include ./../../../built/client/assets/init.css + include ./../../../../built/client/assets/init.css script - include ./../../../built/client/assets/boot.js + include ./../../../../built/client/assets/boot.js script - include ./../../../built/client/assets/safe.js - - //- FontAwesome style - style #{facss} + include ./../../../../built/client/assets/safe.js body noscript: p @@ -41,5 +40,5 @@ html | Please turn on your JavaScript div#ini. <svg viewBox="0 0 50 50"> - <path fill=#{themeColor} d="M25.251,6.461c-10.318,0-18.683,8.365-18.683,18.683h4.068c0-8.071,6.543-14.615,14.615-14.615V6.461z" /> + <path fill=#fb4e4e d="M25.251,6.461c-10.318,0-18.683,8.365-18.683,18.683h4.068c0-8.071,6.543-14.615,14.615-14.615V6.461z" /> </svg> diff --git a/src/server/web/views/info.pug b/src/server/web/views/info.pug new file mode 100644 index 0000000000..1c4b272a62 --- /dev/null +++ b/src/server/web/views/info.pug @@ -0,0 +1,135 @@ +doctype html + +html + + head + meta(charset='utf-8') + meta(name='application-name' content='Misskey') + title Misskey + style. + html { + font-family: sans-serif; + } + + main { + max-width: 934px; + margin: 0 auto; + } + + header { + padding: 5px; + background: rgb(153, 153, 204); + border: 1px solid #000; + box-shadow: rgb(204, 204, 204) 1px 2px 3px; + } + header:after { + content: ''; + display: block; + clear: both; + } + + header > h1 { + float: left; + font-size: 2em; + } + + header > img { + float: right; + width: 220px; + } + + table { + margin: 1em 0; + width: 100%; + border-collapse: collapse; + box-shadow: rgb(204, 204, 204) 1px 2px 3px; + } + table tr th { + background-color: #ccf; + border: 1px solid #000; + width: 300px; + font-weight: bold; + padding: 4px 5px; + text-align: left; + } + table tr td { + background-color: #ddd; + border: 1px solid #000; + padding: 4px 5px; + } + + footer { + text-align: center; + } + + body + main + header + h1 Misskey Version #{version} + img(src='/assets/misskey-php-like-logo.png' alt='') + table + tr + th Instance + td= meta.name + tr + th Description + td= meta.description + tr + th Maintainer + td + = meta.maintainer.name + | <#{meta.maintainer.email}> + tr + th System + td= os + tr + th Node version + td= node + tr + th Machine + td= machine + tr + th CPU + td= cpu.model + tr + th Original users + td= meta.stats.originalUsersCount + tr + th Original notes + td= meta.stats.originalNotesCount + tr + th Registration + td= !meta.disableRegistration ? 'yes' : 'no' + tr + th reCAPTCHA enabled + td= meta.enableRecaptcha ? 'enabled' : 'disabled' + tr + th LTL(STL) enabled + td= !meta.disableLocalTimeline ? 'enabled' : 'disabled' + tr + th GTL enabled + td= !meta.disableGlobalTimeline ? 'enabled' : 'disabled' + tr + th Cache remote files + td= meta.cacheRemoteFiles ? 'yes' : 'no' + tr + th Drive capacity per local user + td + = meta.localDriveCapacityMb + | MB + tr + th Drive capacity per remote user + td + = meta.remoteDriveCapacityMb + | MB + tr + th Max text length + td= meta.maxNoteTextLength + tr + th Emojis + td + each emoji in emojis + | :#{emoji.name}: + = ' ' + footer + p Misskey is open-source software. <a href="https://github.com/syuilo/misskey">View source</a> diff --git a/src/server/web/views/note.pug b/src/server/web/views/note.pug index 234ecabe22..2d07aff2ed 100644 --- a/src/server/web/views/note.pug +++ b/src/server/web/views/note.pug @@ -1,4 +1,4 @@ -extends ../../../../src/client/app/base +extends ./base block vars - const user = note.user; @@ -11,14 +11,19 @@ block title block desc meta(name='description' content= summary) -block meta - meta(name='twitter:card' content='summary') +block og meta(property='og:type' content='article') meta(property='og:title' content= title) meta(property='og:description' content= summary) meta(property='og:url' content= url) meta(property='og:image' content= user.avatarUrl) +block meta + meta(name='twitter:card' content='summary') + + if user.twitter + meta(name='twitter:creator' content=`@${user.twitter.screenName}`) + if note.prev link(rel='prev' href=`${config.url}/notes/${note.prev}`) if note.next diff --git a/src/server/web/views/user.pug b/src/server/web/views/user.pug index 22c76c143b..7810a8b9b2 100644 --- a/src/server/web/views/user.pug +++ b/src/server/web/views/user.pug @@ -1,4 +1,4 @@ -extends ../../../../src/client/app/base +extends ./base block vars - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`; @@ -11,14 +11,19 @@ block title block desc meta(name='description' content= user.description) -block meta - meta(name='twitter:card' content='summary') +block og meta(property='og:type' content='blog') meta(property='og:title' content= title) meta(property='og:description' content= user.description) meta(property='og:url' content= url) meta(property='og:image' content= img) - + +block meta + meta(name='twitter:card' content='summary') + + if user.twitter + meta(name='twitter:creator' content=`@${user.twitter.screenName}`) + if !user.host link(rel='alternate' href=`${config.url}/users/${user._id}` type='application/activity+json') if user.uri diff --git a/src/server/webfinger.ts b/src/server/webfinger.ts index 6c2afae79c..0f3e53b60f 100644 --- a/src/server/webfinger.ts +++ b/src/server/webfinger.ts @@ -68,6 +68,8 @@ router.get('/.well-known/webfinger', async ctx => { template: `${config.url}/authorize-follow?acct={uri}` }] }; + + ctx.set('Cache-Control', 'public, max-age=180'); }); export default router; diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts index 0fb365c91f..7d83e214aa 100644 --- a/src/services/drive/add-file.ts +++ b/src/services/drive/add-file.ts @@ -4,10 +4,11 @@ import * as fs from 'fs'; import * as mongodb from 'mongodb'; import * as crypto from 'crypto'; import * as debug from 'debug'; -import fileType = require('file-type'); import * as Minio from 'minio'; import * as uuid from 'uuid'; import * as sharp from 'sharp'; +import * as fileType from 'file-type'; +import * as isSvg from 'is-svg'; import DriveFile, { IMetadata, getDriveFileBucket, IDriveFile } from '../../models/drive-file'; import DriveFolder from '../../models/drive-folder'; @@ -16,6 +17,7 @@ import { publishMainStream, publishDriveStream } from '../../stream'; import { isLocalUser, IUser, IRemoteUser } from '../../models/user'; import delFile from './delete-file'; import config from '../../config'; +import { getDriveFileWebpublicBucket } from '../../models/drive-file-webpublic'; import { getDriveFileThumbnailBucket } from '../../models/drive-file-thumbnail'; import driveChart from '../../chart/drive'; import perUserDriveChart from '../../chart/per-user-drive'; @@ -23,22 +25,103 @@ import fetchMeta from '../../misc/fetch-meta'; const log = debug('misskey:drive:add-file'); -async function save(path: string, name: string, type: string, hash: string, size: number, metadata: any): Promise<IDriveFile> { +/*** + * Save file + * @param path Path for original + * @param name Name for original + * @param type Content-Type for original + * @param hash Hash for original + * @param size Size for original + * @param metadata + */ +async function save(path: string, name: string, type: string, hash: string, size: number, metadata: IMetadata): Promise<IDriveFile> { + // #region webpublic + let webpublic: Buffer; + let webpublicExt = 'jpg'; + let webpublicType = 'image/jpeg'; + + if (!metadata.uri) { // from local instance + log(`creating web image`); + + if (['image/jpeg'].includes(type)) { + webpublic = await sharp(path) + .resize(2048, 2048, { + fit: 'inside', + withoutEnlargement: true + }) + .rotate() + .jpeg({ + quality: 85, + progressive: true + }) + .toBuffer(); + } else if (['image/webp'].includes(type)) { + webpublic = await sharp(path) + .resize(2048, 2048, { + fit: 'inside', + withoutEnlargement: true + }) + .rotate() + .webp({ + quality: 85 + }) + .toBuffer(); + + webpublicExt = 'webp'; + webpublicType = 'image/webp'; + } else if (['image/png'].includes(type)) { + webpublic = await sharp(path) + .resize(2048, 2048, { + fit: 'inside', + withoutEnlargement: true + }) + .rotate() + .png() + .toBuffer(); + + webpublicExt = 'png'; + webpublicType = 'image/png'; + } else { + log(`web image not created (not an image)`); + } + } else { + log(`web image not created (from remote)`); + } + // #endregion webpublic + + // #region thumbnail let thumbnail: Buffer; + let thumbnailExt = 'jpg'; + let thumbnailType = 'image/jpeg'; - if (['image/jpeg', 'image/png', 'image/webp'].includes(type)) { + if (['image/jpeg', 'image/webp'].includes(type)) { thumbnail = await sharp(path) - .resize(300) + .resize(498, 280, { + fit: 'inside', + withoutEnlargement: true + }) + .rotate() .jpeg({ - quality: 50, + quality: 85, progressive: true }) .toBuffer(); + } else if (['image/png'].includes(type)) { + thumbnail = await sharp(path) + .resize(498, 280, { + fit: 'inside', + withoutEnlargement: true + }) + .rotate() + .png() + .toBuffer(); + + thumbnailExt = 'png'; + thumbnailType = 'image/png'; } + // #endregion thumbnail if (config.drive && config.drive.storage == 'minio') { - const minio = new Minio.Client(config.drive.config); - let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) || ['']); if (ext === '') { @@ -48,33 +131,41 @@ async function save(path: string, name: string, type: string, hash: string, size } const key = `${config.drive.prefix}/${uuid.v4()}${ext}`; - const thumbnailKey = `${config.drive.prefix}/${uuid.v4()}.jpg`; + const webpublicKey = `${config.drive.prefix}/${uuid.v4()}.${webpublicExt}`; + const thumbnailKey = `${config.drive.prefix}/${uuid.v4()}.${thumbnailExt}`; - const baseUrl = config.drive.baseUrl - || `${ config.drive.config.useSSL ? 'https' : 'http' }://${ config.drive.config.endPoint }${ config.drive.config.port ? `:${config.drive.config.port}` : '' }/${ config.drive.bucket }`; + log(`uploading original: ${key}`); + const uploads = [ + upload(key, fs.createReadStream(path), type) + ]; - await minio.putObject(config.drive.bucket, key, fs.createReadStream(path), size, { - 'Content-Type': type, - 'Cache-Control': 'max-age=31536000, immutable' - }); + if (webpublic) { + log(`uploading webpublic: ${webpublicKey}`); + uploads.push(upload(webpublicKey, webpublic, webpublicType)); + } if (thumbnail) { - await minio.putObject(config.drive.bucket, thumbnailKey, thumbnail, size, { - 'Content-Type': 'image/jpeg', - 'Cache-Control': 'max-age=31536000, immutable' - }); + log(`uploading thumbnail: ${thumbnailKey}`); + uploads.push(upload(thumbnailKey, thumbnail, thumbnailType)); } + await Promise.all(uploads); + + const baseUrl = config.drive.baseUrl + || `${ config.drive.config.useSSL ? 'https' : 'http' }://${ config.drive.config.endPoint }${ config.drive.config.port ? `:${config.drive.config.port}` : '' }/${ config.drive.bucket }`; + Object.assign(metadata, { withoutChunks: true, storage: 'minio', storageProps: { key: key, - thumbnailKey: thumbnailKey + webpublicKey: webpublic ? webpublicKey : null, + thumbnailKey: thumbnail ? thumbnailKey : null, }, url: `${ baseUrl }/${ key }`, + webpublicUrl: webpublic ? `${ baseUrl }/${ webpublicKey }` : null, thumbnailUrl: thumbnail ? `${ baseUrl }/${ thumbnailKey }` : null - }); + } as IMetadata); const file = await DriveFile.insert({ length: size, @@ -87,29 +178,55 @@ async function save(path: string, name: string, type: string, hash: string, size return file; } else { - // Get MongoDB GridFS bucket - const bucket = await getDriveFileBucket(); + // #region store original + const originalDst = await getDriveFileBucket(); + + // web用(Exif削除済み)がある場合はオリジナルにアクセス制限 + if (webpublic) metadata.accessKey = uuid.v4(); - const file = await new Promise<IDriveFile>((resolve, reject) => { - const writeStream = bucket.openUploadStream(name, { + const originalFile = await new Promise<IDriveFile>((resolve, reject) => { + const writeStream = originalDst.openUploadStream(name, { contentType: type, metadata }); writeStream.once('finish', resolve); writeStream.on('error', reject); - fs.createReadStream(path).pipe(writeStream); }); + log(`original stored to ${originalFile._id}`); + // #endregion store original + + // #region store webpublic + if (webpublic) { + const webDst = await getDriveFileWebpublicBucket(); + + const webFile = await new Promise<IDriveFile>((resolve, reject) => { + const writeStream = webDst.openUploadStream(name, { + contentType: webpublicType, + metadata: { + originalId: originalFile._id + } + }); + + writeStream.once('finish', resolve); + writeStream.on('error', reject); + writeStream.end(webpublic); + }); + + log(`web stored ${webFile._id}`); + } + // #endregion store webpublic + if (thumbnail) { const thumbnailBucket = await getDriveFileThumbnailBucket(); - await new Promise<IDriveFile>((resolve, reject) => { + const tuhmFile = await new Promise<IDriveFile>((resolve, reject) => { const writeStream = thumbnailBucket.openUploadStream(name, { - contentType: 'image/jpeg', + contentType: thumbnailType, metadata: { - originalId: file._id + originalId: originalFile._id } }); @@ -117,12 +234,23 @@ async function save(path: string, name: string, type: string, hash: string, size writeStream.on('error', reject); writeStream.end(thumbnail); }); + + log(`thumbnail stored ${tuhmFile._id}`); } - return file; + return originalFile; } } +async function upload(key: string, stream: fs.ReadStream | Buffer, type: string) { + const minio = new Minio.Client(config.drive.config); + + await minio.putObject(config.drive.bucket, key, stream, null, { + 'Content-Type': type, + 'Cache-Control': 'max-age=31536000, immutable' + }); +} + async function deleteOldFile(user: IRemoteUser) { const oldFile = await DriveFile.findOne({ _id: { @@ -149,6 +277,10 @@ async function deleteOldFile(user: IRemoteUser) { * @param comment Comment * @param folderId Folder ID * @param force If set to true, forcibly upload the file even if there is a file with the same hash. + * @param isLink Do not save file to local + * @param url URL of source (URLからアップロードされた場合(ローカル/リモート)の元URL) + * @param uri URL of source (リモートインスタンスのURLからアップロードされた場合の元URL) + * @param sensitive Mark file as sensitive * @return Created drive file */ export default async function( @@ -189,6 +321,8 @@ export default async function( const type = fileType(buffer); if (type) { res([type.mime, type.ext]); + } else if (isSvg(buffer)) { + res(['image/svg+xml', 'svg']) } else { // 種類が同定できなかったら application/octet-stream にする res(['application/octet-stream', null]); @@ -342,12 +476,9 @@ export default async function( properties: properties, withoutChunks: isLink, isRemote: isLink, - isSensitive: (sensitive !== null && sensitive !== undefined) - ? sensitive - : isLocalUser(user) - ? user.settings.alwaysMarkNsfw - ? true - : false + isSensitive: isLocalUser(user) && user.settings.alwaysMarkNsfw ? true : + (sensitive !== null && sensitive !== undefined) + ? sensitive : false } as IMetadata; diff --git a/src/services/drive/delete-file.ts b/src/services/drive/delete-file.ts index 3e2f42003b..609c3a86ea 100644 --- a/src/services/drive/delete-file.ts +++ b/src/services/drive/delete-file.ts @@ -4,6 +4,7 @@ import DriveFileThumbnail, { DriveFileThumbnailChunk } from '../../models/drive- import config from '../../config'; import driveChart from '../../chart/drive'; import perUserDriveChart from '../../chart/per-user-drive'; +import DriveFileWebpublic, { DriveFileWebpublicChunk } from '../../models/drive-file-webpublic'; export default async function(file: IDriveFile, isExpired = false) { if (file.metadata.storage == 'minio') { @@ -20,6 +21,11 @@ export default async function(file: IDriveFile, isExpired = false) { const thumbnailObj = file.metadata.storageProps.thumbnailKey ? file.metadata.storageProps.thumbnailKey : `${config.drive.prefix}/${file.metadata.storageProps.id}-thumbnail`; await minio.removeObject(config.drive.bucket, thumbnailObj); } + + if (file.metadata.webpublicUrl) { + const webpublicObj = file.metadata.storageProps.webpublicKey ? file.metadata.storageProps.webpublicKey : `${config.drive.prefix}/${file.metadata.storageProps.id}-original`; + await minio.removeObject(config.drive.bucket, webpublicObj); + } } // チャンクをすべて削除 @@ -27,11 +33,24 @@ export default async function(file: IDriveFile, isExpired = false) { files_id: file._id }); - await DriveFile.update({ _id: file._id }, { - $set: { - 'metadata.deletedAt': new Date(), - 'metadata.isExpired': isExpired + const set = { + metadata: { + deletedAt: new Date(), + isExpired: isExpired } + } as any; + + // リモートファイル期限切れ削除後は直リンクにする + if (isExpired && file.metadata && file.metadata._user && file.metadata._user.host != null) { + set.metadata.withoutChunks = true; + set.metadata.isRemote = true; + set.metadata.url = file.metadata.uri; + set.metadata.thumbnailUrl = undefined; + set.metadata.webpublicUrl = undefined; + } + + await DriveFile.update({ _id: file._id }, { + $set: set }); //#region サムネイルもあれば削除 @@ -48,6 +67,20 @@ export default async function(file: IDriveFile, isExpired = false) { } //#endregion + //#region Web公開用もあれば削除 + const webpublic = await DriveFileWebpublic.findOne({ + 'metadata.originalId': file._id + }); + + if (webpublic) { + await DriveFileWebpublicChunk.remove({ + files_id: webpublic._id + }); + + await DriveFileWebpublic.remove({ _id: webpublic._id }); + } + //#endregion + // 統計を更新 driveChart.update(file, false); perUserDriveChart.update(file, false); diff --git a/src/services/following/create.ts b/src/services/following/create.ts index 46b818f8bb..fac53c40af 100644 --- a/src/services/following/create.ts +++ b/src/services/following/create.ts @@ -45,8 +45,22 @@ export default async function(follower: IUser, followee: IUser, requestId?: stri // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく if (followee.isLocked || (followee.carefulBot && follower.isBot) || (isLocalUser(follower) && isRemoteUser(followee))) { - await createFollowRequest(follower, followee, requestId); - return; + let autoAccept = false; + + // フォローしているユーザーは自動承認オプション + if (isLocalUser(followee) && followee.autoAcceptFollowed) { + const followed = await Following.findOne({ + followerId: followee._id, + followeeId: follower._id + }); + + if (followed) autoAccept = true; + } + + if (!autoAccept) { + await createFollowRequest(follower, followee, requestId); + return; + } } await Following.insert({ diff --git a/src/services/following/requests/accept-all.ts b/src/services/following/requests/accept-all.ts index 45da004988..cf1a9e923d 100644 --- a/src/services/following/requests/accept-all.ts +++ b/src/services/following/requests/accept-all.ts @@ -11,10 +11,10 @@ export default async function(user: IUser) { followeeId: user._id }); - requests.forEach(async request => { + for (const request of requests) { const follower = await User.findOne({ _id: request.followerId }); accept(user, follower); - }); + } User.update({ _id: user._id }, { $set: { diff --git a/src/services/following/requests/accept.ts b/src/services/following/requests/accept.ts index 97cd733b2e..32fc874f47 100644 --- a/src/services/following/requests/accept.ts +++ b/src/services/following/requests/accept.ts @@ -1,4 +1,4 @@ -import User, { IUser, isRemoteUser, ILocalUser, pack as packUser } from '../../../models/user'; +import User, { IUser, isRemoteUser, ILocalUser, pack as packUser, isLocalUser } from '../../../models/user'; import FollowRequest from '../../../models/follow-request'; import pack from '../../../remote/activitypub/renderer'; import renderFollow from '../../../remote/activitypub/renderer/follow'; @@ -9,6 +9,8 @@ import { publishMainStream } from '../../../stream'; import perUserFollowingChart from '../../../chart/per-user-following'; export default async function(followee: IUser, follower: IUser) { + let incremented = 1; + await Following.insert({ createdAt: new Date(), followerId: follower._id, @@ -25,6 +27,13 @@ export default async function(followee: IUser, follower: IUser) { inbox: isRemoteUser(followee) ? followee.inbox : undefined, sharedInbox: isRemoteUser(followee) ? followee.sharedInbox : undefined } + }).catch(e => { + if (e.code === 11000 && isRemoteUser(follower) && isLocalUser(followee)) { + console.log(`Accept => Insert duplicated ignore. ${follower._id} => ${followee._id}`); + incremented = 0; + } else { + throw e; + } }); if (isRemoteUser(follower)) { @@ -45,7 +54,7 @@ export default async function(followee: IUser, follower: IUser) { //#region Increment following count await User.update({ _id: follower._id }, { $inc: { - followingCount: 1 + followingCount: incremented } }); //#endregion @@ -53,7 +62,7 @@ export default async function(followee: IUser, follower: IUser) { //#region Increment followers count await User.update({ _id: followee._id }, { $inc: { - followersCount: 1 + followersCount: incremented } }); //#endregion diff --git a/src/services/i/pin.ts b/src/services/i/pin.ts index 1544b0fdc7..6a42982e6c 100644 --- a/src/services/i/pin.ts +++ b/src/services/i/pin.ts @@ -101,9 +101,9 @@ export async function deliverPinnedChange(userId: mongo.ObjectID, noteId: mongo. const item = `${config.url}/notes/${noteId}`; const content = packAp(isAddition ? renderAdd(user, target, item) : renderRemove(user, target, item)); - queue.forEach(inbox => { + for (const inbox of queue) { deliver(user, content, inbox); - }); + } } /** @@ -117,14 +117,14 @@ async function CreateRemoteInboxes(user: ILocalUser): Promise<string[]> { const queue: string[] = []; - followers.map(following => { + for (const following of followers) { const follower = following._follower; if (isRemoteUser(follower)) { const inbox = follower.sharedInbox || follower.inbox; if (!queue.includes(inbox)) queue.push(inbox); } - }); + } return queue; } diff --git a/src/services/i/update.ts b/src/services/i/update.ts index 25b55b0355..242fbd3aa2 100644 --- a/src/services/i/update.ts +++ b/src/services/i/update.ts @@ -19,20 +19,20 @@ export async function publishToFollowers(userId: mongo.ObjectID) { // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 if (isLocalUser(user)) { - followers.map(following => { + for (const following of followers) { const follower = following._follower; if (isRemoteUser(follower)) { const inbox = follower.sharedInbox || follower.inbox; if (!queue.includes(inbox)) queue.push(inbox); } - }); + } if (queue.length > 0) { const content = packAp(renderUpdate(await renderPerson(user), user)); - queue.forEach(inbox => { + for (const inbox of queue) { deliver(user, content, inbox); - }); + } } } } diff --git a/src/services/note/create.ts b/src/services/note/create.ts index 0fd983d6c2..4b9f8215e1 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -21,16 +21,17 @@ import Meta from '../../models/meta'; import config from '../../config'; import registerHashtag from '../register-hashtag'; import isQuote from '../../misc/is-quote'; -import { TextElementMention } from '../../mfm/parse/elements/mention'; -import { TextElementHashtag } from '../../mfm/parse/elements/hashtag'; import notesChart from '../../chart/notes'; import perUserNotesChart from '../../chart/per-user-notes'; +import activeUsersChart from '../../chart/active-users'; -import { erase, unique } from '../../prelude/array'; +import { erase } from '../../prelude/array'; import insertNoteUnread from './unread'; import registerInstance from '../register-instance'; import Instance from '../../models/instance'; -import { TextElementEmoji } from '../../mfm/parse/elements/emoji'; +import extractMentions from '../../misc/extract-mentions'; +import extractEmojis from '../../misc/extract-emojis'; +import extractHashtags from '../../misc/extract-hashtags'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -67,8 +68,8 @@ class NotificationManager { } } - public deliver() { - this.queue.forEach(async x => { + public async deliver() { + for (const x of this.queue) { // ミュート情報を取得 const mentioneeMutes = await Mute.find({ muterId: x.target @@ -82,7 +83,7 @@ class NotificationManager { noteId: this.note._id }); } - }); + } } } @@ -100,6 +101,9 @@ type Option = { visibility?: string; visibleUsers?: IUser[]; apMentions?: IUser[]; + apHashtags?: string[]; + apEmojis?: string[]; + questionUri?: string; uri?: string; app?: IApp; }; @@ -131,16 +135,6 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< return rej('Renote target is not public or home'); } - // リプライ対象が自分以外の非公開の投稿なら禁止 - if (data.reply && data.reply.visibility == 'private' && !data.reply.userId.equals(user._id)) { - return rej('Reply target is private of others'); - } - - // Renote対象が自分以外の非公開の投稿なら禁止 - if (data.renote && data.renote.visibility == 'private' && !data.renote.userId.equals(user._id)) { - return rej('Renote target is private of others'); - } - // ローカルのみをRenoteしたらローカルのみにする if (data.renote && data.renote.localOnly) { data.localOnly = true; @@ -155,25 +149,42 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< data.text = data.text.trim(); } - // Parse MFM - const tokens = data.text ? parse(data.text) : []; + let tags = data.apHashtags; + let emojis = data.apEmojis; + let mentionedUsers = data.apMentions; - const tags = extractHashtags(tokens); + // Parse MFM if needed + if (!tags || !emojis || !mentionedUsers) { + const tokens = data.text ? parse(data.text) : []; + const cwTokens = data.cw ? parse(data.cw) : []; + const combinedTokens = tokens.concat(cwTokens); - const emojis = extractEmojis(tokens); + tags = data.apHashtags || extractHashtags(combinedTokens); - const mentionedUsers = data.apMentions || await extractMentionedUsers(tokens); + emojis = data.apEmojis || extractEmojis(combinedTokens); + + mentionedUsers = data.apMentions || await extractMentionedUsers(user, combinedTokens); + } + + // MongoDBのインデックス対象は128文字以上にできない + tags = tags.filter(tag => tag.length <= 100); if (data.reply && !user._id.equals(data.reply.userId) && !mentionedUsers.some(u => u._id.equals(data.reply.userId))) { mentionedUsers.push(await User.findOne({ _id: data.reply.userId })); } if (data.visibility == 'specified') { - data.visibleUsers.forEach(u => { + for (const u of data.visibleUsers) { if (!mentionedUsers.some(x => x._id.equals(u._id))) { mentionedUsers.push(u); } - }); + } + + for (const u of mentionedUsers) { + if (!data.visibleUsers.some(x => x._id.equals(u._id))) { + data.visibleUsers.push(u); + } + } } const note = await insertNote(user, data, tags, emojis, mentionedUsers); @@ -187,6 +198,8 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< // 統計を更新 notesChart.update(note, true); perUserNotesChart.update(user, note, true); + // ローカルユーザーのチャートはタイムライン取得時に更新しているのでリモートユーザーの場合だけでよい + if (isRemoteUser(user)) activeUsersChart.update(user); // Register host if (isRemoteUser(user)) { @@ -203,17 +216,17 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< } // ハッシュタグ登録 - tags.map(tag => registerHashtag(user, tag)); + for (const tag of tags) registerHashtag(user, tag); // ファイルが添付されていた場合ドライブのファイルの「このファイルが添付された投稿一覧」プロパティにこの投稿を追加 if (data.files) { - data.files.forEach(file => { + for (const file of data.files) { DriveFile.update({ _id: file._id }, { $push: { 'metadata.attachedNoteIds': note._id } }); - }); + } } // Increment notes count @@ -224,13 +237,13 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< // 未読通知を作成 if (data.visibility == 'specified') { - data.visibleUsers.forEach(u => { + for (const u of data.visibleUsers) { insertNoteUnread(u, note, true); - }); + } } else { - mentionedUsers.forEach(u => { + for (const u of mentionedUsers) { insertNoteUnread(u, note, false); - }); + } } if (data.reply) { @@ -263,7 +276,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< const noteActivity = await renderActivity(data, note); - if (isLocalUser(user) && note.visibility != 'private') { + if (isLocalUser(user)) { deliverNoteToMentionedRemoteUsers(mentionedUsers, user, noteActivity); } @@ -350,13 +363,20 @@ async function publish(user: IUser, note: INote, noteObj: any, reply: INote, ren deliver(user, noteActivity, renote._user.inbox); } - if (['private', 'followers', 'specified'].includes(note.visibility)) { + if (['followers', 'specified'].includes(note.visibility)) { const detailPackedNote = await pack(note, user, { detail: true }); // Publish event to myself's stream publishHomeTimelineStream(note.userId, detailPackedNote); publishHybridTimelineStream(note.userId, detailPackedNote); + + if (note.visibility == 'specified') { + for (const u of visibleUsers) { + publishHomeTimelineStream(u._id, detailPackedNote); + publishHybridTimelineStream(u._id, detailPackedNote); + } + } } else { // Publish event to myself's stream publishHomeTimelineStream(note.userId, noteObj); @@ -459,26 +479,6 @@ async function insertNote(user: IUser, data: Option, tags: string[], emojis: str } } -function extractHashtags(tokens: ReturnType<typeof parse>): string[] { - // Extract hashtags - const hashtags = tokens - .filter(t => t.type == 'hashtag') - .map(t => (t as TextElementHashtag).hashtag) - .filter(tag => tag.length <= 100); - - return unique(hashtags); -} - -function extractEmojis(tokens: ReturnType<typeof parse>): string[] { - // Extract emojis - const emojis = tokens - .filter(t => t.type == 'emoji' && t.name) - .map(t => (t as TextElementEmoji).name) - .filter(emoji => emoji.length <= 100); - - return unique(emojis); -} - function index(note: INote) { if (note.text == null || config.elasticsearch == null) return; @@ -502,9 +502,9 @@ async function notifyToWatchersOfRenotee(renote: INote, user: IUser, nm: Notific } }); - watchers.forEach(watcher => { + for (const watcher of watchers) { nm.push(watcher.userId, type); - }); + } } async function notifyToWatchersOfReplyee(reply: INote, user: IUser, nm: NotificationManager) { @@ -517,9 +517,9 @@ async function notifyToWatchersOfReplyee(reply: INote, user: IUser, nm: Notifica } }); - watchers.forEach(watcher => { + for (const watcher of watchers) { nm.push(watcher.userId, 'reply'); - }); + } } async function publishToUserLists(note: INote, noteObj: any) { @@ -527,9 +527,15 @@ async function publishToUserLists(note: INote, noteObj: any) { userIds: note.userId }); - lists.forEach(list => { - publishUserListStream(list._id, 'note', noteObj); - }); + for (const list of lists) { + if (note.visibility == 'specified') { + if (note.visibleUserIds.some(id => id.equals(list.userId))) { + publishUserListStream(list._id, 'note', noteObj); + } + } else { + publishUserListStream(list._id, 'note', noteObj); + } + } } async function publishToFollowers(note: INote, user: IUser, noteActivity: any) { @@ -544,16 +550,13 @@ async function publishToFollowers(note: INote, user: IUser, noteActivity: any) { const queue: string[] = []; - followers.map(following => { + for (const following of followers) { const follower = following._follower; if (isLocalUser(follower)) { - // ストーキングしていない場合 - if (!following.stalk) { - // この投稿が返信ならスキップ - if (note.replyId && !note._reply.userId.equals(following.followerId) && !note._reply.userId.equals(note.userId)) - return; - } + // この投稿が返信ならスキップ + if (note.replyId && !note._reply.userId.equals(following.followerId) && !note._reply.userId.equals(note.userId)) + continue; // Publish event to followers stream publishHomeTimelineStream(following.followerId, detailPackedNote); @@ -568,21 +571,21 @@ async function publishToFollowers(note: INote, user: IUser, noteActivity: any) { if (!queue.includes(inbox)) queue.push(inbox); } } - }); + } - queue.forEach(inbox => { + for (const inbox of queue) { deliver(user as any, noteActivity, inbox); - }); + } } function deliverNoteToMentionedRemoteUsers(mentionedUsers: IUser[], user: ILocalUser, noteActivity: any) { - mentionedUsers.filter(u => isRemoteUser(u)).forEach(async (u) => { + for (const u of mentionedUsers.filter(u => isRemoteUser(u))) { deliver(user, noteActivity, (u as IRemoteUser).inbox); - }); + } } -function createMentionedEvents(mentionedUsers: IUser[], note: INote, nm: NotificationManager) { - mentionedUsers.filter(u => isLocalUser(u)).forEach(async (u) => { +async function createMentionedEvents(mentionedUsers: IUser[], note: INote, nm: NotificationManager) { + for (const u of mentionedUsers.filter(u => isLocalUser(u))) { const detailPackedNote = await pack(note, u, { detail: true }); @@ -591,7 +594,7 @@ function createMentionedEvents(mentionedUsers: IUser[], note: INote, nm: Notific // Create notification nm.push(u._id, 'mention'); - }); + } } function saveQuote(renote: INote, note: INote) { @@ -604,9 +607,6 @@ function saveQuote(renote: INote, note: INote) { function saveReply(reply: INote, note: INote) { Note.update({ _id: reply._id }, { - $push: { - _replyIds: note._id - }, $inc: { repliesCount: 1 } @@ -615,6 +615,9 @@ function saveReply(reply: INote, note: INote) { function incNotesCountOfUser(user: IUser) { User.update({ _id: user._id }, { + $set: { + updatedAt: new Date() + }, $inc: { notesCount: 1 } @@ -638,16 +641,15 @@ function incNotesCount(user: IUser) { } } -async function extractMentionedUsers(tokens: ReturnType<typeof parse>): Promise<IUser[]> { +async function extractMentionedUsers(user: IUser, tokens: ReturnType<typeof parse>): Promise<IUser[]> { if (tokens == null) return []; - const mentionTokens = tokens - .filter(t => t.type == 'mention') as TextElementMention[]; + const mentions = extractMentions(tokens); let mentionedUsers = - erase(null, await Promise.all(mentionTokens.map(async m => { + erase(null, await Promise.all(mentions.map(async m => { try { - return await resolveUser(m.username, m.host); + return await resolveUser(m.username, m.host ? m.host : user.host); } catch (e) { return null; } diff --git a/src/services/note/delete.ts b/src/services/note/delete.ts index 599525ac8c..9709eeaf5e 100644 --- a/src/services/note/delete.ts +++ b/src/services/note/delete.ts @@ -44,20 +44,20 @@ export default async function(user: IUser, note: INote) { NoteUnread.find({ noteId: note._id }).then(unreads => { - unreads.forEach(unread => { + for (const unread of unreads) { read(unread.userId, unread.noteId); - }); + } }); // ファイルが添付されていた場合ドライブのファイルの「このファイルが添付された投稿一覧」プロパティからこの投稿を削除 if (note.fileIds) { - note.fileIds.forEach(fileId => { + for (const fileId of note.fileIds) { DriveFile.update({ _id: fileId }, { $pull: { 'metadata.attachedNoteIds': note._id } }); - }); + } } //#region ローカルの投稿なら削除アクティビティを配送 @@ -69,9 +69,9 @@ export default async function(user: IUser, note: INote) { '_follower.host': { $ne: null } }); - followings.forEach(following => { + for (const following of followings) { deliver(user, content, following._follower.inbox); - }); + } } //#endregion diff --git a/src/services/note/polls/vote.ts b/src/services/note/polls/vote.ts new file mode 100644 index 0000000000..dafd59331e --- /dev/null +++ b/src/services/note/polls/vote.ts @@ -0,0 +1,77 @@ +import Vote from '../../../models/poll-vote'; +import Note, { INote } from '../../../models/note'; +import Watching from '../../../models/note-watching'; +import watch from '../../../services/note/watch'; +import { publishNoteStream } from '../../../stream'; +import notify from '../../../notify'; +import { isLocalUser, IUser } from '../../../models/user'; + +export default (user: IUser, note: INote, choice: number) => new Promise(async (res, rej) => { + if (!note.poll.choices.some(x => x.id == choice)) return rej('invalid choice param'); + + // if already voted + const exist = await Vote.findOne({ + noteId: note._id, + userId: user._id + }); + + if (exist !== null) { + return rej('already voted'); + } + + // Create vote + await Vote.insert({ + createdAt: new Date(), + noteId: note._id, + userId: user._id, + choice: choice + }); + + // Send response + res(); + + const inc: any = {}; + inc[`poll.choices.${note.poll.choices.findIndex(c => c.id == choice)}.votes`] = 1; + + // Increment votes count + await Note.update({ _id: note._id }, { + $inc: inc + }); + + publishNoteStream(note._id, 'pollVoted', { + choice: choice, + userId: user._id.toHexString() + }); + + // Notify + notify(note.userId, user._id, 'poll_vote', { + noteId: note._id, + choice: choice + }); + + // Fetch watchers + Watching + .find({ + noteId: note._id, + userId: { $ne: user._id }, + // 削除されたドキュメントは除く + deletedAt: { $exists: false } + }, { + fields: { + userId: true + } + }) + .then(watchers => { + for (const watcher of watchers) { + notify(watcher.userId, user._id, 'poll_vote', { + noteId: note._id, + choice: choice + }); + } + }); + + // ローカルユーザーが投票した場合この投稿をWatchする + if (isLocalUser(user) && user.settings.autoWatch !== false) { + watch(user._id, note); + } +}); diff --git a/src/services/note/reaction/create.ts b/src/services/note/reaction/create.ts index 4a09cf535f..4f56f399a8 100644 --- a/src/services/note/reaction/create.ts +++ b/src/services/note/reaction/create.ts @@ -70,12 +70,12 @@ export default async (user: IUser, note: INote, reaction: string) => new Promise } }) .then(watchers => { - watchers.forEach(watcher => { + for (const watcher of watchers) { notify(watcher.userId, user._id, 'reaction', { noteId: note._id, reaction: reaction }); - }); + } }); // ユーザーがローカルユーザーかつ自動ウォッチ設定がオンならばこの投稿をWatchする diff --git a/src/services/note/reaction/delete.ts b/src/services/note/reaction/delete.ts new file mode 100644 index 0000000000..b108f0ba75 --- /dev/null +++ b/src/services/note/reaction/delete.ts @@ -0,0 +1,49 @@ +import { IUser, isLocalUser, isRemoteUser } from '../../../models/user'; +import Note, { INote } from '../../../models/note'; +import Reaction from '../../../models/note-reaction'; +import { publishNoteStream } from '../../../stream'; +import renderLike from '../../../remote/activitypub/renderer/like'; +import renderUndo from '../../../remote/activitypub/renderer/undo'; +import pack from '../../../remote/activitypub/renderer'; +import { deliver } from '../../../queue'; + +export default async (user: IUser, note: INote) => new Promise(async (res, rej) => { + // if already unreacted + const exist = await Reaction.findOne({ + noteId: note._id, + userId: user._id, + deletedAt: { $exists: false } + }); + + if (exist === null) { + return rej('never reacted'); + } + + // Delete reaction + await Reaction.remove({ + _id: exist._id + }); + + res(); + + const dec: any = {}; + dec[`reactionCounts.${exist.reaction}`] = -1; + + // Decrement reactions count + Note.update({ _id: note._id }, { + $inc: dec + }); + + publishNoteStream(note._id, 'unreacted', { + reaction: exist.reaction, + userId: user._id + }); + + //#region 配信 + // リアクターがローカルユーザーかつリアクション対象がリモートユーザーの投稿なら配送 + if (isLocalUser(user) && isRemoteUser(note._user)) { + const content = pack(renderUndo(renderLike(user, note, exist.reaction), user)); + deliver(user, content, note._user.inbox); + } + //#endregion +}); diff --git a/src/stream.ts b/src/stream.ts index 8ca8c3254c..596cb98e72 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -1,31 +1,17 @@ import * as mongo from 'mongodb'; import redis from './db/redis'; import Xev from 'xev'; -import { IMeta } from './models/meta'; -import fetchMeta from './misc/fetch-meta'; type ID = string | mongo.ObjectID; class Publisher { private ev: Xev; - private meta: IMeta; constructor() { // Redisがインストールされてないときはプロセス間通信を使う if (redis == null) { this.ev = new Xev(); } - - setInterval(async () => { - this.meta = await fetchMeta(); - }, 5000); - } - - public fetchMeta = async () => { - if (this.meta != null) return this.meta; - - this.meta = await fetchMeta(); - return this.meta; } private publish = (channel: string, type: string, value?: any): void => { @@ -83,14 +69,10 @@ class Publisher { } public publishLocalTimelineStream = async (note: any): Promise<void> => { - const meta = await this.fetchMeta(); - if (meta.disableLocalTimeline) return; this.publish('localTimeline', null, note); } public publishHybridTimelineStream = async (userId: ID, note: any): Promise<void> => { - const meta = await this.fetchMeta(); - if (meta.disableLocalTimeline) return; this.publish(userId ? `hybridTimeline:${userId}` : 'hybridTimeline', null, note); } diff --git a/src/tools/add-emoji.ts b/src/tools/add-emoji.ts index 875af55c14..e40c96b79d 100644 --- a/src/tools/add-emoji.ts +++ b/src/tools/add-emoji.ts @@ -1,5 +1,5 @@ import * as debug from 'debug'; -import Emoji from "../models/emoji"; +import Emoji from '../models/emoji'; debug.enable('*'); diff --git a/src/tools/resync-remote-user.ts b/src/tools/resync-remote-user.ts index 3a63512f45..c013de723f 100644 --- a/src/tools/resync-remote-user.ts +++ b/src/tools/resync-remote-user.ts @@ -1,4 +1,4 @@ -import parseAcct from "../misc/acct/parse"; +import parseAcct from '../misc/acct/parse'; import resolveUser from '../remote/resolve-user'; import * as debug from 'debug'; diff --git a/src/tools/show-signin-history.ts b/src/tools/show-signin-history.ts new file mode 100644 index 0000000000..e770710322 --- /dev/null +++ b/src/tools/show-signin-history.ts @@ -0,0 +1,57 @@ +// node built/tools/show-signin-history username +// => {Success} {Date} {IPAddrsss} + +// node built/tools/show-signin-history username user-agent,x-forwarded-for +// with user-agent and x-forwarded-for + +// node built/tools/show-signin-history username all +// with full request headers + +import User from '../models/user'; +import Signin from '../models/signin'; + +async function main(username: string, headers: string[]) { + const user = await User.findOne({ + host: null, + usernameLower: username.toLowerCase(), + }); + + if (user === null) throw 'User not found'; + + const history = await Signin.find({ + userId: user._id + }); + + for (const signin of history) { + console.log(`${signin.success ? 'OK' : 'NG'} ${signin.createdAt ? signin.createdAt.toISOString() : 'Unknown'} ${signin.ip}`); + + // headers + if (headers != null) { + for (const key of Object.keys(signin.headers)) { + if (headers.includes('all') || headers.includes(key)) { + console.log(` ${key}: ${signin.headers[key]}`); + } + } + } + } +} + +// get args +const args = process.argv.slice(2); + +let username = args[0]; +let headers: string[]; + +if (args[1] != null) { + headers = args[1].split(/,/).map(header => header.toLowerCase()); +} + +// normalize args +username = username.replace(/^@/, ''); + +main(username, headers).then(() => { + process.exit(0); +}).catch(e => { + console.warn(e); + process.exit(1); +}); diff --git a/test/api.ts b/test/api.ts index 6debea4d12..d82014e754 100644 --- a/test/api.ts +++ b/test/api.ts @@ -192,7 +192,7 @@ describe('API', () => { password: 'foo' }); - expect(res).have.status(204); + expect(res).have.status(200); })); }); @@ -314,6 +314,7 @@ describe('API', () => { const file = await uploadFile(bob); const res = await request('/notes/create', { + text: 'test', fileIds: [file.id] }, me); @@ -327,6 +328,7 @@ describe('API', () => { const me = await signup(); const res = await request('/notes/create', { + text: 'test', fileIds: ['000000000000000000000000'] }, me); @@ -806,6 +808,20 @@ describe('API', () => { expect(res).have.status(400); })); + + it('SVGファイルを作成できる', async(async () => { + const izumi = await signup({ username: 'izumi' }); + + const res = await assert.request(server) + .post('/drive/files/create') + .field('i', izumi.token) + .attach('file', fs.readFileSync(__dirname + '/resources/image.svg'), 'image.svg'); + + expect(res).have.status(200); + expect(res.body).be.a('object'); + expect(res.body).have.property('name').eql('image.svg'); + expect(res.body).have.property('type').eql('image/svg+xml'); + })); }); describe('drive/files/update', () => { diff --git a/test/extract-mentions.ts b/test/extract-mentions.ts new file mode 100644 index 0000000000..b32f5dd4bb --- /dev/null +++ b/test/extract-mentions.ts @@ -0,0 +1,48 @@ +import * as assert from 'assert'; + +import extractMentions from '../src/misc/extract-mentions'; +import parse from '../src/mfm/parse'; + +describe('Extract mentions', () => { + it('simple', () => { + const ast = parse('@foo @bar @baz'); + const mentions = extractMentions(ast); + assert.deepStrictEqual(mentions, [{ + username: 'foo', + acct: '@foo', + canonical: '@foo', + host: null + }, { + username: 'bar', + acct: '@bar', + canonical: '@bar', + host: null + }, { + username: 'baz', + acct: '@baz', + canonical: '@baz', + host: null + }]); + }); + + it('nested', () => { + const ast = parse('@foo **@bar** @baz'); + const mentions = extractMentions(ast); + assert.deepStrictEqual(mentions, [{ + username: 'foo', + acct: '@foo', + canonical: '@foo', + host: null + }, { + username: 'bar', + acct: '@bar', + canonical: '@bar', + host: null + }, { + username: 'baz', + acct: '@baz', + canonical: '@baz', + host: null + }]); + }); +}); diff --git a/test/mfm.ts b/test/mfm.ts index 017144545a..e838492eb3 100644 --- a/test/mfm.ts +++ b/test/mfm.ts @@ -1,363 +1,996 @@ /* * Tests of MFM + * How to run the tests: + * > mocha test/mfm.ts --require ts-node/register */ import * as assert from 'assert'; import analyze from '../src/mfm/parse'; import toHtml from '../src/mfm/html'; -import syntaxhighlighter from '../src/mfm/parse/core/syntax-highlighter'; +import { createTree as tree, createLeaf as leaf, MfmTree, removeOrphanedBrackets } from '../src/mfm/parser'; -describe('Text', () => { - it('can be analyzed', () => { - const tokens = analyze('@himawari @hima_sub@namori.net お腹ペコい :cat: #yryr'); - assert.deepEqual([ - { type: 'mention', content: '@himawari', canonical: '@himawari', username: 'himawari', host: null }, - { type: 'text', content: ' ' }, - { type: 'mention', content: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' }, - { type: 'text', content: ' お腹ペコい ' }, - { type: 'emoji', content: ':cat:', name: 'cat' }, - { type: 'text', content: ' ' }, - { type: 'hashtag', content: '#yryr', hashtag: 'yryr' } - ], tokens); +function text(text: string): MfmTree { + return leaf('text', { text }); +} + +describe('createLeaf', () => { + it('creates leaf', () => { + assert.deepStrictEqual(leaf('text', { text: 'abc' }), { + node: { + type: 'text', + props: { + text: 'abc' + } + }, + children: [], + }); + }); +}); + +describe('createTree', () => { + it('creates tree', () => { + const t = tree('tree', [ + leaf('left', { a: 2 }), + leaf('right', { b: 'hi' }) + ], { + c: 4 + }); + assert.deepStrictEqual(t, { + node: { + type: 'tree', + props: { + c: 4 + } + }, + children: [ + leaf('left', { a: 2 }), + leaf('right', { b: 'hi' }) + ], + }); + }); +}); + +describe('removeOrphanedBrackets', () => { + it('single (contained)', () => { + const input = '(foo)'; + const expected = '(foo)'; + const actual = removeOrphanedBrackets(input); + assert.deepStrictEqual(actual, expected); + }); + + it('single (head)', () => { + const input = '(foo)bar'; + const expected = '(foo)bar'; + const actual = removeOrphanedBrackets(input); + assert.deepStrictEqual(actual, expected); + }); + + it('single (tail)', () => { + const input = 'foo(bar)'; + const expected = 'foo(bar)'; + const actual = removeOrphanedBrackets(input); + assert.deepStrictEqual(actual, expected); + }); + + it('a', () => { + const input = '(foo'; + const expected = ''; + const actual = removeOrphanedBrackets(input); + assert.deepStrictEqual(actual, expected); + }); + + it('b', () => { + const input = ')foo'; + const expected = ''; + const actual = removeOrphanedBrackets(input); + assert.deepStrictEqual(actual, expected); + }); + + it('nested', () => { + const input = 'foo(「(bar)」)'; + const expected = 'foo(「(bar)」)'; + const actual = removeOrphanedBrackets(input); + assert.deepStrictEqual(actual, expected); + }); + + it('no brackets', () => { + const input = 'foo'; + const expected = 'foo'; + const actual = removeOrphanedBrackets(input); + assert.deepStrictEqual(actual, expected); + }); + + it('with foreign bracket (single)', () => { + const input = 'foo(bar))'; + const expected = 'foo(bar)'; + const actual = removeOrphanedBrackets(input); + assert.deepStrictEqual(actual, expected); + }); + + it('with foreign bracket (open)', () => { + const input = 'foo(bar'; + const expected = 'foo'; + const actual = removeOrphanedBrackets(input); + assert.deepStrictEqual(actual, expected); + }); + + it('with foreign bracket (close)', () => { + const input = 'foo)bar'; + const expected = 'foo'; + const actual = removeOrphanedBrackets(input); + assert.deepStrictEqual(actual, expected); + }); + + it('with foreign bracket (close and open)', () => { + const input = 'foo)(bar'; + const expected = 'foo'; + const actual = removeOrphanedBrackets(input); + assert.deepStrictEqual(actual, expected); }); - it('can be inverted', () => { - const text = '@himawari @hima_sub@namori.net お腹ペコい :cat: #yryr'; - assert.equal(analyze(text).map(x => x.content).join(''), text); + it('various bracket type', () => { + const input = 'foo「(bar)」('; + const expected = 'foo「(bar)」'; + const actual = removeOrphanedBrackets(input); + assert.deepStrictEqual(actual, expected); + }); + + it('intersected', () => { + const input = 'foo(「)」'; + const expected = 'foo(「)」'; + const actual = removeOrphanedBrackets(input); + assert.deepStrictEqual(actual, expected); + }); +}); + +describe('MFM', () => { + it('can be analyzed', () => { + const tokens = analyze('@himawari @hima_sub@namori.net お腹ペコい :cat: #yryr'); + assert.deepStrictEqual(tokens, [ + leaf('mention', { acct: '@himawari', canonical: '@himawari', username: 'himawari', host: null }), + text(' '), + leaf('mention', { acct: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' }), + text(' お腹ペコい '), + leaf('emoji', { name: 'cat' }), + text(' '), + leaf('hashtag', { hashtag: 'yryr' }), + ]); }); describe('elements', () => { - it('bold', () => { - const tokens = analyze('**Strawberry** Pasta'); - assert.deepEqual([ - { type: 'bold', content: '**Strawberry**', bold: 'Strawberry' }, - { type: 'text', content: ' Pasta' } - ], tokens); + describe('bold', () => { + it('simple', () => { + const tokens = analyze('**foo**'); + assert.deepStrictEqual(tokens, [ + tree('bold', [ + text('foo') + ], {}), + ]); + }); + + it('with other texts', () => { + const tokens = analyze('bar**foo**bar'); + assert.deepStrictEqual(tokens, [ + text('bar'), + tree('bold', [ + text('foo') + ], {}), + text('bar'), + ]); + }); + + it('with underscores', () => { + const tokens = analyze('__foo__'); + assert.deepStrictEqual(tokens, [ + tree('bold', [ + text('foo') + ], {}), + ]); + }); + + it('with underscores (ensure it allows alphabet only)', () => { + const tokens = analyze('(=^・__________・^=)'); + assert.deepStrictEqual(tokens, [ + text('(=^・__________・^=)') + ]); + }); + + it('mixed syntax', () => { + const tokens = analyze('**foo__'); + assert.deepStrictEqual(tokens, [ + text('**foo__'), + ]); + }); + + it('mixed syntax', () => { + const tokens = analyze('__foo**'); + assert.deepStrictEqual(tokens, [ + text('__foo**'), + ]); + }); }); it('big', () => { const tokens = analyze('***Strawberry*** Pasta'); - assert.deepEqual([ - { type: 'big', content: '***Strawberry***', big: 'Strawberry' }, - { type: 'text', content: ' Pasta' } - ], tokens); + assert.deepStrictEqual(tokens, [ + tree('big', [ + text('Strawberry') + ], {}), + text(' Pasta'), + ]); }); - it('motion', () => { - const tokens1 = analyze('(((Strawberry))) Pasta'); - assert.deepEqual([ - { type: 'motion', content: '(((Strawberry)))', motion: 'Strawberry' }, - { type: 'text', content: ' Pasta' } - ], tokens1); + it('small', () => { + const tokens = analyze('<small>smaller</small>'); + assert.deepStrictEqual(tokens, [ + tree('small', [ + text('smaller') + ], {}), + ]); + }); - const tokens2 = analyze('<motion>Strawberry</motion> Pasta'); - assert.deepEqual([ - { type: 'motion', content: '<motion>Strawberry</motion>', motion: 'Strawberry' }, - { type: 'text', content: ' Pasta' } - ], tokens2); + describe('motion', () => { + it('by triple brackets', () => { + const tokens = analyze('(((foo)))'); + assert.deepStrictEqual(tokens, [ + tree('motion', [ + text('foo') + ], {}), + ]); + }); + + it('by triple brackets (with other texts)', () => { + const tokens = analyze('bar(((foo)))bar'); + assert.deepStrictEqual(tokens, [ + text('bar'), + tree('motion', [ + text('foo') + ], {}), + text('bar'), + ]); + }); + + it('by <motion> tag', () => { + const tokens = analyze('<motion>foo</motion>'); + assert.deepStrictEqual(tokens, [ + tree('motion', [ + text('foo') + ], {}), + ]); + }); + + it('by <motion> tag (with other texts)', () => { + const tokens = analyze('bar<motion>foo</motion>bar'); + assert.deepStrictEqual(tokens, [ + text('bar'), + tree('motion', [ + text('foo') + ], {}), + text('bar'), + ]); + }); }); describe('mention', () => { it('local', () => { - const tokens = analyze('@himawari お腹ペコい'); - assert.deepEqual([ - { type: 'mention', content: '@himawari', canonical: '@himawari', username: 'himawari', host: null }, - { type: 'text', content: ' お腹ペコい' } - ], tokens); + const tokens = analyze('@himawari foo'); + assert.deepStrictEqual(tokens, [ + leaf('mention', { acct: '@himawari', canonical: '@himawari', username: 'himawari', host: null }), + text(' foo') + ]); }); it('remote', () => { - const tokens = analyze('@hima_sub@namori.net お腹ペコい'); - assert.deepEqual([ - { type: 'mention', content: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' }, - { type: 'text', content: ' お腹ペコい' } - ], tokens); + const tokens = analyze('@hima_sub@namori.net foo'); + assert.deepStrictEqual(tokens, [ + leaf('mention', { acct: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' }), + text(' foo') + ]); }); it('remote punycode', () => { - const tokens = analyze('@hima_sub@xn--q9j5bya.xn--zckzah お腹ペコい'); - assert.deepEqual([ - { type: 'mention', content: '@hima_sub@xn--q9j5bya.xn--zckzah', canonical: '@hima_sub@なもり.テスト', username: 'hima_sub', host: 'xn--q9j5bya.xn--zckzah' }, - { type: 'text', content: ' お腹ペコい' } - ], tokens); + const tokens = analyze('@hima_sub@xn--q9j5bya.xn--zckzah foo'); + assert.deepStrictEqual(tokens, [ + leaf('mention', { acct: '@hima_sub@xn--q9j5bya.xn--zckzah', canonical: '@hima_sub@なもり.テスト', username: 'hima_sub', host: 'xn--q9j5bya.xn--zckzah' }), + text(' foo') + ]); }); it('ignore', () => { const tokens = analyze('idolm@ster'); - assert.deepEqual([ - { type: 'text', content: 'idolm@ster' } - ], tokens); + assert.deepStrictEqual(tokens, [ + text('idolm@ster') + ]); const tokens2 = analyze('@a\n@b\n@c'); - assert.deepEqual([ - { type: 'mention', content: '@a', canonical: '@a', username: 'a', host: null }, - { type: 'text', content: '\n' }, - { type: 'mention', content: '@b', canonical: '@b', username: 'b', host: null }, - { type: 'text', content: '\n' }, - { type: 'mention', content: '@c', canonical: '@c', username: 'c', host: null } - ], tokens2); + assert.deepStrictEqual(tokens2, [ + leaf('mention', { acct: '@a', canonical: '@a', username: 'a', host: null }), + text('\n'), + leaf('mention', { acct: '@b', canonical: '@b', username: 'b', host: null }), + text('\n'), + leaf('mention', { acct: '@c', canonical: '@c', username: 'c', host: null }) + ]); const tokens3 = analyze('**x**@a'); - assert.deepEqual([ - { type: 'bold', content: '**x**', bold: 'x' }, - { type: 'mention', content: '@a', canonical: '@a', username: 'a', host: null } - ], tokens3); + assert.deepStrictEqual(tokens3, [ + tree('bold', [ + text('x') + ], {}), + leaf('mention', { acct: '@a', canonical: '@a', username: 'a', host: null }) + ]); + + const tokens4 = analyze('@\n@v\n@veryverylongusername' /* \n@toolongtobeasamention */); + assert.deepStrictEqual(tokens4, [ + text('@\n'), + leaf('mention', { acct: '@v', canonical: '@v', username: 'v', host: null }), + text('\n'), + leaf('mention', { acct: '@veryverylongusername', canonical: '@veryverylongusername', username: 'veryverylongusername', host: null }), + // text('\n@toolongtobeasamention') + ]); + /* + const tokens5 = analyze('@domain_is@valid.example.com\n@domain_is@.invalid\n@domain_is@invali.d\n@domain_is@invali.d\n@domain_is@-invalid.com\n@domain_is@invalid-.com'); + assert.deepStrictEqual([ + leaf('mention', { acct: '@domain_is@valid.example.com', canonical: '@domain_is@valid.example.com', username: 'domain_is', host: 'valid.example.com' }), + text('\n@domain_is@.invalid\n@domain_is@invali.d\n@domain_is@invali.d\n@domain_is@-invalid.com\n@domain_is@invalid-.com') + ], tokens5); + */ }); }); - it('hashtag', () => { - const tokens1 = analyze('Strawberry Pasta #alice'); - assert.deepEqual([ - { type: 'text', content: 'Strawberry Pasta ' }, - { type: 'hashtag', content: '#alice', hashtag: 'alice' } - ], tokens1); + describe('hashtag', () => { + it('simple', () => { + const tokens = analyze('#alice'); + assert.deepStrictEqual(tokens, [ + leaf('hashtag', { hashtag: 'alice' }) + ]); + }); + + it('after line break', () => { + const tokens = analyze('foo\n#alice'); + assert.deepStrictEqual(tokens, [ + text('foo\n'), + leaf('hashtag', { hashtag: 'alice' }) + ]); + }); + + it('with text', () => { + const tokens = analyze('Strawberry Pasta #alice'); + assert.deepStrictEqual(tokens, [ + text('Strawberry Pasta '), + leaf('hashtag', { hashtag: 'alice' }) + ]); + }); - const tokens2 = analyze('Foo #bar, baz #piyo.'); - assert.deepEqual([ - { type: 'text', content: 'Foo ' }, - { type: 'hashtag', content: '#bar', hashtag: 'bar' }, - { type: 'text', content: ', baz ' }, - { type: 'hashtag', content: '#piyo', hashtag: 'piyo' }, - { type: 'text', content: '.' } - ], tokens2); + it('with text (zenkaku)', () => { + const tokens = analyze('こんにちは#世界'); + assert.deepStrictEqual(tokens, [ + text('こんにちは'), + leaf('hashtag', { hashtag: '世界' }) + ]); + }); + + it('ignore comma and period', () => { + const tokens = analyze('Foo #bar, baz #piyo.'); + assert.deepStrictEqual(tokens, [ + text('Foo '), + leaf('hashtag', { hashtag: 'bar' }), + text(', baz '), + leaf('hashtag', { hashtag: 'piyo' }), + text('.'), + ]); + }); - const tokens3 = analyze('#Foo!'); - assert.deepEqual([ - { type: 'hashtag', content: '#Foo', hashtag: 'Foo' }, - { type: 'text', content: '!' }, - ], tokens3); + it('ignore exclamation mark', () => { + const tokens = analyze('#Foo!'); + assert.deepStrictEqual(tokens, [ + leaf('hashtag', { hashtag: 'Foo' }), + text('!'), + ]); + }); + + it('ignore colon', () => { + const tokens = analyze('#Foo:'); + assert.deepStrictEqual(tokens, [ + leaf('hashtag', { hashtag: 'Foo' }), + text(':'), + ]); + }); + + it('ignore single quote', () => { + const tokens = analyze('#foo\''); + assert.deepStrictEqual(tokens, [ + leaf('hashtag', { hashtag: 'foo' }), + text('\''), + ]); + }); + + it('ignore double quote', () => { + const tokens = analyze('#foo"'); + assert.deepStrictEqual(tokens, [ + leaf('hashtag', { hashtag: 'foo' }), + text('"'), + ]); + }); + + it('allow including number', () => { + const tokens = analyze('#foo123'); + assert.deepStrictEqual(tokens, [ + leaf('hashtag', { hashtag: 'foo123' }), + ]); + }); + + it('with brackets', () => { + const tokens1 = analyze('(#foo)'); + assert.deepStrictEqual(tokens1, [ + text('('), + leaf('hashtag', { hashtag: 'foo' }), + text(')'), + ]); + + const tokens2 = analyze('「#foo」'); + assert.deepStrictEqual(tokens2, [ + text('「'), + leaf('hashtag', { hashtag: 'foo' }), + text('」'), + ]); + }); + + it('with mixed brackets', () => { + const tokens = analyze('「#foo(bar)」'); + assert.deepStrictEqual(tokens, [ + text('「'), + leaf('hashtag', { hashtag: 'foo(bar)' }), + text('」'), + ]); + }); + + it('with brackets (space before)', () => { + const tokens1 = analyze('(bar #foo)'); + assert.deepStrictEqual(tokens1, [ + text('(bar '), + leaf('hashtag', { hashtag: 'foo' }), + text(')'), + ]); + + const tokens2 = analyze('「bar #foo」'); + assert.deepStrictEqual(tokens2, [ + text('「bar '), + leaf('hashtag', { hashtag: 'foo' }), + text('」'), + ]); + }); + + it('disallow number only', () => { + const tokens = analyze('#123'); + assert.deepStrictEqual(tokens, [ + text('#123'), + ]); + }); + + it('disallow number only (with brackets)', () => { + const tokens = analyze('(#123)'); + assert.deepStrictEqual(tokens, [ + text('(#123)'), + ]); + }); }); - it('quote', () => { - const tokens1 = analyze('> foo\nbar\nbaz'); - assert.deepEqual([ - { type: 'quote', content: '> foo\nbar\nbaz', quote: 'foo\nbar\nbaz' } - ], tokens1); + describe('quote', () => { + it('basic', () => { + const tokens1 = analyze('> foo'); + assert.deepStrictEqual(tokens1, [ + tree('quote', [ + text('foo') + ], {}) + ]); + + const tokens2 = analyze('>foo'); + assert.deepStrictEqual(tokens2, [ + tree('quote', [ + text('foo') + ], {}) + ]); + }); + + it('series', () => { + const tokens = analyze('> foo\n\n> bar'); + assert.deepStrictEqual(tokens, [ + tree('quote', [ + text('foo') + ], {}), + text('\n'), + tree('quote', [ + text('bar') + ], {}), + ]); + }); + + it('trailing line break', () => { + const tokens1 = analyze('> foo\n'); + assert.deepStrictEqual(tokens1, [ + tree('quote', [ + text('foo') + ], {}), + ]); + + const tokens2 = analyze('> foo\n\n'); + assert.deepStrictEqual(tokens2, [ + tree('quote', [ + text('foo') + ], {}), + text('\n') + ]); + }); + + it('multiline', () => { + const tokens1 = analyze('>foo\n>bar'); + assert.deepStrictEqual(tokens1, [ + tree('quote', [ + text('foo\nbar') + ], {}) + ]); + + const tokens2 = analyze('> foo\n> bar'); + assert.deepStrictEqual(tokens2, [ + tree('quote', [ + text('foo\nbar') + ], {}) + ]); + }); + + it('multiline with trailing line break', () => { + const tokens1 = analyze('> foo\n> bar\n'); + assert.deepStrictEqual(tokens1, [ + tree('quote', [ + text('foo\nbar') + ], {}), + ]); + + const tokens2 = analyze('> foo\n> bar\n\n'); + assert.deepStrictEqual(tokens2, [ + tree('quote', [ + text('foo\nbar') + ], {}), + text('\n') + ]); + }); + + it('with before and after texts', () => { + const tokens = analyze('before\n> foo\nafter'); + assert.deepStrictEqual(tokens, [ + text('before\n'), + tree('quote', [ + text('foo') + ], {}), + text('after'), + ]); + }); - const tokens2 = analyze('before\n> foo\nbar\nbaz\n\nafter'); - assert.deepEqual([ - { type: 'text', content: 'before' }, - { type: 'quote', content: '\n> foo\nbar\nbaz\n\n', quote: 'foo\nbar\nbaz' }, - { type: 'text', content: 'after' } - ], tokens2); + it('multiple quotes', () => { + const tokens = analyze('> foo\nbar\n\n> foo\nbar\n\n> foo\nbar'); + assert.deepStrictEqual(tokens, [ + tree('quote', [ + text('foo') + ], {}), + text('bar\n\n'), + tree('quote', [ + text('foo') + ], {}), + text('bar\n\n'), + tree('quote', [ + text('foo') + ], {}), + text('bar'), + ]); + }); - const tokens3 = analyze('piyo> foo\nbar\nbaz'); - assert.deepEqual([ - { type: 'text', content: 'piyo> foo\nbar\nbaz' } - ], tokens3); + it('require line break before ">"', () => { + const tokens = analyze('foo>bar'); + assert.deepStrictEqual(tokens, [ + text('foo>bar'), + ]); + }); - const tokens4 = analyze('> foo\n> bar\n> baz'); - assert.deepEqual([ - { type: 'quote', content: '> foo\n> bar\n> baz', quote: 'foo\nbar\nbaz' } - ], tokens4); + it('nested', () => { + const tokens = analyze('>> foo\n> bar'); + assert.deepStrictEqual(tokens, [ + tree('quote', [ + tree('quote', [ + text('foo') + ], {}), + text('bar') + ], {}) + ]); + }); - const tokens5 = analyze('"\nfoo\nbar\nbaz\n"'); - assert.deepEqual([ - { type: 'quote', content: '"\nfoo\nbar\nbaz\n"', quote: 'foo\nbar\nbaz' } - ], tokens5); + it('trim line breaks', () => { + const tokens = analyze('foo\n\n>a\n>>b\n>>\n>>>\n>>>c\n>>>\n>d\n\n'); + assert.deepStrictEqual(tokens, [ + text('foo\n\n'), + tree('quote', [ + text('a\n'), + tree('quote', [ + text('b\n\n'), + tree('quote', [ + text('\nc\n') + ], {}) + ], {}), + text('d') + ], {}), + text('\n'), + ]); + }); }); describe('url', () => { it('simple', () => { const tokens = analyze('https://example.com'); - assert.deepEqual([{ - type: 'url', - content: 'https://example.com', - url: 'https://example.com' - }], tokens); + assert.deepStrictEqual(tokens, [ + leaf('url', { url: 'https://example.com' }) + ]); }); it('ignore trailing period', () => { const tokens = analyze('https://example.com.'); - assert.deepEqual([{ - type: 'url', - content: 'https://example.com', - url: 'https://example.com' - }, { - type: 'text', content: '.' - }], tokens); + assert.deepStrictEqual(tokens, [ + leaf('url', { url: 'https://example.com' }), + text('.') + ]); }); it('with comma', () => { const tokens = analyze('https://example.com/foo?bar=a,b'); - assert.deepEqual([{ - type: 'url', - content: 'https://example.com/foo?bar=a,b', - url: 'https://example.com/foo?bar=a,b' - }], tokens); + assert.deepStrictEqual(tokens, [ + leaf('url', { url: 'https://example.com/foo?bar=a,b' }) + ]); }); it('ignore trailing comma', () => { const tokens = analyze('https://example.com/foo, bar'); - assert.deepEqual([{ - type: 'url', - content: 'https://example.com/foo', - url: 'https://example.com/foo' - }, { - type: 'text', content: ', bar' - }], tokens); + assert.deepStrictEqual(tokens, [ + leaf('url', { url: 'https://example.com/foo' }), + text(', bar') + ]); }); it('with brackets', () => { const tokens = analyze('https://example.com/foo(bar)'); - assert.deepEqual([{ - type: 'url', - content: 'https://example.com/foo(bar)', - url: 'https://example.com/foo(bar)' - }], tokens); + assert.deepStrictEqual(tokens, [ + leaf('url', { url: 'https://example.com/foo(bar)' }) + ]); }); it('ignore parent brackets', () => { const tokens = analyze('(https://example.com/foo)'); - assert.deepEqual([{ - type: 'text', content: '(' - }, { - type: 'url', - content: 'https://example.com/foo', - url: 'https://example.com/foo' - }, { - type: 'text', content: ')' - }], tokens); + assert.deepStrictEqual(tokens, [ + text('('), + leaf('url', { url: 'https://example.com/foo' }), + text(')') + ]); + }); + + it('ignore parent brackets 2', () => { + const tokens = analyze('(foo https://example.com/foo)'); + assert.deepStrictEqual(tokens, [ + text('(foo '), + leaf('url', { url: 'https://example.com/foo' }), + text(')') + ]); }); it('ignore parent brackets with internal brackets', () => { const tokens = analyze('(https://example.com/foo(bar))'); - assert.deepEqual([{ - type: 'text', content: '(' - }, { - type: 'url', - content: 'https://example.com/foo(bar)', - url: 'https://example.com/foo(bar)' - }, { - type: 'text', content: ')' - }], tokens); + assert.deepStrictEqual(tokens, [ + text('('), + leaf('url', { url: 'https://example.com/foo(bar)' }), + text(')') + ]); }); }); - it('link', () => { - const tokens = analyze('[ひまさく](https://himasaku.net)'); - assert.deepEqual([{ - type: 'link', - content: '[ひまさく](https://himasaku.net)', - title: 'ひまさく', - url: 'https://himasaku.net', - silent: false - }], tokens); + describe('link', () => { + it('simple', () => { + const tokens = analyze('[foo](https://example.com)'); + assert.deepStrictEqual(tokens, [ + tree('link', [ + text('foo') + ], { url: 'https://example.com', silent: false }) + ]); + }); + + it('simple (with silent flag)', () => { + const tokens = analyze('?[foo](https://example.com)'); + assert.deepStrictEqual(tokens, [ + tree('link', [ + text('foo') + ], { url: 'https://example.com', silent: true }) + ]); + }); + + it('in text', () => { + const tokens = analyze('before[foo](https://example.com)after'); + assert.deepStrictEqual(tokens, [ + text('before'), + tree('link', [ + text('foo') + ], { url: 'https://example.com', silent: false }), + text('after'), + ]); + }); + + it('with brackets', () => { + const tokens = analyze('[foo](https://example.com/foo(bar))'); + assert.deepStrictEqual(tokens, [ + tree('link', [ + text('foo') + ], { url: 'https://example.com/foo(bar)', silent: false }) + ]); + }); + + it('with parent brackets', () => { + const tokens = analyze('([foo](https://example.com/foo(bar)))'); + assert.deepStrictEqual(tokens, [ + text('('), + tree('link', [ + text('foo') + ], { url: 'https://example.com/foo(bar)', silent: false }), + text(')') + ]); + }); }); it('emoji', () => { const tokens1 = analyze(':cat:'); - assert.deepEqual([ - { type: 'emoji', content: ':cat:', name: 'cat' } - ], tokens1); + assert.deepStrictEqual(tokens1, [ + leaf('emoji', { name: 'cat' }) + ]); const tokens2 = analyze(':cat::cat::cat:'); - assert.deepEqual([ - { type: 'emoji', content: ':cat:', name: 'cat' }, - { type: 'emoji', content: ':cat:', name: 'cat' }, - { type: 'emoji', content: ':cat:', name: 'cat' } - ], tokens2); + assert.deepStrictEqual(tokens2, [ + leaf('emoji', { name: 'cat' }), + leaf('emoji', { name: 'cat' }), + leaf('emoji', { name: 'cat' }) + ]); const tokens3 = analyze('🍎'); - assert.deepEqual([ - { type: 'emoji', content: '🍎', emoji: '🍎' } - ], tokens3); + assert.deepStrictEqual(tokens3, [ + leaf('emoji', { emoji: '🍎' }) + ]); }); - it('block code', () => { - const tokens = analyze('```\nvar x = "Strawberry Pasta";\n```'); - assert.equal(tokens[0].type, 'code'); - assert.equal(tokens[0].content, '```\nvar x = "Strawberry Pasta";\n```'); + describe('block code', () => { + it('simple', () => { + const tokens = analyze('```\nvar x = "Strawberry Pasta";\n```'); + assert.deepStrictEqual(tokens, [ + leaf('blockCode', { code: 'var x = "Strawberry Pasta";', lang: null }) + ]); + }); + + it('can specify language', () => { + const tokens = analyze('``` json\n{ "x": 42 }\n```'); + assert.deepStrictEqual(tokens, [ + leaf('blockCode', { code: '{ "x": 42 }', lang: 'json' }) + ]); + }); + + it('require line break before "```"', () => { + const tokens = analyze('before```\nfoo\n```'); + assert.deepStrictEqual(tokens, [ + text('before'), + leaf('inlineCode', { code: '`' }), + text('\nfoo\n'), + leaf('inlineCode', { code: '`' }) + ]); + }); + + it('series', () => { + const tokens = analyze('```\nfoo\n```\n```\nbar\n```\n```\nbaz\n```'); + assert.deepStrictEqual(tokens, [ + leaf('blockCode', { code: 'foo', lang: null }), + leaf('blockCode', { code: 'bar', lang: null }), + leaf('blockCode', { code: 'baz', lang: null }), + ]); + }); + + it('ignore internal marker', () => { + const tokens = analyze('```\naaa```bbb\n```'); + assert.deepStrictEqual(tokens, [ + leaf('blockCode', { code: 'aaa```bbb', lang: null }) + ]); + }); + + it('trim after line break', () => { + const tokens = analyze('```\nfoo\n```\nbar'); + assert.deepStrictEqual(tokens, [ + leaf('blockCode', { code: 'foo', lang: null }), + text('bar') + ]); + }); }); - it('inline code', () => { - const tokens = analyze('`var x = "Strawberry Pasta";`'); - assert.equal(tokens[0].type, 'inline-code'); - assert.equal(tokens[0].content, '`var x = "Strawberry Pasta";`'); + describe('inline code', () => { + it('simple', () => { + const tokens = analyze('`var x = "Strawberry Pasta";`'); + assert.deepStrictEqual(tokens, [ + leaf('inlineCode', { code: 'var x = "Strawberry Pasta";' }) + ]); + }); + + it('disallow line break', () => { + const tokens = analyze('`foo\nbar`'); + assert.deepStrictEqual(tokens, [ + text('`foo\nbar`') + ]); + }); + + it('disallow ´', () => { + const tokens = analyze('`foo´bar`'); + assert.deepStrictEqual(tokens, [ + text('`foo´bar`') + ]); + }); }); it('math', () => { const fomula = 'x = {-b \\pm \\sqrt{b^2-4ac} \\over 2a}'; const text = `\\(${fomula}\\)`; const tokens = analyze(text); - assert.deepEqual([ - { type: 'math', content: text, formula: fomula } - ], tokens); + assert.deepStrictEqual(tokens, [ + leaf('math', { formula: fomula }) + ]); }); it('search', () => { const tokens1 = analyze('a b c 検索'); - assert.deepEqual([ - { type: 'search', content: 'a b c 検索', query: 'a b c' } - ], tokens1); + assert.deepStrictEqual(tokens1, [ + leaf('search', { content: 'a b c 検索', query: 'a b c' }) + ]); const tokens2 = analyze('a b c Search'); - assert.deepEqual([ - { type: 'search', content: 'a b c Search', query: 'a b c' } - ], tokens2); + assert.deepStrictEqual(tokens2, [ + leaf('search', { content: 'a b c Search', query: 'a b c' }) + ]); const tokens3 = analyze('a b c search'); - assert.deepEqual([ - { type: 'search', content: 'a b c search', query: 'a b c' } - ], tokens3); + assert.deepStrictEqual(tokens3, [ + leaf('search', { content: 'a b c search', query: 'a b c' }) + ]); const tokens4 = analyze('a b c SEARCH'); - assert.deepEqual([ - { type: 'search', content: 'a b c SEARCH', query: 'a b c' } - ], tokens4); + assert.deepStrictEqual(tokens4, [ + leaf('search', { content: 'a b c SEARCH', query: 'a b c' }) + ]); }); - it('title', () => { - const tokens1 = analyze('【yee】\nhaw'); - assert.deepEqual( - { type: 'title', content: '【yee】\n', title: 'yee' } - , tokens1[0]); - - const tokens2 = analyze('[yee]\nhaw'); - assert.deepEqual( - { type: 'title', content: '[yee]\n', title: 'yee' } - , tokens2[0]); + describe('title', () => { + it('simple', () => { + const tokens = analyze('【foo】'); + assert.deepStrictEqual(tokens, [ + tree('title', [ + text('foo') + ], {}) + ]); + }); - const tokens3 = analyze('a [a]\nb [b]\nc [c]'); - assert.deepEqual( - { type: 'text', content: 'a [a]\nb [b]\nc [c]' } - , tokens3[0]); + it('require line break', () => { + const tokens = analyze('a【foo】'); + assert.deepStrictEqual(tokens, [ + text('a【foo】') + ]); + }); - const tokens4 = analyze('foo\n【bar】\nbuzz'); - assert.deepEqual([ - { type: 'text', content: 'foo' }, - { type: 'title', content: '\n【bar】\n', title: 'bar' }, - { type: 'text', content: 'buzz' }, - ], tokens4); + it('with before and after texts', () => { + const tokens = analyze('before\n【foo】\nafter'); + assert.deepStrictEqual(tokens, [ + text('before\n'), + tree('title', [ + text('foo') + ], {}), + text('after') + ]); + }); }); - }); - describe('syntax highlighting', () => { - it('comment', () => { - const html1 = syntaxhighlighter('// Strawberry pasta'); - assert.equal(html1, '<span class="comment">// Strawberry pasta</span>'); - - const html2 = syntaxhighlighter('x // x\ny // y'); - assert.equal(html2, 'x <span class="comment">// x\n</span>y <span class="comment">// y</span>'); + describe('center', () => { + it('simple', () => { + const tokens = analyze('<center>foo</center>'); + assert.deepStrictEqual(tokens, [ + tree('center', [ + text('foo') + ], {}), + ]); + }); }); - it('regexp', () => { - const html = syntaxhighlighter('/.*/'); - assert.equal(html, '<span class="regexp">/.*/</span>'); + describe('strike', () => { + it('simple', () => { + const tokens = analyze('~~foo~~'); + assert.deepStrictEqual(tokens, [ + tree('strike', [ + text('foo') + ], {}), + ]); + }); }); - it('slash', () => { - const html = syntaxhighlighter('/'); - assert.equal(html, '<span class="symbol">/</span>'); + describe('italic', () => { + it('<i>', () => { + const tokens = analyze('<i>foo</i>'); + assert.deepStrictEqual(tokens, [ + tree('italic', [ + text('foo') + ], {}), + ]); + }); + + it('underscore', () => { + const tokens = analyze('_foo_'); + assert.deepStrictEqual(tokens, [ + tree('italic', [ + text('foo') + ], {}), + ]); + }); + + it('simple with asterix', () => { + const tokens = analyze('*foo*'); + assert.deepStrictEqual(tokens, [ + tree('italic', [ + text('foo') + ], {}), + ]); + }); + + it('exlude emotes', () => { + const tokens = analyze('*.*'); + assert.deepStrictEqual(tokens, [ + text("*.*"), + ]); + }); + + it('mixed', () => { + const tokens = analyze('_foo*'); + assert.deepStrictEqual(tokens, [ + text('_foo*'), + ]); + }); + + it('mixed', () => { + const tokens = analyze('*foo_'); + assert.deepStrictEqual(tokens, [ + text('*foo_'), + ]); + }); }); }); describe('toHtml', () => { it('br', () => { const input = 'foo\nbar\nbaz'; - const output = '<p>foo<br>bar<br>baz</p>'; + const output = '<p><span>foo<br>bar<br>baz</span></p>'; assert.equal(toHtml(analyze(input)), output); }); }); + + it('code block with quote', () => { + const tokens = analyze('> foo\n```\nbar\n```'); + assert.deepStrictEqual(tokens, [ + tree('quote', [ + text('foo') + ], {}), + leaf('blockCode', { code: 'bar', lang: null }) + ]); + }); + + it('quote between two code blocks', () => { + const tokens = analyze('```\nbefore\n```\n> foo\n```\nafter\n```'); + assert.deepStrictEqual(tokens, [ + leaf('blockCode', { code: 'before', lang: null }), + tree('quote', [ + text('foo') + ], {}), + leaf('blockCode', { code: 'after', lang: null }) + ]); + }); }); diff --git a/test/resources/image.svg b/test/resources/image.svg new file mode 100644 index 0000000000..1e2bf5b5bb --- /dev/null +++ b/test/resources/image.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="#FF40A4" d="M128 80c-16 4-20 24-20 48v16c0 8-8 16-20.3 8 4.3 20 24.3 28 40.3 24s20-24 20-48v-16c0-8 8-16 20.3-8C164 84 144 76 128 80"/><path fill="#FFBF40" d="M192 80c-16 4-20 24-20 48v16c0 8-8 16-20.3 8 4.3 20 24.3 28 40.3 24s20-24 20-48v-16c0-8 8-16 20.3-8C228 84 208 76 192 80"/><path fill="#408EFF" d="M64 80c-16 4-20 24-20 48v16c0 8-8 16-20.3 8C28 172 48 180 64 176s20-24 20-48v-16c0-8 8-16 20.3-8C100 84 80 76 64 80"/></svg> diff --git a/tslint.json b/tslint.json index 1adc0a2aed..a58b4f1206 100644 --- a/tslint.json +++ b/tslint.json @@ -1,7 +1,8 @@ { "defaultSeverity": "error", "extends": [ - "tslint:recommended" + "tslint:recommended", + "tslint-sonarts" ], "jsRules": {}, "rules": { @@ -31,7 +32,32 @@ "member-ordering": [false], "ban-types": [ "Object" - ] + ], + "ban": [ + true, + {"name": ["*", "forEach"], "message": "Use for-of loop instead."} + ], + "no-duplicate-string": false, + "no-commented-code": false, + "cognitive-complexity": false, + "no-nested-template-literals": false, + "no-identical-functions": false, + "max-union-size": false, + "no-big-function": false, + "no-statements-same-line": false, + "no-small-switch": false, + "no-identical-expressions": false, + "no-invalid-await": false, + "prefer-immediate-return": false, + "no-use-of-empty-return-value": false, + "no-collapsible-if": false, + "no-ignored-return": false, + "no-redundant-boolean": false, + "prefer-promise-shorthand": false, + "parameters-max-number": false, + "no-duplicated-branches": false, + "no-identical-conditions": false, + "no-useless-cast": false }, "rulesDirectory": [] } diff --git a/webpack.config.ts b/webpack.config.ts index fd552dd21a..3c7a112a80 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -5,21 +5,22 @@ import * as fs from 'fs'; import * as webpack from 'webpack'; import chalk from 'chalk'; +import rndstr from 'rndstr'; const { VueLoaderPlugin } = require('vue-loader'); const WebpackOnBuildPlugin = require('on-build-webpack'); //const HardSourceWebpackPlugin = require('hard-source-webpack-plugin'); const ProgressBarPlugin = require('progress-bar-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); +const isProduction = process.env.NODE_ENV == 'production'; + const constants = require('./src/const.json'); const locales = require('./locales'); const meta = require('./package.json'); -const version = meta.clientVersion; +const version = isProduction ? meta.clientVersion : meta.clientVersion + '-' + rndstr({ length: 8, chars: '0-9a-z' }); const codename = meta.codename; -const isProduction = process.env.NODE_ENV == 'production'; - const postcss = { loader: 'postcss-loader', options: { @@ -38,6 +39,7 @@ module.exports = { dev: './src/client/app/dev/script.ts', auth: './src/client/app/auth/script.ts', admin: './src/client/app/admin/script.ts', + test: './src/client/app/test/script.ts', sw: './src/client/app/sw.js' }, module: { @@ -112,7 +114,6 @@ module.exports = { clear: false }), new webpack.DefinePlugin({ - _THEME_COLOR_: JSON.stringify(constants.themeColor), _COPYRIGHT_: JSON.stringify(constants.copyright), _VERSION_: JSON.stringify(meta.version), _CLIENT_VERSION_: JSON.stringify(version), |