diff --git a/.config/example.yml b/.config/example.yml index b84a50c525..9ea048f70b 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -50,8 +50,11 @@ remoteDriveCapacityMb: 8 # If enabled: # Server will not cache remote files (Using direct link instead). # You can save your storage. -# Users cannot see remote images when they turn off "Show media from a remote server" setting. -preventCache: false +# +# NOTE: +# * Users cannot see remote images when they turn off "Show media from a remote server" setting. +# * Since thumbnails are not provided, traffic increases. +preventCacheRemoteFiles: false drive: storage: 'db' @@ -64,7 +67,7 @@ drive: # config: # endPoint: # port: - # secure: + # useSSL: # accessKey: # secretKey: @@ -75,7 +78,7 @@ drive: # config: # endPoint: s3-us-west-2.amazonaws.com # region: us-west-2 - # secure: true + # useSSL: true # accessKey: XXX # secretKey: YYY @@ -87,7 +90,7 @@ drive: # config: # endPoint: s3-us-west-2.amazonaws.com # region: us-west-2 - # secure: true + # useSSL: true # accessKey: XXX # secretKey: YYY @@ -123,6 +126,7 @@ drive: # google_maps_api_key: example-google-maps-api-key # Twitter integration +# You need to set the oauth callback url as : https://<your-misskey-instance>/api/tw/cb # twitter: # consumer_key: example-twitter-consumer-key # consumer_secret: example-twitter-consumer-secret-key diff --git a/.travis.yml b/.travis.yml index f52fe7e3f5..cdb6edc965 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,7 +22,6 @@ addons: - ubuntu-toolchain-r-test packages: - g++-4.8 - - graphicsmagick cache: directories: diff --git a/.vsls.json b/.vsls.json new file mode 100644 index 0000000000..3fff862442 --- /dev/null +++ b/.vsls.json @@ -0,0 +1,4 @@ +{ + "$schema": "http://json.schemastore.org/vsls", + "gitignore": "exclude" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 08ad99977f..9b5d8c8307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ ChangeLog This document describes breaking changes only. +6.0.0 +----- + +### Migration + +オブジェクトストレージを使用している場合、設定ファイルの`drive.config.secure`を`drive.config.useSSL`にリネームしてください。 + +If you use object storage, please rename `drive.config.secure` to `drive.config.useSSL` in config. + 5.0.0 ----- diff --git a/README.md b/README.md index 9e1c834b40..a52ead65a7 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,34 @@ If you want to... :heart: Backers & Sponsors ---------------------------------------------------------------- -| <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"> | <img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12378075/0156f769e20f412594fa6b87d85fe228/1?token-time=2145916800&token-hash=IsIJRUXszzoD6-7pDnRY8I05T9nSznc4GTaxj7C9SwU%3D"> | <img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/4503830/ccf2cc867ea64de0b524bb2e24b9a1cb/1?token-time=2145916800&token-hash=S1zP0QyLU52Dqq6dtc9qNYyWfW86XrYHiR4NMbeOrnA%3D"> | <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"> | -|:-:|:-:|:-:|:-:| -| [Gargron](https://www.patreon.com/mastodon) | [39ff](https://www.patreon.com/user/creators?u=12378075) | [dansup](https://www.patreon.com/dansup) | [Takashi Shibuya](https://www.patreon.com/user/creators?u=12531784) | +<table> + <tr> + <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"></td> + <td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12378075/0156f769e20f412594fa6b87d85fe228/1?token-time=2145916800&token-hash=IsIJRUXszzoD6-7pDnRY8I05T9nSznc4GTaxj7C9SwU%3D"></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"></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"></td> + <td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12959468/c249e15aebec4424b5c0f427173671b6/1?token-time=2145916800&token-hash=lubpCEdxAkxPlpR2O6bvZ7BIh8Q4nGf-U_mE1qpjVAQ%3D"></td> + <td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12913507/f7181eacafe8469a93033d85f5969c29/1?token-time=2145916800&token-hash=f03BFb4S2FUx9YEt87TnEmifb4h33OywGBW2akQVtQY%3D"></td> + <td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/5881381/6235ca5d3fb04c8e95ef5b4ff2abcc18/2?token-time=2145916800&token-hash=zElv7ZcPL3viGsXbNG_KWiKrbV0vvw1gk0panx8DJoo%3D"></td> + <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"></td> + <td><img src="https://c8.patreon.com/2/100/12718187"></td> + <td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12931605/ead494101f364dffa90efe49e36fb494/1?token-time=2145916800&token-hash=NzSFPjIlodXyv41rwK61aZWVZWfI4surJaNj8vWKvqM%3D"></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"></td> + </tr> + <tr> + <td><a href="https://www.patreon.com/mastodon">Gargron</a></td> + <td><a href="https://www.patreon.com/user/creators?u=12378075">39ff</a></td> + <td><a href="https://www.patreon.com/dansup">dansup</a></td> + <td><a href="https://www.patreon.com/user/creators?u=12531784">Takashi Shibuya</a></td> + <td><a href="https://www.patreon.com/fujishan">fujishan</a></td> + <td><a href="https://www.patreon.com/user?u=12913507">Melilot</a></td> + <td><a href="https://www.patreon.com/user?u=5881381">Naoki Kosaka</a></td> + <td><a href="https://www.patreon.com/user?u=12731202">negao</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=12931605">Reiju</a></td> + <td><a href="https://www.patreon.com/gutfuckllc">gutfuckllc</a></td> + </tr> +</table> :four_leaf_clover: Copyright ---------------------------------------------------------------- diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index f089c02545..0000000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -FROM base/archlinux - -MAINTAINER Aya Morisawa - -RUN rm /etc/pacman.d/mirrorlist -RUN echo 'Server = http://ftp.jaist.ac.jp/pub/Linux/ArchLinux/$repo/os/$arch' >> /etc/pacman.d/mirrorlist -RUN echo 'Server = http://ftp.tsukuba.wide.ad.jp/Linux/archlinux/$repo/os/$arch' >> /etc/pacman.d/mirrorlist - -RUN rm /etc/localtime -RUN ln -s /usr/share/zoneinfo/Asia/Tokyo /etc/localtime - -RUN pacman -Sy --noconfirm -RUN pacman -S --noconfirm pacman -RUN pacman-db-upgrade -RUN pacman -S --noconfirm archlinux-keyring -RUN pacman -Syyu --noconfirm -RUN pacman -S --noconfirm git nodejs npm mongodb redis - -COPY misskey.sh /root/misskey.sh -RUN chmod u+x /root/misskey.sh - -EXPOSE 80 -EXPOSE 443 -EXPOSE 27017 - -CMD ["/root/misskey.sh"] diff --git a/docker/misskey.sh b/docker/misskey.sh deleted file mode 100644 index 82291e3a97..0000000000 --- a/docker/misskey.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh -redis-server --daemonize yes -mongod > /dev/null & -cd /root/misskey -npm start -tail -f /dev/null diff --git a/docs/docker.en.md b/docs/docker.en.md deleted file mode 100644 index 591ad86607..0000000000 --- a/docs/docker.en.md +++ /dev/null @@ -1,29 +0,0 @@ -Setup with Docker :whale: -================================================================ - -Ensure that the working directory is the repository root directory. - -To create misskey image: -``` console -$ sudo docker build -t misskey ./docker -``` - -To run misskey: -``` console -$ sudo docker run --rm -i -t -p $PORT:80 -v $(pwd):/root/misskey -v $DBPATH:/data/db misskey -``` - -where `$PORT` is the port used to access Misskey Web from host browser -and `$DBPATH` is the path of MongoDB database on the host for data persistence. - -ex: -``` console -$ sudo docker run --rm -i -t -p 80:80 -v $(pwd):/root/misskey -v /data/db:/data/db misskey -``` - -If you want to run misskey in production mode, add `--env NODE_ENV=production` like this: -``` console -$ sudo docker run --rm -i -t -p 80:80 -v $(pwd):/root/misskey -v /data/db:/data/db --env NODE_ENV=production misskey -``` - -Note that `$(pwd)` is the working directory. diff --git a/docs/setup.en.md b/docs/setup.en.md index 56632cc361..6a54817a78 100644 --- a/docs/setup.en.md +++ b/docs/setup.en.md @@ -62,6 +62,13 @@ npm install web-push -g web-push generate-vapid-keys ``` +*(optional)* Create a twitter application +---------------------------------------------------------------- +If you want to enable the twitter integration, you need to create a twitter app at [https://developer.twitter.com/en/apply/user](https://developer.twitter.com/en/apply/user). + +In the app you need to set the oauth callback url as : https://misskey-instance/api/tw/cb + + *5.* Make configuration file ---------------------------------------------------------------- 1. `cp .config/example.yml .config/default.yml` Copy the `.config/example.yml` and rename it to `default.yml`. diff --git a/locales/ja.yml b/locales/ja.yml index ecde1bb6a1..310a73a64e 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -70,6 +70,7 @@ common: congrats: "おめでとう" angry: "おこ" confused: "こまこまのこまり" + rip: "RIP" pudding: "Pudding" note-placeholders: @@ -89,7 +90,7 @@ common: my-token-regenerated: "あなたのトークンが更新されたのでサインアウトします。" i-like-sushi: "私は(プリンよりむしろ)寿司が好き" show-reversi-board-labels: "リバーシのボードの行と列のラベルを表示" - verified-user: "認証済みのユーザー" + verified-user: "公式アカウント" disable-animated-mfm: "投稿内の動きのあるテキストを無効にする" reversi: @@ -181,6 +182,9 @@ common/views/components/games/reversi/reversi.vue: common/views/components/games/reversi/reversi.game.vue: surrender: "投了" surrendered: "投了により" + is-llotheo: "石の少ない方が勝ち(ロセオ)" + looped-map: "ループマップ" + can-put-everywhere: "どこでも置けるモード" common/views/components/games/reversi/reversi.index.vue: title: "Misskey Reversi" @@ -310,6 +314,7 @@ common/views/components/signin.vue: signin: "サインイン" or: "または" signin-with-twitter: "Twitterでログイン" + login-failed: "ログインできませんでした。ユーザー名とパスワードを確認してください。" common/views/components/signup.vue: username: "ユーザー名" @@ -703,9 +708,11 @@ desktop/views/components/settings.vue: circle-icons: "円形のアイコンを使用" gradient-window-header: "ウィンドウのタイトルバーにグラデーションを使用" post-form-on-timeline: "タイムライン上部に投稿フォームを表示する" + suggest-recent-hashtags: "最近のハッシュタグを投稿フォームに表示する" show-reply-target: "リプライ先を表示する" show-my-renotes: "自分の行ったRenoteをタイムラインに表示する" - show-renoted-my-notes: "Renoteされた自分の投稿をタイムラインに表示する" + show-renoted-my-notes: "自分の投稿のRenoteをタイムラインに表示する" + show-local-renotes: "ローカルの投稿のRenoteをタイムラインに表示する" show-maps: "マップの自動展開" show-maps-desc: "位置情報が添付された投稿のマップを自動的に展開します。" @@ -893,6 +900,29 @@ desktop/views/components/window.vue: popout: "ポップアウト" close: "閉じる" +desktop/views/pages/admin/admin.vue: + dashboard: "ダッシュボード" + drive: "ドライブ" + users: "ユーザー" + update: "更新" + +desktop/views/pages/admin/admin.dashboard.vue: + dashboard: "ダッシュボード" + all-users: "全てのユーザー" + original-users: "このインスタンスのユーザー" + all-notes: "全てのノート" + original-notes: "このインスタンスのノート" + +desktop/views/pages/admin/admin.suspend-user.vue: + suspend-user: "ユーザーの凍結" + suspend: "凍結" + suspended: "凍結しました" + +desktop/views/pages/admin/admin.unsuspend-user.vue: + unsuspend-user: "ユーザーの凍結の解除" + unsuspend: "凍結の解除" + unsuspended: "凍結を解除しました" + desktop/views/pages/deck/deck.tl-column.vue: is-media-only: "メディア投稿のみ" is-media-view: "メディアビュー" @@ -1266,7 +1296,8 @@ mobile/views/pages/settings.vue: timeline: "タイムライン" show-reply-target: "リプライ先を表示する" show-my-renotes: "自分の行ったRenoteを表示する" - show-renoted-my-notes: "Renoteされた自分の投稿を表示する" + show-renoted-my-notes: "自分の投稿のRenoteを表示する" + show-local-renotes: "ローカルの投稿のRenoteを表示する" post-style: "投稿の表示スタイル" post-style-standard: "標準" post-style-smart: "スマート" diff --git a/package.json b/package.json index 64f9b577a4..3d8df6aaee 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "misskey", "author": "syuilo <i@syuilo.com>", - "version": "5.20.1", - "clientVersion": "1.0.8105", + "version": "6.2.0", + "clientVersion": "1.0.8417", "codename": "nighthike", "main": "./built/index.js", "private": true, @@ -31,6 +31,7 @@ "@types/dateformat": "1.0.1", "@types/debug": "0.0.30", "@types/deep-equal": "1.0.1", + "@types/double-ended-queue": "2.1.0", "@types/elasticsearch": "5.0.25", "@types/file-type": "5.2.1", "@types/gulp": "3.8.36", @@ -57,9 +58,9 @@ "@types/minio": "6.0.2", "@types/mkdirp": "0.5.2", "@types/mocha": "5.2.3", - "@types/mongodb": "3.1.3", + "@types/mongodb": "3.1.4", "@types/ms": "0.7.30", - "@types/node": "10.5.7", + "@types/node": "10.7.1", "@types/portscanner": "2.1.0", "@types/pug": "2.0.4", "@types/qrcode": "1.2.0", @@ -76,10 +77,10 @@ "@types/systeminformation": "3.23.0", "@types/tmp": "0.0.33", "@types/uuid": "3.4.3", - "@types/webpack": "4.4.9", + "@types/webpack": "4.4.10", "@types/webpack-stream": "3.2.10", "@types/websocket": "0.0.39", - "@types/ws": "5.1.2", + "@types/ws": "6.0.0", "animejs": "2.2.0", "autosize": "4.0.2", "autwh": "0.1.0", @@ -97,6 +98,7 @@ "deepcopy": "0.6.3", "diskusage": "0.2.4", "dompurify": "1.0.5", + "double-ended-queue": "2.1.0-0", "elasticsearch": "15.1.1", "element-ui": "2.4.6", "emojilib": "2.3.0", @@ -145,8 +147,9 @@ "koa-slow": "2.1.0", "koa-views": "6.1.4", "loader-utils": "1.1.0", + "lodash.assign": "4.2.0", "mecab-async": "0.1.2", - "minio": "6.0.0", + "minio": "7.0.0", "mkdirp": "0.5.1", "mocha": "5.2.0", "moji": "0.5.1", @@ -160,7 +163,7 @@ "object-assign-deep": "0.4.0", "on-build-webpack": "0.1.0", "os-utils": "0.0.14", - "parse5": "5.0.0", + "parse5": "5.1.0", "portscanner": "2.2.0", "progress-bar-webpack-plugin": "1.11.0", "promise-sequential": "1.1.1", @@ -171,13 +174,13 @@ "recaptcha-promise": "0.1.3", "reconnecting-websocket": "3.2.2", "redis": "2.8.0", - "request": "2.87.0", + "request": "2.88.0", "request-promise-native": "1.0.5", "rimraf": "2.6.2", "rndstr": "1.0.0", "s-age": "1.1.2", "sass-loader": "7.1.0", - "seedrandom": "2.4.3", + "seedrandom": "2.4.4", "sharp": "0.20.5", "showdown": "1.8.6", "showdown-highlightjs-extension": "0.1.2", @@ -187,27 +190,27 @@ "style-loader": "0.22.1", "stylus": "0.54.5", "stylus-loader": "3.0.2", - "summaly": "2.1.2", + "summaly": "2.1.3", "systeminformation": "3.42.9", "syuilo-password-strength": "0.0.1", "textarea-caret": "3.1.0", "tmp": "0.0.33", "ts-loader": "4.4.1", - "ts-node": "7.0.0", + "ts-node": "7.0.1", "tslint": "5.10.0", "typescript": "2.9.2", "typescript-eslint-parser": "18.0.0", "uglify-es": "3.3.9", - "url-loader": "1.0.1", + "url-loader": "1.1.0", "uuid": "3.3.2", "v-animate-css": "0.0.2", "vue": "2.5.17", "vue-cropperjs": "2.2.1", - "vue-js-modal": "1.3.16", + "vue-js-modal": "1.3.17", "vue-json-tree-view": "2.1.4", "vue-loader": "15.3.0", "vue-router": "3.0.1", - "vue-style-loader": "4.1.1", + "vue-style-loader": "4.1.2", "vue-template-compiler": "2.5.17", "vuedraggable": "2.16.0", "vuex": "3.0.1", diff --git a/src/client/app/common/scripts/get-kao.ts b/src/client/app/common/scripts/get-kao.ts index 645196132b..ca83153b96 100644 --- a/src/client/app/common/scripts/get-kao.ts +++ b/src/client/app/common/scripts/get-kao.ts @@ -1,5 +1,9 @@ -export default () => [ +const kaos = [ '(=^・・^=)', 'v(\'ω\')v', - '🐡( \'-\' 🐡 )フグパンチ!!!!' -][Math.floor(Math.random() * 3)]; + '🐡( \'-\' 🐡 )フグパンチ!!!!', + '🖕(´・_・`)🖕', + '(。>﹏<。)' +]; + +export default () => kaos[Math.floor(Math.random() * kaos.length)]; diff --git a/src/client/app/common/views/components/avatar.vue b/src/client/app/common/views/components/avatar.vue index 6685296c16..c5ac74e537 100644 --- a/src/client/app/common/views/components/avatar.vue +++ b/src/client/app/common/views/components/avatar.vue @@ -1,8 +1,16 @@ <template> - <span class="mk-avatar" :title="user | acct" :style="style" v-if="disableLink && !disablePreview" v-user-preview="user.id" @click="onClick"></span> - <span class="mk-avatar" :title="user | acct" :style="style" v-else-if="disableLink && disablePreview" @click="onClick"></span> - <router-link class="mk-avatar" :to="user | userPage" :title="user | acct" :target="target" :style="style" v-else-if="!disableLink && !disablePreview" v-user-preview="user.id"></router-link> - <router-link class="mk-avatar" :to="user | userPage" :title="user | acct" :target="target" :style="style" v-else-if="!disableLink && disablePreview"></router-link> + <span class="mk-avatar" :class="{ cat }" :title="user | acct" v-if="disableLink && !disablePreview" v-user-preview="user.id" @click="onClick"> + <span class="inner" :style="style"></span> + </span> + <span class="mk-avatar" :class="{ cat }" :title="user | acct" v-else-if="disableLink && disablePreview" @click="onClick"> + <span class="inner" :style="style"></span> + </span> + <router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && !disablePreview" v-user-preview="user.id"> + <span class="inner" :style="style"></span> + </router-link> + <router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && disablePreview"> + <span class="inner" :style="style"></span> + </router-link> </template> <script lang="ts"> @@ -30,14 +38,17 @@ export default Vue.extend({ lightmode(): boolean { return this.$store.state.device.lightmode; }, + cat(): boolean { + return this.user.isCat && this.$store.state.settings.circleIcons; + }, style(): any { return { backgroundColor: this.lightmode - ? `rgb(${ this.user.avatarColor.slice(0, 3).join(',') })` + ? `rgb(${this.user.avatarColor.slice(0, 3).join(',')})` : this.user.avatarColor && this.user.avatarColor.length == 3 - ? `rgb(${ this.user.avatarColor.join(',') })` + ? `rgb(${this.user.avatarColor.join(',')})` : null, - backgroundImage: this.lightmode ? null : `url(${ this.user.avatarUrl })`, + backgroundImage: this.lightmode ? null : `url(${this.user.avatarUrl})`, borderRadius: this.$store.state.settings.circleIcons ? '100%' : null }; } @@ -51,10 +62,47 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.mk-avatar + +root(isDark) display inline-block vertical-align bottom - background-size cover - background-position center center - transition border-radius 1s ease + + &:not(.cat) + overflow hidden + border-radius 8px + + &.cat::before, + &.cat::after + background #df548f + border solid 4px isDark ? #e0eefd : #202224 + box-sizing border-box + content '' + display inline-block + height 50% + width 50% + + &.cat::before + border-radius 0 75% 75% + transform rotate(37.5deg) skew(30deg) + + &.cat::after + border-radius 75% 0 75% 75% + transform rotate(-37.5deg) skew(-30deg) + + .inner + background-position center center + background-size cover + bottom 0 + left 0 + position absolute + right 0 + top 0 + transition border-radius 1s ease + z-index 1 + +.mk-avatar[data-darkmode] + root(true) + +.mk-avatar:not([data-darkmode]) + root(false) </style> 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 d1809d741f..389934af97 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 @@ -60,6 +60,12 @@ <el-button type="primary" @click="logPos = logs.length" :disabled="logPos == logs.length">%fa:angle-double-right%</el-button> </el-button-group> </div> + + <div class="info"> + <p v-if="game.settings.isLlotheo">%i18n:@is-llotheo%</p> + <p v-if="game.settings.loopedBoard">%i18n:@looped-map%</p> + <p v-if="game.settings.canPutEverywhere">%i18n:@can-put-everywhere%</p> + </div> </div> </template> diff --git a/src/client/app/common/views/components/note-header.vue b/src/client/app/common/views/components/note-header.vue index 9bba6990db..d25bd430f2 100644 --- a/src/client/app/common/views/components/note-header.vue +++ b/src/client/app/common/views/components/note-header.vue @@ -2,7 +2,7 @@ <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> - <span class="is-verified" v-if="note.user.isVerified" title="%i18n:common.verified-user%">%fa:bookmark%</span> + <span class="is-verified" v-if="note.user.isVerified" title="%i18n:common.verified-user%">%fa:star%</span> <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> diff --git a/src/client/app/common/views/components/reaction-icon.vue b/src/client/app/common/views/components/reaction-icon.vue index 2d5391a21e..46886b8ab2 100644 --- a/src/client/app/common/views/components/reaction-icon.vue +++ b/src/client/app/common/views/components/reaction-icon.vue @@ -8,6 +8,7 @@ <img v-if="reaction == 'congrats'" src="/assets/reactions/congrats.png" alt="%i18n:common.reactions.congrats%"> <img v-if="reaction == 'angry'" src="/assets/reactions/angry.png" alt="%i18n:common.reactions.angry%"> <img v-if="reaction == 'confused'" src="/assets/reactions/confused.png" alt="%i18n:common.reactions.confused%"> + <img v-if="reaction == 'rip'" src="/assets/reactions/rip.png" alt="%i18n:common.reactions.rip%"> <template v-if="reaction == 'pudding'"> <img v-if="$store.getters.isSignedIn && $store.state.settings.iLikeSushi" src="/assets/reactions/sushi.png" alt="%i18n:common.reactions.pudding%"> <img v-else src="/assets/reactions/pudding.png" alt="%i18n:common.reactions.pudding%"> diff --git a/src/client/app/common/views/components/reaction-picker.vue b/src/client/app/common/views/components/reaction-picker.vue index 5a149cc4d1..a455afbf7d 100644 --- a/src/client/app/common/views/components/reaction-picker.vue +++ b/src/client/app/common/views/components/reaction-picker.vue @@ -10,9 +10,10 @@ <button @click="react('hmm')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="4" title="%i18n:common.reactions.hmm%"><mk-reaction-icon reaction='hmm'/></button> <button @click="react('surprise')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="5" title="%i18n:common.reactions.surprise%"><mk-reaction-icon reaction='surprise'/></button> <button @click="react('congrats')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="6" title="%i18n:common.reactions.congrats%"><mk-reaction-icon reaction='congrats'/></button> - <button @click="react('angry')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="4" title="%i18n:common.reactions.angry%"><mk-reaction-icon reaction='angry'/></button> - <button @click="react('confused')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="5" title="%i18n:common.reactions.confused%"><mk-reaction-icon reaction='confused'/></button> - <button @click="react('pudding')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="6" title="%i18n:common.reactions.pudding%"><mk-reaction-icon reaction='pudding'/></button> + <button @click="react('angry')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="7" title="%i18n:common.reactions.angry%"><mk-reaction-icon reaction='angry'/></button> + <button @click="react('confused')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="8" title="%i18n:common.reactions.confused%"><mk-reaction-icon reaction='confused'/></button> + <button @click="react('rip')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="9" title="%i18n:common.reactions.rip%"><mk-reaction-icon reaction='rip'/></button> + <button @click="react('pudding')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="10" title="%i18n:common.reactions.pudding%"><mk-reaction-icon reaction='pudding'/></button> </div> </div> </div> diff --git a/src/client/app/common/views/components/reactions-viewer.vue b/src/client/app/common/views/components/reactions-viewer.vue index 97cb6be17c..c30fa2a1dc 100644 --- a/src/client/app/common/views/components/reactions-viewer.vue +++ b/src/client/app/common/views/components/reactions-viewer.vue @@ -1,15 +1,16 @@ <template> <div class="mk-reactions-viewer"> <template v-if="reactions"> - <span v-if="reactions.like"><mk-reaction-icon reaction="like"/><span>{{ reactions.like }}</span></span> - <span v-if="reactions.love"><mk-reaction-icon reaction="love"/><span>{{ reactions.love }}</span></span> - <span v-if="reactions.laugh"><mk-reaction-icon reaction="laugh"/><span>{{ reactions.laugh }}</span></span> - <span v-if="reactions.hmm"><mk-reaction-icon reaction="hmm"/><span>{{ reactions.hmm }}</span></span> - <span v-if="reactions.surprise"><mk-reaction-icon reaction="surprise"/><span>{{ reactions.surprise }}</span></span> - <span v-if="reactions.congrats"><mk-reaction-icon reaction="congrats"/><span>{{ reactions.congrats }}</span></span> - <span v-if="reactions.angry"><mk-reaction-icon reaction="angry"/><span>{{ reactions.angry }}</span></span> - <span v-if="reactions.confused"><mk-reaction-icon reaction="confused"/><span>{{ reactions.confused }}</span></span> - <span v-if="reactions.pudding"><mk-reaction-icon reaction="pudding"/><span>{{ reactions.pudding }}</span></span> + <span :class="{notReacted}" @click="react('like')" v-if="reactions.like"><mk-reaction-icon reaction="like"/><span>{{ reactions.like }}</span></span> + <span :class="{notReacted}" @click="react('love')" v-if="reactions.love"><mk-reaction-icon reaction="love"/><span>{{ reactions.love }}</span></span> + <span :class="{notReacted}" @click="react('laugh')" v-if="reactions.laugh"><mk-reaction-icon reaction="laugh"/><span>{{ reactions.laugh }}</span></span> + <span :class="{notReacted}" @click="react('hmm')" v-if="reactions.hmm"><mk-reaction-icon reaction="hmm"/><span>{{ reactions.hmm }}</span></span> + <span :class="{notReacted}" @click="react('surprise')" v-if="reactions.surprise"><mk-reaction-icon reaction="surprise"/><span>{{ reactions.surprise }}</span></span> + <span :class="{notReacted}" @click="react('congrats')" v-if="reactions.congrats"><mk-reaction-icon reaction="congrats"/><span>{{ reactions.congrats }}</span></span> + <span :class="{notReacted}" @click="react('angry')" v-if="reactions.angry"><mk-reaction-icon reaction="angry"/><span>{{ reactions.angry }}</span></span> + <span :class="{notReacted}" @click="react('confused')" v-if="reactions.confused"><mk-reaction-icon reaction="confused"/><span>{{ reactions.confused }}</span></span> + <span :class="{notReacted}" @click="react('rip')" v-if="reactions.rip"><mk-reaction-icon reaction="rip"/><span>{{ reactions.rip }}</span></span> + <span :class="{notReacted}" @click="react('pudding')" v-if="reactions.pudding"><mk-reaction-icon reaction="pudding"/><span>{{ reactions.pudding }}</span></span> </template> </div> </template> @@ -21,6 +22,17 @@ export default Vue.extend({ computed: { reactions(): number { return this.note.reactionCounts; + }, + notReacted(): boolean { + return this.note.myReaction == null; + } + }, + methods: { + react(reaction: string) { + (this as any).api('notes/reactions/create', { + noteId: this.note.id, + reaction: reaction + }); } } }); @@ -39,6 +51,9 @@ root(isDark) > span margin-right 8px + &.notReacted + cursor pointer + > .mk-reaction-icon font-size 1.4em diff --git a/src/client/app/common/views/components/signin.vue b/src/client/app/common/views/components/signin.vue index deaeeca6a7..0c3cceb1b3 100644 --- a/src/client/app/common/views/components/signin.vue +++ b/src/client/app/common/views/components/signin.vue @@ -12,13 +12,13 @@ </ui-input> <ui-input v-if="user && user.twoFactorEnabled" v-model="token" type="number" required/> <ui-button type="submit" :disabled="signing">{{ signing ? '%i18n:@signing-in%' : '%i18n:@signin%' }}</ui-button> - <p style="margin: 8px 0;">%i18n:@or%<a :href="`${apiUrl}/signin/twitter`">%i18n:@signin-with-twitter%</a></p> + <p style="margin: 8px 0;" v-if="twitterIntegration">%i18n:@or% <a :href="`${apiUrl}/signin/twitter`">%i18n:@signin-with-twitter%</a></p> </form> </template> <script lang="ts"> import Vue from 'vue'; -import { apiUrl, host } from '../../../config'; +import { apiUrl, host, twitterIntegration } from '../../../config'; export default Vue.extend({ props: { @@ -36,7 +36,8 @@ export default Vue.extend({ password: '', token: '', apiUrl, - host + host, + twitterIntegration }; }, methods: { @@ -59,7 +60,7 @@ export default Vue.extend({ }).then(() => { location.reload(); }).catch(() => { - alert('something happened'); + alert('%i18n:@login-failed%'); this.signing = false; }); } diff --git a/src/client/app/common/views/components/url-preview.vue b/src/client/app/common/views/components/url-preview.vue index 4f1e34c6ca..95dafa8f4c 100644 --- a/src/client/app/common/views/components/url-preview.vue +++ b/src/client/app/common/views/components/url-preview.vue @@ -1,13 +1,5 @@ <template> -<iframe v-if="youtubeId" type="text/html" height="250" - :src="`https://www.youtube.com/embed/${youtubeId}?origin=${misskeyUrl}`" - frameborder="0"/> -<iframe v-else-if="spotifyId" - :src="`https://open.spotify.com/embed/track/${spotifyId}`" - frameborder="0" allowtransparency="true" allow="encrypted-media" /> -<iframe v-else-if="nicovideoId" - :src="`https://embed.nicovideo.jp/watch/${nicovideoId}?oldScript=1&referer=${misskeyUrl}&from=${position || '0'}&allowProgrammaticFullScreen=1`" - frameborder="0" allow="autoplay; encrypted-media" allowfullscreen /> +<iframe v-if="player" :src="player" heigth="250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen /> <div v-else-if="tweetUrl && detail" class="twitter"> <blockquote ref="tweet" class="twitter-tweet" :data-theme="$store.state.device.darkmode ? 'dark' : null"> <a :href="url"></a> @@ -54,10 +46,7 @@ export default Vue.extend({ thumbnail: null, icon: null, sitename: null, - youtubeId: null, - spotifyId: null, - nicovideoId: null, - position: null, + player: null, tweetUrl: null, misskeyUrl }; @@ -65,23 +54,7 @@ export default Vue.extend({ created() { const url = new URL(this.url); - if (url.hostname == 'www.youtube.com') { - this.youtubeId = url.searchParams.get('v'); - return; - } else if (url.hostname == 'youtu.be') { - this.youtubeId = url.pathname; - return; - } else if (url.hostname == 'open.spotify.com') { - this.spotifyId = url.pathname.split('/').reverse().filter(x => x !== '')[0]; - return; - } else if (['nicovideo.jp', 'www.nicovideo.jp', 'nico.ms'].includes(url.hostname)) { - const id = url.pathname.split('/').reverse().filter(x => x !== '')[0]; - if (['sm', 'nm', 'ax', 'ca', 'cd', 'cw', 'fx', 'ig', 'na', 'om', 'sd', 'sk', 'yk', 'yo', 'za', 'zb', 'zc', 'zd', 'ze', 'nl', 'so', ...Array(10).keys()].some(x => id.startsWith(x)) { - this.nicovideoId = id; - this.position = url.searchParams.get('from'); - return; - } - } else if (this.detail && url.hostname == 'twitter.com' && /^\/.+\/status(es)?\/\d+/.test(url.pathname)) { + if (this.detail && url.hostname == 'twitter.com' && /^\/.+\/status(es)?\/\d+/.test(url.pathname)) { this.tweetUrl = url; const twttr = (window as any).twttr || {}; const loadTweet = () => twttr.widgets.load(this.$refs.tweet); @@ -104,16 +77,95 @@ export default Vue.extend({ } fetch('/url?url=' + encodeURIComponent(this.url)).then(res => { res.json().then(info => { - this.title = info.title; - this.description = info.description; - this.thumbnail = info.thumbnail; - this.icon = info.icon; - this.sitename = info.sitename; - - this.fetching = false; - }); - }); - } + if (info.url != null) { + this.title = info.title; + this.description = info.description; + this.thumbnail = info.thumbnail; + this.icon = info.icon; + this.sitename = info.sitename; + this.fetching = false; + if ([ // THIS IS THE WHITELIST FOR THE EMBED PLAYER + 'afreecatv.com', + 'aparat.com', + 'applemusic.com', + 'amazon.com', + 'awa.fm', + 'bandcamp.com', + 'bbc.co.uk', + 'beatport.com', + 'bilibili.com', + 'boomstream.com', + 'breakers.tv', + 'cam4.com', + 'cavelis.net', + 'chaturbate.com', + 'cnn.com', + 'cybergame.tv', + 'dailymotion.com', + 'deezer.com', + 'djlive.pl', + 'e-onkyo.com', + 'eventials.com', + 'facebook.com', + 'fc2.com', + 'gameplank.tv', + 'goodgame.ru', + 'google.com', + 'hardtunes.com', + 'instagram.com', + 'johnnylooch.com', + 'kexp.org', + 'lahzenegar.com', + 'liveedu.tv', + 'livetube.cc', + 'livestream.com', + 'meridix.com', + 'mixcloud.com', + 'mixer.com', + 'mobcrush.com', + 'mylive.in.th', + 'myspace.com', + 'netflix.com', + 'newretrowave.com', + 'nhk.or.jp', + 'nicovideo.jp', + 'nico.ms', + 'noisetrade.com', + 'nood.tv', + 'npr.org', + 'openrec.tv', + 'pandora.com', + 'pandora.tv', + 'picarto.tv', + 'pscp.tv', + 'restream.io', + 'reverbnation.com', + 'sermonaudio.com', + 'smashcast.tv', + 'songkick.com', + 'soundcloud.com', + 'spinninrecords.com', + 'spotify.com', + 'stitcher.com', + 'stream.me', + 'switchboard.live', + 'tunein.com', + 'twitcasting.tv', + 'twitch.tv', + 'twitter.com', + 'vaughnlive.tv', + 'veoh.com', + 'vimeo.com', + 'watchpeoplecode.com', + 'web.tv', + 'youtube.com', + 'youtu.be' + ].some(x => x == url.hostname || url.hostname.endsWith(`.${x}`))) + this.player = info.player; + } // info.url + }) // json + }); // fetch + } // created }); </script> diff --git a/src/client/app/config.ts b/src/client/app/config.ts index ceee0a2d62..04486ea230 100644 --- a/src/client/app/config.ts +++ b/src/client/app/config.ts @@ -22,6 +22,7 @@ declare const _CODENAME_: string; declare const _LICENSE_: string; declare const _GOOGLE_MAPS_API_KEY_: string; declare const _WELCOME_BG_URL_: string; +declare const _TWITTER_INTEGRATION_: boolean; export const host = _HOST_; export const hostname = _HOSTNAME_; @@ -47,3 +48,4 @@ export const codename = _CODENAME_; export const license = _LICENSE_; export const googleMapsApiKey = _GOOGLE_MAPS_API_KEY_; export const welcomeBgUrl = _WELCOME_BG_URL_; +export const twitterIntegration = _TWITTER_INTEGRATION_; diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts index 8175ce9b66..8dc0482191 100644 --- a/src/client/app/desktop/script.ts +++ b/src/client/app/desktop/script.ts @@ -24,6 +24,7 @@ import updateBanner from './api/update-banner'; import MkIndex from './views/pages/index.vue'; import MkDeck from './views/pages/deck/deck.vue'; +import MkAdmin from './views/pages/admin/admin.vue'; import MkUser from './views/pages/user/user.vue'; import MkFavorites from './views/pages/favorites.vue'; import MkSelectDrive from './views/pages/selectdrive.vue'; @@ -55,6 +56,7 @@ init(async (launch) => { routes: [ { path: '/', name: 'index', component: MkIndex }, { path: '/deck', name: 'deck', component: MkDeck }, + { path: '/admin', name: 'admin', component: MkAdmin }, { path: '/i/customize-home', component: MkHomeCustomize }, { path: '/i/favorites', component: MkFavorites }, { path: '/i/messaging/:user', component: MkMessagingRoom }, diff --git a/src/client/app/desktop/views/components/drive.file.vue b/src/client/app/desktop/views/components/drive.file.vue index 6541a8f21f..3b5be19dcf 100644 --- a/src/client/app/desktop/views/components/drive.file.vue +++ b/src/client/app/desktop/views/components/drive.file.vue @@ -16,7 +16,7 @@ <p>%i18n:@banner%</p> </div> <div class="thumbnail" ref="thumbnail" :style="`background-color: ${ background }`"> - <img :src="file.url" alt="" @load="onThumbnailLoaded"/> + <img :src="file.thumbnailUrl" alt="" @load="onThumbnailLoaded"/> </div> <p class="name"> <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> @@ -99,7 +99,7 @@ export default Vue.extend({ text: '%i18n:@contextmenu.set-as-banner%', action: this.setAsBanner }] - }, { + }, /*{ type: 'nest', text: '%i18n:@contextmenu.open-in-app%', menu: [{ @@ -107,11 +107,11 @@ export default Vue.extend({ text: '%i18n:@contextmenu.add-app%...', action: this.addApp }] - }], { - closed: () => { - this.isContextmenuShowing = false; - } - }); + }*/], { + closed: () => { + this.isContextmenuShowing = false; + } + }); }, onDragstart(e) { diff --git a/src/client/app/desktop/views/components/drive.folder.vue b/src/client/app/desktop/views/components/drive.folder.vue index e8077f9e3d..83880fef5c 100644 --- a/src/client/app/desktop/views/components/drive.folder.vue +++ b/src/client/app/desktop/views/components/drive.folder.vue @@ -67,16 +67,16 @@ export default Vue.extend({ text: '%i18n:@contextmenu.rename%', icon: '%fa:i-cursor%', action: this.rename - }, null, { + }/*, null, { type: 'item', text: '%i18n:common.delete%', icon: '%fa:R trash-alt%', action: this.deleteFolder - }], { - closed: () => { - this.isContextmenuShowing = false; - } - }); + }*/], { + closed: () => { + this.isContextmenuShowing = false; + } + }); }, onMouseover() { diff --git a/src/client/app/desktop/views/components/drive.vue b/src/client/app/desktop/views/components/drive.vue index 6bad7c78a2..d919e4a5ea 100644 --- a/src/client/app/desktop/views/components/drive.vue +++ b/src/client/app/desktop/views/components/drive.vue @@ -567,6 +567,7 @@ export default Vue.extend({ // ファイル一覧取得 (this as any).api('drive/files', { folderId: this.folder ? this.folder.id : null, + untilId: this.files[this.files.length - 1].id, limit: max + 1 }).then(files => { if (files.length == max + 1) { diff --git a/src/client/app/desktop/views/components/media-image.vue b/src/client/app/desktop/views/components/media-image.vue index 74bb03f4ed..8b68f260fa 100644 --- a/src/client/app/desktop/views/components/media-image.vue +++ b/src/client/app/desktop/views/components/media-image.vue @@ -37,7 +37,7 @@ export default Vue.extend({ style(): any { return { 'background-color': this.image.properties.avgColor && this.image.properties.avgColor.length == 3 ? `rgb(${this.image.properties.avgColor.join(',')})` : 'transparent', - 'background-image': this.raw ? `url(${this.image.url})` : `url(${this.image.url})` + 'background-image': this.raw ? `url(${this.image.url})` : `url(${this.image.thumbnailUrl})` }; } }, diff --git a/src/client/app/desktop/views/components/note-detail.vue b/src/client/app/desktop/views/components/note-detail.vue index b6980fae72..227bcc349d 100644 --- a/src/client/app/desktop/views/components/note-detail.vue +++ b/src/client/app/desktop/views/components/note-detail.vue @@ -55,15 +55,15 @@ </div> <footer> <mk-reactions-viewer :note="p"/> - <button @click="reply" title=""> + <button class="replyButton" @click="reply" title=""> <template v-if="p.reply">%fa:reply-all%</template> <template v-else>%fa:reply%</template> <p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> </button> - <button @click="renote" title="%i18n:@renote%"> + <button class="renoteButton" @click="renote" title="%i18n:@renote%"> %fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p> </button> - <button :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton" title="%i18n:@add-reaction%"> + <button class="reactionButton" :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton" title="%i18n:@add-reaction%"> %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p> </button> <button @click="menu" ref="menuButton"> @@ -372,15 +372,24 @@ root(isDark) cursor pointer &:hover - color isDark ? #9198af : #666 + color isDark ? #a1a8bf : #444 + + &.replyButton:hover + color #0af + + &.renoteButton:hover + color #8d0 + + &.reactionButton:hover + color #fa0 > .count display inline margin 0 0 0 8px color #999 - &.reacted - color $theme-color + &.reacted, &.reacted:hover + color #fa0 > .replies > * diff --git a/src/client/app/desktop/views/components/notes.note.vue b/src/client/app/desktop/views/components/notes.note.vue index a98df104a3..87acf7974d 100644 --- a/src/client/app/desktop/views/components/notes.note.vue +++ b/src/client/app/desktop/views/components/notes.note.vue @@ -42,15 +42,15 @@ </div> <footer> <mk-reactions-viewer :note="p" ref="reactionsViewer"/> - <button @click="reply" title="%i18n:@reply%"> + <button class="replyButton" @click="reply" title="%i18n:@reply%"> <template v-if="p.reply">%fa:reply-all%</template> <template v-else>%fa:reply%</template> <p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> </button> - <button @click="renote" title="%i18n:@renote%"> + <button class="renoteButton" @click="renote" title="%i18n:@renote%"> %fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p> </button> - <button :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton" title="%i18n:@add-reaction%"> + <button class="reactionButton" :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton" title="%i18n:@add-reaction%"> %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p> </button> <button @click="menu" ref="menuButton"> @@ -487,20 +487,24 @@ root(isDark) cursor pointer &:hover - color isDark ? #9198af : #666 + color isDark ? #a1a8bf : #444 + + &.replyButton:hover + color #0af + + &.renoteButton:hover + color #8d0 + + &.reactionButton:hover + color #fa0 > .count display inline margin 0 0 0 8px color #999 - &.reacted - color $theme-color - - &:last-child - position absolute - right 0 - margin 0 + &.reacted, &.reacted:hover + color #fa0 > .detail padding-top 4px diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue index 02167ef85c..efb9db70fa 100644 --- a/src/client/app/desktop/views/components/notes.vue +++ b/src/client/app/desktop/views/components/notes.vue @@ -135,6 +135,12 @@ export default Vue.extend({ return; } } + + if (this.$store.state.settings.showLocalRenotes === false) { + if (isPureRenote && (note.renote.user.host == null)) { + return; + } + } //#endregion // 投稿が自分のものではないかつ、タブが非表示またはスクロール位置が最上部ではないならタイトルで通知 diff --git a/src/client/app/desktop/views/components/post-form.vue b/src/client/app/desktop/views/components/post-form.vue index 334a457504..ea51144173 100644 --- a/src/client/app/desktop/views/components/post-form.vue +++ b/src/client/app/desktop/views/components/post-form.vue @@ -10,7 +10,7 @@ <span v-for="u in visibleUsers">{{ u | userName }}<a @click="removeVisibleUser(u)">[x]</a></span> <a @click="addVisibleUser">%i18n:@add-visible-user%</a> </div> - <div class="hashtags" v-if="recentHashtags.length > 0"> + <div class="hashtags" v-if="recentHashtags.length > 0 && $store.state.settings.suggestRecentHashtags"> <b>%i18n:@recent-tags%:</b> <a v-for="tag in recentHashtags.slice(0, 5)" @click="addTag(tag)" title="%@click-to-tagging%">#{{ tag }}</a> </div> @@ -23,7 +23,7 @@ <div class="medias" :class="{ with: poll }" v-show="files.length != 0"> <x-draggable :list="files" :options="{ animation: 150 }"> <div v-for="file in files" :key="file.id"> - <div class="img" :style="{ backgroundImage: `url(${file.url})` }" :title="file.name"></div> + <div class="img" :style="{ backgroundImage: `url(${file.thumbnailUrl})` }" :title="file.name"></div> <img class="remove" @click="detachMedia(file.id)" src="/assets/desktop/remove.png" title="%i18n:@attach-cancel%" alt=""/> </div> </x-draggable> diff --git a/src/client/app/desktop/views/components/settings.vue b/src/client/app/desktop/views/components/settings.vue index 84ea768a5c..e249afed85 100644 --- a/src/client/app/desktop/views/components/settings.vue +++ b/src/client/app/desktop/views/components/settings.vue @@ -48,9 +48,11 @@ <mk-switch v-model="$store.state.settings.iLikeSushi" @change="onChangeILikeSushi" text="%i18n:common.i-like-sushi%"/> </div> <mk-switch v-model="$store.state.settings.showPostFormOnTopOfTl" @change="onChangeShowPostFormOnTopOfTl" text="%i18n:@post-form-on-timeline%"/> + <mk-switch v-model="$store.state.settings.suggestRecentHashtags" @change="onChangeSuggestRecentHashtags" text="%i18n:@suggest-recent-hashtags%"/> <mk-switch v-model="$store.state.settings.showReplyTarget" @change="onChangeShowReplyTarget" text="%i18n:@show-reply-target%"/> <mk-switch v-model="$store.state.settings.showMyRenotes" @change="onChangeShowMyRenotes" text="%i18n:@show-my-renotes%"/> <mk-switch v-model="$store.state.settings.showRenotedMyNotes" @change="onChangeShowRenotedMyNotes" text="%i18n:@show-renoted-my-notes%"/> + <mk-switch v-model="$store.state.settings.showLocalRenotes" @change="onChangeShowLocalRenotes" text="%i18n:@show-local-renotes%"/> <mk-switch v-model="$store.state.settings.showMaps" @change="onChangeShowMaps" text="%i18n:@show-maps%"> <span>%i18n:@show-maps-desc%</span> </mk-switch> @@ -335,6 +337,12 @@ export default Vue.extend({ value: v }); }, + onChangeSuggestRecentHashtags(v) { + this.$store.dispatch('settings/set', { + key: 'suggestRecentHashtags', + value: v + }); + }, onChangeShowReplyTarget(v) { this.$store.dispatch('settings/set', { key: 'showReplyTarget', @@ -353,6 +361,12 @@ export default Vue.extend({ value: v }); }, + onChangeShowLocalRenotes(v) { + this.$store.dispatch('settings/set', { + key: 'showLocalRenotes', + value: v + }); + }, onChangeShowMaps(v) { this.$store.dispatch('settings/set', { key: 'showMaps', diff --git a/src/client/app/desktop/views/components/timeline.core.vue b/src/client/app/desktop/views/components/timeline.core.vue index 15e188be67..25fd5d36ac 100644 --- a/src/client/app/desktop/views/components/timeline.core.vue +++ b/src/client/app/desktop/views/components/timeline.core.vue @@ -100,7 +100,8 @@ export default Vue.extend({ limit: fetchLimit + 1, untilDate: this.date ? this.date.getTime() : undefined, includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes }).then(notes => { if (notes.length == fetchLimit + 1) { notes.pop(); @@ -122,7 +123,8 @@ export default Vue.extend({ limit: fetchLimit + 1, untilId: (this.$refs.timeline as any).tail().id, includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes }); promise.then(notes => { diff --git a/src/client/app/desktop/views/components/ui.header.vue b/src/client/app/desktop/views/components/ui.header.vue index 83df4b2fbc..edd9829c1c 100644 --- a/src/client/app/desktop/views/components/ui.header.vue +++ b/src/client/app/desktop/views/components/ui.header.vue @@ -155,10 +155,15 @@ root(isDark) max-width 1300px margin 0 auto + > * + position absolute + height 48px + > .center - margin auto + right 0 > .icon + margin auto display block width 48px height 48px @@ -169,11 +174,12 @@ root(isDark) opacity 0.3 cursor pointer - > .left - height 48px + > .left, + > .center + left 0 > .right - height 48px + right 0 > * display inline-block 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 03ac81a4a1..0a6f758763 100644 --- a/src/client/app/desktop/views/components/user-list-timeline.vue +++ b/src/client/app/desktop/views/components/user-list-timeline.vue @@ -47,7 +47,8 @@ export default Vue.extend({ listId: this.list.id, limit: fetchLimit + 1, includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes }).then(notes => { if (notes.length == fetchLimit + 1) { notes.pop(); @@ -67,7 +68,8 @@ export default Vue.extend({ limit: fetchLimit + 1, untilId: (this.$refs.timeline as any).tail().id, includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes }); promise.then(notes => { diff --git a/src/client/app/desktop/views/components/user-preview.vue b/src/client/app/desktop/views/components/user-preview.vue index aa17890682..7ef8dff5be 100644 --- a/src/client/app/desktop/views/components/user-preview.vue +++ b/src/client/app/desktop/views/components/user-preview.vue @@ -10,13 +10,13 @@ <div class="description">{{ u.description }}</div> <div class="status"> <div> - <p>%i18n:@notes%</p><a>{{ u.notesCount }}</a> + <p>%i18n:@notes%</p><span>{{ u.notesCount }}</span> </div> <div> - <p>%i18n:@following%</p><a>{{ u.followingCount }}</a> + <p>%i18n:@following%</p><span>{{ u.followingCount }}</span> </div> <div> - <p>%i18n:@followers%</p><a>{{ u.followersCount }}</a> + <p>%i18n:@followers%</p><span>{{ u.followersCount }}</span> </div> </div> <mk-follow-button v-if="$store.getters.isSignedIn && u.id != $store.state.i.id" :user="u"/> @@ -149,7 +149,7 @@ root(isDark) font-size 0.7em color #aaa - > a + > span font-size 1em color $theme-color diff --git a/src/client/app/desktop/views/pages/admin/admin.dashboard.vue b/src/client/app/desktop/views/pages/admin/admin.dashboard.vue new file mode 100644 index 0000000000..b10e829965 --- /dev/null +++ b/src/client/app/desktop/views/pages/admin/admin.dashboard.vue @@ -0,0 +1,37 @@ +<template> +<div> + <h1>%i18n:@dashboard%</h1> + <div v-if="stats"> + <p><b>%i18n:@all-users%</b>: <span>{{ stats.usersCount | number }}</span></p> + <p><b>%i18n:@original-users%</b>: <span>{{ stats.originalUsersCount | number }}</span></p> + <p><b>%i18n:@all-notes%</b>: <span>{{ stats.notesCount | number }}</span></p> + <p><b>%i18n:@original-notes%</b>: <span>{{ stats.originalNotesCount | number }}</span></p> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from "vue"; + +export default Vue.extend({ + data() { + return { + stats: null + }; + }, + created() { + (this as any).api('stats').then(stats => { + this.stats = stats; + }); + } +}); +</script> + +<style lang="stylus" scoped> +h1 + margin 0 0 1em 0 + padding 0 0 8px 0 + font-size 1em + color #555 + border-bottom solid 1px #eee +</style> diff --git a/src/client/app/desktop/views/pages/admin/admin.suspend-user.vue b/src/client/app/desktop/views/pages/admin/admin.suspend-user.vue new file mode 100644 index 0000000000..6eb82f0a51 --- /dev/null +++ b/src/client/app/desktop/views/pages/admin/admin.suspend-user.vue @@ -0,0 +1,51 @@ +<template> +<div> + <header>%i18n:@suspend-user%</header> + <input v-model="username" type="text" class="ui"/> + <button class="ui" @click="suspendUser" :disabled="suspending">%i18n:@suspend%</button> +</div> +</template> + +<script lang="ts"> +import Vue from "vue"; +import parseAcct from "../../../../../../misc/acct/parse"; + +export default Vue.extend({ + data() { + return { + username: null, + suspending: false + }; + }, + methods: { + async suspendUser() { + this.suspending = true; + + const user = await (this as any).os.api( + "users/show", + parseAcct(this.username) + ); + + await (this as any).os.api("admin/suspend-user", { + userId: user.id + }); + + this.suspending = false; + + (this as any).os.apis.dialog({ text: "%i18n:@suspended%" }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +header + margin 10px 0 + + +button + margin 16px 0 + +</style> diff --git a/src/client/app/desktop/views/pages/admin/admin.unsuspend-user.vue b/src/client/app/desktop/views/pages/admin/admin.unsuspend-user.vue new file mode 100644 index 0000000000..8c6f63ce88 --- /dev/null +++ b/src/client/app/desktop/views/pages/admin/admin.unsuspend-user.vue @@ -0,0 +1,51 @@ +<template> +<div> + <header>%i18n:@unsuspend-user%</header> + <input v-model="username" type="text" class="ui"/> + <button class="ui" @click="unsuspendUser" :disabled="unsuspending">%i18n:@unsuspend%</button> +</div> +</template> + +<script lang="ts"> +import Vue from "vue"; +import parseAcct from "../../../../../../misc/acct/parse"; + +export default Vue.extend({ + data() { + return { + username: null, + unsuspending: false + }; + }, + methods: { + async unsuspendUser() { + this.unsuspending = true; + + const user = await (this as any).os.api( + "users/show", + parseAcct(this.username) + ); + + await (this as any).os.api("admin/unsuspend-user", { + userId: user.id + }); + + this.unsuspending = false; + + (this as any).os.apis.dialog({ text: "%i18n:@unsuspended%" }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +header + margin 10px 0 + + +button + margin 16px 0 + +</style> diff --git a/src/client/app/desktop/views/pages/admin/admin.vue b/src/client/app/desktop/views/pages/admin/admin.vue new file mode 100644 index 0000000000..b581bea465 --- /dev/null +++ b/src/client/app/desktop/views/pages/admin/admin.vue @@ -0,0 +1,102 @@ +<template> +<div class="mk-admin"> + <nav> + <ul> + <li @click="nav('dashboard')" :class="{ active: page == 'dashboard' }">%fa:chalkboard .fw%%i18n:@dashboard%</li> + <li @click="nav('users')" :class="{ active: page == 'users' }">%fa:users .fw%%i18n:@users%</li> + <!-- <li @click="nav('drive')" :class="{ active: page == 'drive' }">%fa:cloud .fw%%i18n:@drive%</li> --> + <!-- <li @click="nav('update')" :class="{ active: page == 'update' }">%i18n:@update%</li> --> + </ul> + </nav> + <main> + <div v-if="page == 'dashboard'"> + <x-dashboard/> + </div> + <div v-if="page == 'users'"> + <x-suspend-user/> + <x-unsuspend-user/> + </div> + <div v-if="page == 'drive'"></div> + <div v-if="page == 'update'"></div> + </main> +</div> +</template> + +<script lang="ts"> +import Vue from "vue"; +import XDashboard from "./admin.dashboard.vue"; +import XSuspendUser from "./admin.suspend-user.vue"; +import XUnsuspendUser from "./admin.unsuspend-user.vue"; + +export default Vue.extend({ + components: { + XDashboard, + XSuspendUser, + XUnsuspendUser + }, + data() { + return { + page: 'dashboard' + }; + }, + methods: { + nav(page: string) { + this.page = page; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-admin + display flex + height 100% + margin 32px + + > nav + flex 0 0 250px + width 100% + height 100% + padding 16px 0 0 0 + overflow auto + border-right solid 1px #ddd + + > ul + list-style none + + > li + display block + padding 10px 16px + margin 0 + color #666 + cursor pointer + user-select none + transition margin-left 0.2s ease + + > [data-fa] + margin-right 4px + + + &:hover + color #555 + + &.active + margin-left 8px + color $theme-color !important + + > main + width 100% + padding 16px 32px + +header + margin 10px 0 + + +button + margin 16px 0 + position absolute + right 0 + +</style> 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 d2f46bd8be..70048f99e3 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 @@ -70,7 +70,8 @@ export default Vue.extend({ limit: fetchLimit + 1, mediaOnly: this.mediaOnly, includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes }).then(notes => { if (notes.length == fetchLimit + 1) { notes.pop(); @@ -91,7 +92,8 @@ export default Vue.extend({ untilId: (this.$refs.timeline as any).tail().id, mediaOnly: this.mediaOnly, includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes }); promise.then(notes => { 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 3578e17287..f7fca5de92 100644 --- a/src/client/app/desktop/views/pages/deck/deck.notes.vue +++ b/src/client/app/desktop/views/pages/deck/deck.notes.vue @@ -140,6 +140,12 @@ export default Vue.extend({ return; } } + + if (this.$store.state.settings.showLocalRenotes === false) { + if (isPureRenote && (note.renote.user.host == null)) { + return; + } + } //#endregion if (this.isScrollTop()) { 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 d402ee6a8b..a9e4d489c3 100644 --- a/src/client/app/desktop/views/pages/deck/deck.tl.vue +++ b/src/client/app/desktop/views/pages/deck/deck.tl.vue @@ -98,7 +98,8 @@ export default Vue.extend({ limit: fetchLimit + 1, mediaOnly: this.mediaOnly, includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes }).then(notes => { if (notes.length == fetchLimit + 1) { notes.pop(); @@ -119,7 +120,8 @@ export default Vue.extend({ mediaOnly: this.mediaOnly, untilId: (this.$refs.timeline as any).tail().id, includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes }); promise.then(notes => { 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 00545723e2..d8f4656ed0 100644 --- a/src/client/app/desktop/views/pages/user/user.header.vue +++ b/src/client/app/desktop/views/pages/user/user.header.vue @@ -176,6 +176,10 @@ root(isDark) height 120px box-shadow 1px 1px 3px rgba(#000, 0.2) + > &.cat::before, + > &.cat::after + border-width 8px + > .body padding 16px 16px 16px 154px color isDark ? #c5ced6 : #555 diff --git a/src/client/app/mobile/views/components/drive.file.vue b/src/client/app/mobile/views/components/drive.file.vue index 776e11ecf8..c337629cb6 100644 --- a/src/client/app/mobile/views/components/drive.file.vue +++ b/src/client/app/mobile/views/components/drive.file.vue @@ -43,7 +43,7 @@ export default Vue.extend({ thumbnail(): any { return { 'background-color': this.file.properties.avgColor && this.file.properties.avgColor.length == 3 ? `rgb(${this.file.properties.avgColor.join(',')})` : 'transparent', - 'background-image': `url(${this.file.url})` + 'background-image': `url(${this.file.thumbnailUrl})` }; } }, diff --git a/src/client/app/mobile/views/components/media-image.vue b/src/client/app/mobile/views/components/media-image.vue index d9d68fa7ba..e40069bbe3 100644 --- a/src/client/app/mobile/views/components/media-image.vue +++ b/src/client/app/mobile/views/components/media-image.vue @@ -27,7 +27,7 @@ export default Vue.extend({ }, computed: { style(): any { - let url = `url(${this.image.url})`; + let url = `url(${this.image.thumbnailUrl})`; if (this.$store.state.device.loadRemoteMedia || this.$store.state.device.lightmode) { url = null; diff --git a/src/client/app/mobile/views/components/notes.vue b/src/client/app/mobile/views/components/notes.vue index aed372d9a2..cce81d1b78 100644 --- a/src/client/app/mobile/views/components/notes.vue +++ b/src/client/app/mobile/views/components/notes.vue @@ -139,6 +139,12 @@ export default Vue.extend({ return; } } + + if (this.$store.state.settings.showLocalRenotes === false) { + if (isPureRenote && (note.renote.user.host == null)) { + return; + } + } //#endregion // 投稿が自分のものではないかつ、タブが非表示またはスクロール位置が最上部ではないならタイトルで通知 diff --git a/src/client/app/mobile/views/components/post-form.vue b/src/client/app/mobile/views/components/post-form.vue index 1c71d0d46f..702bc4c9e1 100644 --- a/src/client/app/mobile/views/components/post-form.vue +++ b/src/client/app/mobile/views/components/post-form.vue @@ -21,7 +21,7 @@ <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"> - <div class="img" :style="`background-image: url(${file.url})`" @click="detachMedia(file)"></div> + <div class="img" :style="`background-image: url(${file.thumbnailUrl})`" @click="detachMedia(file)"></div> </div> </x-draggable> </div> @@ -45,7 +45,7 @@ <input ref="file" class="file" type="file" accept="image/*" multiple="multiple" @change="onChangeFile"/> </div> </div> - <div class="hashtags" v-if="recentHashtags.length > 0"> + <div class="hashtags" v-if="recentHashtags.length > 0 && $store.state.settings.suggestRecentHashtags"> <a v-for="tag in recentHashtags.slice(0, 5)" @click="addTag(tag)">#{{ tag }}</a> </div> </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 2c1564b7ed..9b3f11f5c2 100644 --- a/src/client/app/mobile/views/components/user-list-timeline.vue +++ b/src/client/app/mobile/views/components/user-list-timeline.vue @@ -59,7 +59,8 @@ export default Vue.extend({ listId: this.list.id, limit: fetchLimit + 1, includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes }).then(notes => { if (notes.length == fetchLimit + 1) { notes.pop(); @@ -82,7 +83,8 @@ export default Vue.extend({ limit: fetchLimit + 1, untilId: (this.$refs.timeline as any).tail().id, includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes }); promise.then(notes => { diff --git a/src/client/app/mobile/views/pages/home.timeline.vue b/src/client/app/mobile/views/pages/home.timeline.vue index 93d1364e09..416b006cd8 100644 --- a/src/client/app/mobile/views/pages/home.timeline.vue +++ b/src/client/app/mobile/views/pages/home.timeline.vue @@ -95,7 +95,8 @@ export default Vue.extend({ limit: fetchLimit + 1, untilDate: this.date ? this.date.getTime() : undefined, includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes }).then(notes => { if (notes.length == fetchLimit + 1) { notes.pop(); @@ -117,7 +118,8 @@ export default Vue.extend({ limit: fetchLimit + 1, untilId: (this.$refs.timeline as any).tail().id, includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes }); promise.then(notes => { diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue index f74b734b6e..7636a0370a 100644 --- a/src/client/app/mobile/views/pages/settings.vue +++ b/src/client/app/mobile/views/pages/settings.vue @@ -21,6 +21,7 @@ <ui-switch v-model="$store.state.settings.showReplyTarget" @change="onChangeShowReplyTarget">%i18n:@show-reply-target%</ui-switch> <ui-switch v-model="$store.state.settings.showMyRenotes" @change="onChangeShowMyRenotes">%i18n:@show-my-renotes%</ui-switch> <ui-switch v-model="$store.state.settings.showRenotedMyNotes" @change="onChangeShowRenotedMyNotes">%i18n:@show-renoted-my-notes%</ui-switch> + <ui-switch v-model="$store.state.settings.showLocalRenotes" @change="onChangeShowLocalRenotes">%i18n:@show-local-renotes%</ui-switch> </div> <div> @@ -221,6 +222,13 @@ export default Vue.extend({ }); }, + onChangeShowLocalRenotes(v) { + this.$store.dispatch('settings/set', { + key: 'showLocalRenotes', + value: v + }); + }, + checkForUpdate() { this.checkingForUpdate = true; checkForUpdate((this as any).os, true, true).then(newer => { diff --git a/src/client/app/mobile/views/pages/settings/settings.profile.vue b/src/client/app/mobile/views/pages/settings/settings.profile.vue index da97cbebd7..78023ba501 100644 --- a/src/client/app/mobile/views/pages/settings/settings.profile.vue +++ b/src/client/app/mobile/views/pages/settings/settings.profile.vue @@ -61,7 +61,6 @@ export default Vue.extend({ birthday: null, avatarId: null, bannerId: null, - isBot: false, isCat: false, saving: false, avatarUploading: false, @@ -77,7 +76,6 @@ export default Vue.extend({ this.birthday = this.$store.state.i.profile.birthday; this.avatarId = this.$store.state.i.avatarId; this.bannerId = this.$store.state.i.bannerId; - this.isBot = this.$store.state.i.isBot; this.isCat = this.$store.state.i.isCat; }, @@ -136,7 +134,6 @@ export default Vue.extend({ birthday: this.birthday || null, avatarId: this.avatarId, bannerId: this.bannerId, - isBot: this.isBot, isCat: this.isCat }).then(i => { this.saving = false; diff --git a/src/client/app/store.ts b/src/client/app/store.ts index dfb24bb5f4..f85253a281 100644 --- a/src/client/app/store.ts +++ b/src/client/app/store.ts @@ -11,11 +11,13 @@ const defaultSettings = { fetchOnScroll: true, showMaps: true, showPostFormOnTopOfTl: false, + suggestRecentHashtags: true, circleIcons: true, gradientWindowHeader: false, showReplyTarget: true, showMyRenotes: true, showRenotedMyNotes: true, + showLocalRenotes: true, loadRemoteMedia: true, disableViaMobile: false, memo: null, diff --git a/src/client/assets/reactions/rip.png b/src/client/assets/reactions/rip.png new file mode 100644 index 0000000000..4800fdb91b Binary files /dev/null and b/src/client/assets/reactions/rip.png differ diff --git a/src/const.json b/src/const.json index b4f3b63ac7..b93226b2d2 100644 --- a/src/const.json +++ b/src/const.json @@ -1,5 +1,5 @@ { "copyright": "Copyright (c) 2014-2018 syuilo", - "themeColor": "#f66e4f", + "themeColor": "#f6584f", "themeColorForeground": "#fff" } diff --git a/src/daemons/notes-stats.ts b/src/daemons/notes-stats.ts index 136ccb60c2..3d2c4820a6 100644 --- a/src/daemons/notes-stats.ts +++ b/src/daemons/notes-stats.ts @@ -1,10 +1,11 @@ import * as childProcess from 'child_process'; +import * as Deque from 'double-ended-queue'; import Xev from 'xev'; const ev = new Xev(); export default function() { - const log: any[] = []; + const log = new Deque<any>(); const p = childProcess.fork(__dirname + '/notes-stats-child.js'); @@ -15,7 +16,7 @@ export default function() { }); ev.on('requestNotesStatsLog', id => { - ev.emit('notesStatsLog:' + id, log); + ev.emit('notesStatsLog:' + id, log.toArray()); }); process.on('exit', code => { diff --git a/src/daemons/server-stats.ts b/src/daemons/server-stats.ts index 0c0a72f747..af935d35b2 100644 --- a/src/daemons/server-stats.ts +++ b/src/daemons/server-stats.ts @@ -1,6 +1,7 @@ import * as os from 'os'; import * as sysUtils from 'systeminformation'; import * as diskusage from 'diskusage'; +import * as Deque from 'double-ended-queue'; import Xev from 'xev'; const osUtils = require('os-utils'); @@ -12,10 +13,10 @@ const interval = 1000; * Report server stats regularly */ export default function() { - const log: any[] = []; + const log = new Deque<any>(); ev.on('requestServerStatsLog', id => { - ev.emit('serverStatsLog:' + id, log); + ev.emit('serverStatsLog:' + id, log.toArray()); }); async function tick() { diff --git a/src/db/mongodb.ts b/src/db/mongodb.ts index 034473b0d3..f82ced1765 100644 --- a/src/db/mongodb.ts +++ b/src/db/mongodb.ts @@ -3,9 +3,7 @@ import config from '../config'; const u = config.mongodb.user ? encodeURIComponent(config.mongodb.user) : null; const p = config.mongodb.pass ? encodeURIComponent(config.mongodb.pass) : null; -const uri = u && p - ? `mongodb://${u}:${p}@${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}` - : `mongodb://${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`; +const uri = `mongodb://${u && p ? `${u}:${p}@` : ''}${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`; /** * monk diff --git a/src/index.ts b/src/index.ts index 0dda8b05b7..086e665679 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,7 +28,7 @@ import { Config } from './config/types'; const clusterLog = debug('misskey:cluster'); const ev = new Xev(); -if (process.env.NODE_ENV != 'production') { +if (process.env.NODE_ENV != 'production' && process.env.DEBUG == null) { debug.enable('misskey'); } @@ -48,7 +48,7 @@ main(); * Init process */ function main() { - process.title = `Misskey (${ cluster.isMaster ? 'master' : 'worker' })`; + process.title = `Misskey (${cluster.isMaster ? 'master' : 'worker'})`; if (cluster.isMaster || program.disableClustering) { masterMain(); @@ -112,7 +112,7 @@ async function workerMain() { async function init(): Promise<Config> { Logger.info('Welcome to Misskey!'); - (new Logger('Deps')).info(`Node.js ${process.version}`); + new Logger('Deps').info(`Node.js ${process.version}`); MachineInfo.show(); EnvironmentInfo.show(); new DependencyInfo().showAll(); @@ -154,11 +154,10 @@ async function init(): Promise<Config> { function checkMongoDb(config: Config) { const mongoDBLogger = new Logger('MongoDB'); - mongoDBLogger.info(`Host: ${config.mongodb.host}`); - mongoDBLogger.info(`Port: ${config.mongodb.port}`); - mongoDBLogger.info(`DB: ${config.mongodb.db}`); - if (config.mongodb.user) mongoDBLogger.info(`User: ${config.mongodb.user}`); - if (config.mongodb.pass) mongoDBLogger.info(`Pass: ****`); + const u = config.mongodb.user ? encodeURIComponent(config.mongodb.user) : null; + const p = config.mongodb.pass ? encodeURIComponent(config.mongodb.pass) : null; + const uri = `mongodb://${u && p ? `${u}:****@` : ''}${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`; + mongoDBLogger.info(`Connecting to ${uri}`); require('./db/mongodb'); mongoDBLogger.succ('Connectivity confirmed'); } diff --git a/src/mfm/parse/elements/bold.ts b/src/mfm/parse/elements/bold.ts index cf615cd3cc..c8c3c80a39 100644 --- a/src/mfm/parse/elements/bold.ts +++ b/src/mfm/parse/elements/bold.ts @@ -15,6 +15,6 @@ export default function(text: string) { return { type: 'bold', content: bold, - bold: bold.substr(2, bold.length - 4) + bold: match[1] } as TextElementBold; } diff --git a/src/mfm/parse/elements/code.ts b/src/mfm/parse/elements/code.ts index f48e945048..3c97fc1d99 100644 --- a/src/mfm/parse/elements/code.ts +++ b/src/mfm/parse/elements/code.ts @@ -18,7 +18,7 @@ export default function(text: string) { return { type: 'code', content: code, - code: code.substr(3, code.length - 6).trim(), - html: genHtml(code.substr(3, code.length - 6).trim()) + code: match[1], + html: genHtml(match[1].trim()) } as TextElementCode; } diff --git a/src/mfm/parse/elements/emoji.ts b/src/mfm/parse/elements/emoji.ts index 83d3effef5..cd9a3d032c 100644 --- a/src/mfm/parse/elements/emoji.ts +++ b/src/mfm/parse/elements/emoji.ts @@ -9,12 +9,12 @@ export type TextElementEmoji = { }; export default function(text: string) { - const match = text.match(/^:[a-zA-Z0-9+-_]+:/); + const match = text.match(/^:([a-zA-Z0-9+-_]+):/); if (!match) return null; const emoji = match[0]; return { type: 'emoji', content: emoji, - emoji: emoji.substr(1, emoji.length - 2) + emoji: match[1] } as TextElementEmoji; } diff --git a/src/mfm/parse/elements/inline-code.ts b/src/mfm/parse/elements/inline-code.ts index 1dd5affa51..e4ab499584 100644 --- a/src/mfm/parse/elements/inline-code.ts +++ b/src/mfm/parse/elements/inline-code.ts @@ -14,11 +14,12 @@ export type TextElementInlineCode = { 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: code.substr(1, code.length - 2).trim(), - html: genHtml(code.substr(1, code.length - 2).trim()) + code: match[1], + html: genHtml(match[1]) } as TextElementInlineCode; } diff --git a/src/mfm/parse/elements/quote.ts b/src/mfm/parse/elements/quote.ts index bef9ad4988..ea99240d5f 100644 --- a/src/mfm/parse/elements/quote.ts +++ b/src/mfm/parse/elements/quote.ts @@ -15,6 +15,6 @@ export default function(text: string) { return { type: 'quote', content: quote, - quote: quote.substr(1, quote.length - 2).trim(), + quote: match[1].trim(), } as TextElementQuote; } diff --git a/src/mfm/parse/elements/search.ts b/src/mfm/parse/elements/search.ts index 9c4b7ffbe5..2fb0f93f2c 100644 --- a/src/mfm/parse/elements/search.ts +++ b/src/mfm/parse/elements/search.ts @@ -9,7 +9,7 @@ export type TextElementSearch = { }; export default function(text: string) { - const match = text.match(/^(.+?) (検索|Search)(\n|$)/i); + const match = text.match(/^(.+?)( | )(検索|\[検索\]|Search|\[Search\])(\n|$)/i); if (!match) return null; return { type: 'search', diff --git a/src/misc/get-reaction-emoji.ts b/src/misc/get-reaction-emoji.ts index c661205379..9d6956c4ac 100644 --- a/src/misc/get-reaction-emoji.ts +++ b/src/misc/get-reaction-emoji.ts @@ -8,6 +8,7 @@ export default function(reaction: string): string { case 'congrats': return '🎉'; case 'angry': return '💢'; case 'confused': return '😥'; + case 'rip': return '😇'; case 'pudding': return '🍮'; default: return ''; } diff --git a/src/models/app.ts b/src/models/app.ts index 8dc7fe01d9..01cc946c6e 100644 --- a/src/models/app.ts +++ b/src/models/app.ts @@ -13,7 +13,7 @@ export default App; export type IApp = { _id: mongo.ObjectID; createdAt: Date; - userId: mongo.ObjectID; + userId: mongo.ObjectID | null; secret: string; name: string; nameId: string; diff --git a/src/models/drive-file.ts b/src/models/drive-file.ts index ad5496d7ca..2b9efc404d 100644 --- a/src/models/drive-file.ts +++ b/src/models/drive-file.ts @@ -10,7 +10,7 @@ import DriveFileThumbnail, { deleteDriveFileThumbnail } from './drive-file-thumb const DriveFile = monkDb.get<IDriveFile>('driveFiles.files'); DriveFile.createIndex('md5'); -DriveFile.createIndex(['metadata.uri', 'metadata.userId'], { sparse: true, unique: true }); +DriveFile.createIndex('metadata.uri'); export default DriveFile; export const DriveFileChunk = monkDb.get('driveFiles.chunks'); @@ -31,6 +31,7 @@ export type IMetadata = { comment: string; uri?: string; url?: string; + thumbnailUrl?: string; src?: string; deletedAt?: Date; withoutChunks?: boolean; @@ -164,6 +165,7 @@ export const pack = ( _target = Object.assign(_target, _file.metadata); _target.url = _file.metadata.url ? _file.metadata.url : `${config.drive_url}/${_target.id}/${encodeURIComponent(_target.name)}`; + _target.thumbnailUrl = _file.metadata.thumbnailUrl ? _file.metadata.thumbnailUrl : _file.metadata.url ? _file.metadata.url : `${config.drive_url}/${_target.id}/${encodeURIComponent(_target.name)}?thumbnail`; _target.isRemote = _file.metadata.isRemote; if (_target.properties == null) _target.properties = {}; diff --git a/src/models/note-reaction.ts b/src/models/note-reaction.ts index 915dc0cf91..a710fef364 100644 --- a/src/models/note-reaction.ts +++ b/src/models/note-reaction.ts @@ -26,6 +26,7 @@ export const validateReaction = $.str.or([ 'congrats', 'angry', 'confused', + 'rip', 'pudding' ]); diff --git a/src/models/note.ts b/src/models/note.ts index af6f6f7c01..9d2e23d901 100644 --- a/src/models/note.ts +++ b/src/models/note.ts @@ -340,7 +340,7 @@ export const pack = async ( _note = await rap(_note); if (_note.user.isCat && _note.text) { - _note.text = _note.text.replace(/な/g, 'にゃ').replace(/ナ/g, 'ニャ'); + _note.text = _note.text.replace(/な/g, 'にゃ').replace(/ナ/g, 'ニャ').replace(/ナ/g, 'ニャ'); } if (hide) { diff --git a/src/remote/activitypub/kernel/announce/note.ts b/src/remote/activitypub/kernel/announce/note.ts index 7aa6aa5707..60f2b9baa0 100644 --- a/src/remote/activitypub/kernel/announce/note.ts +++ b/src/remote/activitypub/kernel/announce/note.ts @@ -40,11 +40,13 @@ export default async function(resolver: Resolver, actor: IRemoteUser, activity: if (!note.to.includes('https://www.w3.org/ns/activitystreams#Public')) { if (note.cc.includes('https://www.w3.org/ns/activitystreams#Public')) { visibility = 'home'; + } else if (note.to.includes(`${actor.uri}/followers`)) { // TODO: person.followerと照合するべき? + visibility = 'followers'; } else { visibility = 'specified'; visibleUsers = await Promise.all(note.to.map(uri => resolvePerson(uri))); } - } if (activity.cc.length == 0) visibility = 'followers'; + } //#endergion await post(actor, { diff --git a/src/remote/activitypub/models/note.ts b/src/remote/activitypub/models/note.ts index 85a8f89bc8..dbad63ea42 100644 --- a/src/remote/activitypub/models/note.ts +++ b/src/remote/activitypub/models/note.ts @@ -69,12 +69,13 @@ export async function createNote(value: any, resolver?: Resolver, silent = false if (!note.to.includes('https://www.w3.org/ns/activitystreams#Public')) { if (note.cc.includes('https://www.w3.org/ns/activitystreams#Public')) { visibility = 'home'; + } else if (note.to.includes(`${actor.uri}/followers`)) { // TODO: person.followerと照合するべき? + visibility = 'followers'; } else { visibility = 'specified'; visibleUsers = await Promise.all(note.to.map(uri => resolvePerson(uri))); } } - if (note.cc.length == 0) visibility = 'followers'; //#endergion // 添付メディア diff --git a/src/remote/activitypub/renderer/announce.ts b/src/remote/activitypub/renderer/announce.ts index 8c3ff9f113..6d5a67b5c3 100644 --- a/src/remote/activitypub/renderer/announce.ts +++ b/src/remote/activitypub/renderer/announce.ts @@ -1,4 +1,15 @@ -export default (object: any) => ({ - type: 'Announce', - object -}); +import config from '../../../config'; +import { INote } from '../../../models/note'; + +export default (object: any, note: INote) => { + const attributedTo = `${config.url}/users/${note.userId}`; + + return { + id: `${config.url}/notes/${note._id}`, + type: 'Announce', + published: note.createdAt.toISOString(), + to: ['https://www.w3.org/ns/activitystreams#Public'], + cc: [attributedTo, `${attributedTo}/followers`], + object + }; +}; diff --git a/src/remote/activitypub/renderer/document.ts b/src/remote/activitypub/renderer/document.ts index 1985c6bc8b..1f4543d7ee 100644 --- a/src/remote/activitypub/renderer/document.ts +++ b/src/remote/activitypub/renderer/document.ts @@ -4,5 +4,5 @@ import { IDriveFile } from '../../../models/drive-file'; export default (file: IDriveFile) => ({ type: 'Document', mediaType: file.contentType, - url: `${config.drive_url}/${file._id}` + url: file.metadata.url || `${config.drive_url}/${file._id}` }); diff --git a/src/remote/activitypub/renderer/follow-user.ts b/src/remote/activitypub/renderer/follow-user.ts new file mode 100644 index 0000000000..9a488d392b --- /dev/null +++ b/src/remote/activitypub/renderer/follow-user.ts @@ -0,0 +1,16 @@ +import config from '../../../config'; +import * as mongo from 'mongodb'; +import User, { isLocalUser } from '../../../models/user'; + +/** + * Convert (local|remote)(Follower|Followee)ID to URL + * @param id Follower|Followee ID + */ +export default async function renderFollowUser(id: mongo.ObjectID): Promise<any> { + + const user = await User.findOne({ + _id: id + }); + + return isLocalUser(user) ? `${config.url}/users/${user._id}` : user.uri; +} diff --git a/src/remote/activitypub/renderer/image.ts b/src/remote/activitypub/renderer/image.ts index 69bddd9188..b2f2555003 100644 --- a/src/remote/activitypub/renderer/image.ts +++ b/src/remote/activitypub/renderer/image.ts @@ -3,6 +3,6 @@ import { IDriveFile } from '../../../models/drive-file'; export default (file: IDriveFile) => ({ type: 'Image', - url: `${config.drive_url}/${file._id}`, + url: file.metadata.url || `${config.drive_url}/${file._id}`, sensitive: file.metadata.isSensitive }); diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts index 7cee2be22c..209e743927 100644 --- a/src/remote/activitypub/renderer/note.ts +++ b/src/remote/activitypub/renderer/note.ts @@ -50,9 +50,21 @@ export default async function renderNote(note: INote, dive = true): Promise<any> ? note.mentionedRemoteUsers.map(x => x.uri) : []; - const cc = ['public', 'home', 'followers'].includes(note.visibility) - ? [`${attributedTo}/followers`].concat(mentions) - : []; + let to: string[] = []; + let cc: string[] = []; + + if (note.visibility == 'public') { + to = ['https://www.w3.org/ns/activitystreams#Public']; + cc = [`${attributedTo}/followers`].concat(mentions); + } else if (note.visibility == 'home') { + to = [`${attributedTo}/followers`]; + cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions); + } else if (note.visibility == 'followers') { + to = [`${attributedTo}/followers`]; + cc = mentions; + } else { + to = mentions; + } const mentionedUsers = note.mentions ? await User.find({ _id: { @@ -74,7 +86,7 @@ export default async function renderNote(note: INote, dive = true): Promise<any> summary: note.cw, content: toHtml(note), published: note.createdAt.toISOString(), - to: 'https://www.w3.org/ns/activitystreams#Public', + to, cc, inReplyTo, attachment: (await promisedFiles).map(renderDocument), diff --git a/src/remote/activitypub/renderer/ordered-collection-page.ts b/src/remote/activitypub/renderer/ordered-collection-page.ts new file mode 100644 index 0000000000..83af07870e --- /dev/null +++ b/src/remote/activitypub/renderer/ordered-collection-page.ts @@ -0,0 +1,23 @@ +/** + * Render OrderedCollectionPage + * @param id URL of self + * @param totalItems Number of total items + * @param orderedItems Items + * @param partOf URL of base + * @param prev URL of prev page (optional) + * @param next URL of next page (optional) + */ +export default function(id: string, totalItems: any, orderedItems: any, partOf: string, prev: string, next: string) { + const page = { + id, + partOf, + type: 'OrderedCollectionPage', + totalItems, + orderedItems + } as any; + + if (prev) page.prev = prev; + if (next) page.next = next; + + return page; +} diff --git a/src/remote/activitypub/renderer/ordered-collection.ts b/src/remote/activitypub/renderer/ordered-collection.ts index 9d543b1e1b..3c448cf873 100644 --- a/src/remote/activitypub/renderer/ordered-collection.ts +++ b/src/remote/activitypub/renderer/ordered-collection.ts @@ -1,6 +1,19 @@ -export default (id: string, totalItems: any, orderedItems: any) => ({ - id, - type: 'OrderedCollection', - totalItems, - orderedItems -}); +/** + * Render OrderedCollection + * @param id URL of self + * @param totalItems Total number of items + * @param first URL of first page (optional) + * @param last URL of last page (optional) + */ +export default function(id: string, totalItems: any, first: string, last: string) { + const page: any = { + id, + type: 'OrderedCollection', + totalItems, + }; + + if (first) page.first = first; + if (last) page.last = last; + + return page; +} diff --git a/src/remote/activitypub/renderer/person.ts b/src/remote/activitypub/renderer/person.ts index 7d828f97ae..0d227303c0 100644 --- a/src/remote/activitypub/renderer/person.ts +++ b/src/remote/activitypub/renderer/person.ts @@ -19,6 +19,8 @@ export default async (user: ILocalUser) => { id, inbox: `${id}/inbox`, outbox: `${id}/outbox`, + followers: `${id}/followers`, + following: `${id}/following`, sharedInbox: `${config.url}/inbox`, url: `${config.url}/@${user.username}`, preferredUsername: user.username, diff --git a/src/server/activitypub.ts b/src/server/activitypub.ts index 2d9a4746c6..c2dec2b997 100644 --- a/src/server/activitypub.ts +++ b/src/server/activitypub.ts @@ -10,8 +10,9 @@ import User, { isLocalUser, ILocalUser, IUser } from '../models/user'; import renderNote from '../remote/activitypub/renderer/note'; import renderKey from '../remote/activitypub/renderer/key'; import renderPerson from '../remote/activitypub/renderer/person'; -import renderOrderedCollection from '../remote/activitypub/renderer/ordered-collection'; -import config from '../config'; +import Outbox from './activitypub/outbox'; +import Followers from './activitypub/followers'; +import Following from './activitypub/following'; // Init router const router = new Router(); @@ -64,30 +65,14 @@ router.get('/notes/:note', async (ctx, next) => { ctx.body = pack(await renderNote(note)); }); -// outbot -router.get('/users/:user/outbox', async ctx => { - const userId = new mongo.ObjectID(ctx.params.user); +// outbox +router.get('/users/:user/outbox', Outbox); - const user = await User.findOne({ - _id: userId, - host: null - }); +// followers +router.get('/users/:user/followers', Followers); - if (user === null) { - ctx.status = 404; - return; - } - - const notes = await Note.find({ userId: user._id }, { - limit: 10, - sort: { _id: -1 } - }); - - const renderedNotes = await Promise.all(notes.map(note => renderNote(note))); - const rendered = renderOrderedCollection(`${config.url}/users/${userId}/inbox`, user.notesCount, renderedNotes); - - ctx.body = pack(rendered); -}); +// following +router.get('/users/:user/following', Following); // publickey router.get('/users/:user/publickey', async ctx => { diff --git a/src/server/activitypub/followers.ts b/src/server/activitypub/followers.ts new file mode 100644 index 0000000000..d51d45b1c7 --- /dev/null +++ b/src/server/activitypub/followers.ts @@ -0,0 +1,80 @@ +import * as mongo from 'mongodb'; +import * as Koa from 'koa'; +import config from '../../config'; +import $ from 'cafy'; import ID from '../../misc/cafy-id'; +import User from '../../models/user'; +import Following from '../../models/following'; +import pack from '../../remote/activitypub/renderer'; +import renderOrderedCollection from '../../remote/activitypub/renderer/ordered-collection'; +import renderOrderedCollectionPage from '../../remote/activitypub/renderer/ordered-collection-page'; +import renderFollowUser from '../../remote/activitypub/renderer/follow-user'; + +export default async (ctx: Koa.Context) => { + const userId = new mongo.ObjectID(ctx.params.user); + + // Get 'cursor' parameter + const [cursor = null, cursorErr] = $.type(ID).optional.get(ctx.request.query.cursor); + + // Get 'page' parameter + const pageErr = !$.str.optional.or(['true', 'false']).ok(ctx.request.query.page); + const page: boolean = ctx.request.query.page === 'true'; + + // Validate parameters + if (cursorErr || pageErr) { + ctx.status = 400; + return; + } + + // Verify user + const user = await User.findOne({ + _id: userId, + host: null + }); + + if (user === null) { + ctx.status = 404; + return; + } + + const limit = 10; + const partOf = `${config.url}/users/${userId}/followers`; + + if (page) { + // Construct query + const query = { + followeeId: user._id + } as any; + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: cursor + }; + } + + // Get followers + const followings = await Following + .find(query, { + limit: limit + 1, + sort: { _id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = followings.length === limit + 1; + if (inStock) followings.pop(); + + const renderedFollowers = await Promise.all(followings.map(following => renderFollowUser(following.followerId))); + const rendered = renderOrderedCollectionPage( + `${partOf}?page=true${cursor ? `&cursor=${cursor}` : ''}`, + user.followersCount, renderedFollowers, partOf, + null, + inStock ? `${partOf}?page=true&cursor=${followings[followings.length - 1]._id}` : null + ); + + ctx.body = pack(rendered); + } else { + // index page + const rendered = renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`, null); + ctx.body = pack(rendered); + } +}; diff --git a/src/server/activitypub/following.ts b/src/server/activitypub/following.ts new file mode 100644 index 0000000000..7e496f590d --- /dev/null +++ b/src/server/activitypub/following.ts @@ -0,0 +1,80 @@ +import * as mongo from 'mongodb'; +import * as Koa from 'koa'; +import config from '../../config'; +import $ from 'cafy'; import ID from '../../misc/cafy-id'; +import User from '../../models/user'; +import Following from '../../models/following'; +import pack from '../../remote/activitypub/renderer'; +import renderOrderedCollection from '../../remote/activitypub/renderer/ordered-collection'; +import renderOrderedCollectionPage from '../../remote/activitypub/renderer/ordered-collection-page'; +import renderFollowUser from '../../remote/activitypub/renderer/follow-user'; + +export default async (ctx: Koa.Context) => { + const userId = new mongo.ObjectID(ctx.params.user); + + // Get 'cursor' parameter + const [cursor = null, cursorErr] = $.type(ID).optional.get(ctx.request.query.cursor); + + // Get 'page' parameter + const pageErr = !$.str.optional.or(['true', 'false']).ok(ctx.request.query.page); + const page: boolean = ctx.request.query.page === 'true'; + + // Validate parameters + if (cursorErr || pageErr) { + ctx.status = 400; + return; + } + + // Verify user + const user = await User.findOne({ + _id: userId, + host: null + }); + + if (user === null) { + ctx.status = 404; + return; + } + + const limit = 10; + const partOf = `${config.url}/users/${userId}/following`; + + if (page) { + // Construct query + const query = { + followerId: user._id + } as any; + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: cursor + }; + } + + // Get followings + const followings = await Following + .find(query, { + limit: limit + 1, + sort: { _id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = followings.length === limit + 1; + if (inStock) followings.pop(); + + const renderedFollowees = await Promise.all(followings.map(following => renderFollowUser(following.followeeId))); + const rendered = renderOrderedCollectionPage( + `${partOf}?page=true${cursor ? `&cursor=${cursor}` : ''}`, + user.followingCount, renderedFollowees, partOf, + null, + inStock ? `${partOf}?page=true&cursor=${followings[followings.length - 1]._id}` : null + ); + + ctx.body = pack(rendered); + } else { + // index page + const rendered = renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`, null); + ctx.body = pack(rendered); + } +}; diff --git a/src/server/activitypub/outbox.ts b/src/server/activitypub/outbox.ts new file mode 100644 index 0000000000..91473b77e3 --- /dev/null +++ b/src/server/activitypub/outbox.ts @@ -0,0 +1,103 @@ +import * as mongo from 'mongodb'; +import * as Koa from 'koa'; +import config from '../../config'; +import $ from 'cafy'; import ID from '../../misc/cafy-id'; +import User from '../../models/user'; +import pack from '../../remote/activitypub/renderer'; +import renderOrderedCollection from '../../remote/activitypub/renderer/ordered-collection'; +import renderOrderedCollectionPage from '../../remote/activitypub/renderer/ordered-collection-page'; + +import Note from '../../models/note'; +import renderNote from '../../remote/activitypub/renderer/note'; + +export default async (ctx: Koa.Context) => { + const userId = new mongo.ObjectID(ctx.params.user); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional.get(ctx.request.query.since_id); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional.get(ctx.request.query.until_id); + + // Get 'page' parameter + const pageErr = !$.str.optional.or(['true', 'false']).ok(ctx.request.query.page); + const page: boolean = ctx.request.query.page === 'true'; + + // Validate parameters + if (sinceIdErr || untilIdErr || pageErr || [sinceId, untilId].filter(x => x != null).length > 1) { + ctx.status = 400; + return; + } + + // Verify user + const user = await User.findOne({ + _id: userId, + host: null + }); + + if (user === null) { + ctx.status = 404; + return; + } + + const limit = 20; + const partOf = `${config.url}/users/${userId}/outbox`; + + if (page) { + //#region Construct query + const sort = { + _id: -1 + }; + + const query = { + userId: user._id, + $and: [{ + $or: [ { visibility: 'public' }, { visibility: 'home' } ] + }, { // exclude renote, but include quote + $or: [{ + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }] + }] + } as any; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + //#endregion + + // Issue query + const notes = await Note + .find(query, { + limit: limit, + sort: sort + }); + + if (sinceId) notes.reverse(); + + const renderedNotes = await Promise.all(notes.map(note => renderNote(note))); + const rendered = renderOrderedCollectionPage( + `${partOf}?page=true${sinceId ? `&since_id=${sinceId}` : ''}${untilId ? `&until_id=${untilId}` : ''}`, + user.notesCount, renderedNotes, partOf, + notes.length > 0 ? `${partOf}?page=true&since_id=${notes[0]._id}` : null, + notes.length > 0 ? `${partOf}?page=true&until_id=${notes[notes.length - 1]._id}` : null + ); + + ctx.body = pack(rendered); + } else { + // index page + const rendered = renderOrderedCollection(partOf, user.notesCount, + `${partOf}?page=true`, + `${partOf}?page=true&since_id=000000000000000000000000` + ); + ctx.body = pack(rendered); + } +}; diff --git a/src/server/api/call.ts b/src/server/api/call.ts index 1d0e858762..e4bb30b695 100644 --- a/src/server/api/call.ts +++ b/src/server/api/call.ts @@ -1,6 +1,6 @@ import { performance } from 'perf_hooks'; import limitter from './limitter'; -import { IUser } from '../../models/user'; +import { IUser, isLocalUser } from '../../models/user'; import { IApp } from '../../models/app'; import endpoints from './endpoints'; @@ -21,6 +21,10 @@ export default (endpoint: string, user: IUser, app: IApp, data: any, file?: any) return rej('YOUR_ACCOUNT_HAS_BEEN_SUSPENDED'); } + if (ep.meta.requireAdmin && !(isLocalUser(user) && user.isAdmin)) { + return rej('YOU_ARE_NOT_ADMIN'); + } + if (app && ep.meta.kind) { if (!app.permission.some(p => p === ep.meta.kind)) { return rej('PERMISSION_DENIED'); @@ -53,7 +57,7 @@ export default (endpoint: string, user: IUser, app: IApp, data: any, file?: any) const time = after - before; if (time > 1000) { - console.warn(`SLOW API CALL DETECTED: ${ep.name} (${ time }ms)`); + console.warn(`SLOW API CALL DETECTED: ${ep.name} (${time}ms)`); } } catch (e) { rej(e); diff --git a/src/server/api/endpoints.ts b/src/server/api/endpoints.ts index 332a051ae1..d4a44070e6 100644 --- a/src/server/api/endpoints.ts +++ b/src/server/api/endpoints.ts @@ -14,6 +14,11 @@ export interface IEndpointMeta { */ requireCredential?: boolean; + /** + * 管理者のみ使えるエンドポイントか否か + */ + requireAdmin?: boolean; + /** * エンドポイントのリミテーションに関するやつ * 省略した場合はリミテーションは無いものとして解釈されます。 diff --git a/src/server/api/endpoints/admin/suspend-user.ts b/src/server/api/endpoints/admin/suspend-user.ts new file mode 100644 index 0000000000..8698120cdb --- /dev/null +++ b/src/server/api/endpoints/admin/suspend-user.ts @@ -0,0 +1,46 @@ +import $ from 'cafy'; +import ID from '../../../../misc/cafy-id'; +import getParams from '../../get-params'; +import User from '../../../../models/user'; + +export const meta = { + desc: { + ja: '指定したユーザーを凍結します。', + en: 'Suspend a user.' + }, + + requireCredential: true, + requireAdmin: true, + + params: { + userId: $.type(ID).note({ + desc: { + ja: '対象のユーザーID', + en: 'The user ID which you want to suspend' + } + }), + } +}; + +export default (params: any) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const user = await User.findOne({ + _id: ps.userId + }); + + if (user == null) { + return rej('user not found'); + } + + await User.findOneAndUpdate({ + _id: user._id + }, { + $set: { + isSuspended: true + } + }); + + res(); +}); diff --git a/src/server/api/endpoints/admin/unsuspend-user.ts b/src/server/api/endpoints/admin/unsuspend-user.ts new file mode 100644 index 0000000000..8409bd1b76 --- /dev/null +++ b/src/server/api/endpoints/admin/unsuspend-user.ts @@ -0,0 +1,46 @@ +import $ from 'cafy'; +import ID from '../../../../misc/cafy-id'; +import getParams from '../../get-params'; +import User from '../../../../models/user'; + +export const meta = { + desc: { + ja: '指定したユーザーの凍結を解除します。', + en: 'Unsuspend a user.' + }, + + requireCredential: true, + requireAdmin: true, + + params: { + userId: $.type(ID).note({ + desc: { + ja: '対象のユーザーID', + en: 'The user ID which you want to unsuspend' + } + }), + } +}; + +export default (params: any) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const user = await User.findOne({ + _id: ps.userId + }); + + if (user == null) { + return rej('user not found'); + } + + await User.findOneAndUpdate({ + _id: user._id + }, { + $set: { + isSuspended: false + } + }); + + res(); +}); diff --git a/src/server/api/endpoints/app/create.ts b/src/server/api/endpoints/app/create.ts index 5df8bd2f25..b2a5fb73c1 100644 --- a/src/server/api/endpoints/app/create.ts +++ b/src/server/api/endpoints/app/create.ts @@ -4,7 +4,7 @@ import App, { isValidNameId, pack } from '../../../../models/app'; import { ILocalUser } from '../../../../models/user'; export const meta = { - requireCredential: true + requireCredential: false }; /** @@ -38,7 +38,7 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res, // Create account const app = await App.insert({ createdAt: new Date(), - userId: user._id, + userId: user && user._id, name: name, nameId: nameId, nameIdLower: nameId.toLowerCase(), diff --git a/src/server/api/endpoints/messaging/history.ts b/src/server/api/endpoints/messaging/history.ts index 66798d50c5..43cceacf95 100644 --- a/src/server/api/endpoints/messaging/history.ts +++ b/src/server/api/endpoints/messaging/history.ts @@ -40,6 +40,5 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = }); // Serialize - res(await Promise.all(history.map(async h => - await pack(h.messageId, user)))); + res(await Promise.all(history.map(h => pack(h.messageId, user)))); }); diff --git a/src/server/api/endpoints/messaging/messages/read.ts b/src/server/api/endpoints/messaging/messages/read.ts new file mode 100644 index 0000000000..f609337523 --- /dev/null +++ b/src/server/api/endpoints/messaging/messages/read.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; import ID from '../../../../../misc/cafy-id'; +import Message from '../../../../../models/messaging-message'; +import { ILocalUser } from '../../../../../models/user'; +import read from '../../../common/read-messaging-message'; +import getParams from '../../../get-params'; + +export const meta = { + desc: { + ja: '指定した自分宛てのメッセージを既読にします。', + en: 'Mark as read a message of messaging.' + }, + + requireCredential: true, + + kind: 'messaging-write', + + params: { + messageId: $.type(ID).note({ + desc: { + ja: '既読にするメッセージのID', + en: 'The ID of a message that you want to mark as read' + } + }) + } +}; + +export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) throw psErr; + + const message = await Message.findOne({ + _id: ps.messageId, + recipientId: user._id + }); + + if (message == null) { + return rej('message not found'); + } + + read(user._id, message.userId, message); + + res(); +}); diff --git a/src/server/api/endpoints/notes/hybrid-timeline.ts b/src/server/api/endpoints/notes/hybrid-timeline.ts index 036f84b54b..3fce4fb9af 100644 --- a/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -59,6 +59,13 @@ export const meta = { } }), + includeLocalRenotes: $.bool.optional.note({ + default: true, + desc: { + ja: 'Renoteされたローカルの投稿を含めるかどうか' + } + }), + mediaOnly: $.bool.optional.note({ desc: { ja: 'true にすると、メディアが添付された投稿だけ取得します' @@ -180,6 +187,22 @@ export default async (params: any, user: ILocalUser) => { }); } + if (ps.includeLocalRenotes === false) { + query.$and.push({ + $or: [{ + '_renote.user.host': { $ne: null } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + if (ps.mediaOnly) { query.$and.push({ mediaIds: { $exists: true, $ne: [] } diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts index faa8ccf3ca..3e3fa8c4aa 100644 --- a/src/server/api/endpoints/notes/timeline.ts +++ b/src/server/api/endpoints/notes/timeline.ts @@ -8,7 +8,8 @@ import getParams from '../../get-params'; export const meta = { desc: { - ja: 'タイムラインを取得します。' + ja: 'タイムラインを取得します。', + en: 'Get timeline of myself.' }, requireCredential: true, @@ -59,6 +60,13 @@ export const meta = { } }), + includeLocalRenotes: $.bool.optional.note({ + default: true, + desc: { + ja: 'Renoteされたローカルの投稿を含めるかどうか' + } + }), + mediaOnly: $.bool.optional.note({ desc: { ja: 'true にすると、メディアが添付された投稿だけ取得します' @@ -67,9 +75,6 @@ export const meta = { } }; -/** - * Get timeline of myself - */ export default async (params: any, user: ILocalUser) => { const [ps, psErr] = getParams(meta, params); if (psErr) throw psErr; @@ -172,6 +177,22 @@ export default async (params: any, user: ILocalUser) => { }); } + if (ps.includeLocalRenotes === false) { + query.$and.push({ + $or: [{ + '_renote.user.host': { $ne: null } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + if (ps.mediaOnly) { query.$and.push({ mediaIds: { $exists: true, $ne: [] } diff --git a/src/server/api/endpoints/notes/user-list-timeline.ts b/src/server/api/endpoints/notes/user-list-timeline.ts index 5837a9a301..dcef548666 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 { pack } from '../../../../models/note'; import UserList from '../../../../models/user-list'; import { ILocalUser } from '../../../../models/user'; +import getParams from '../../get-params'; export const meta = { desc: { @@ -11,56 +12,84 @@ export const meta = { en: 'Get timeline of a user list.' }, - requireCredential: true + requireCredential: true, + + params: { + listId: $.type(ID).note({ + desc: { + ja: 'リストのID' + } + }), + + limit: $.num.optional.range(1, 100).note({ + default: 10, + desc: { + ja: '最大数' + } + }), + + sinceId: $.type(ID).optional.note({ + desc: { + ja: '指定すると、この投稿を基点としてより新しい投稿を取得します' + } + }), + + untilId: $.type(ID).optional.note({ + desc: { + ja: '指定すると、この投稿を基点としてより古い投稿を取得します' + } + }), + + sinceDate: $.num.optional.note({ + desc: { + ja: '指定した時間を基点としてより新しい投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。' + } + }), + + untilDate: $.num.optional.note({ + desc: { + ja: '指定した時間を基点としてより古い投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。' + } + }), + + includeMyRenotes: $.bool.optional.note({ + default: true, + desc: { + ja: '自分の行ったRenoteを含めるかどうか' + } + }), + + includeRenotedMyNotes: $.bool.optional.note({ + default: true, + desc: { + ja: 'Renoteされた自分の投稿を含めるかどうか' + } + }), + + includeLocalRenotes: $.bool.optional.note({ + default: true, + desc: { + ja: 'Renoteされたローカルの投稿を含めるかどうか' + } + }), + + mediaOnly: $.bool.optional.note({ + desc: { + ja: 'true にすると、メディアが添付された投稿だけ取得します' + } + }), + } }; export default async (params: any, user: ILocalUser) => { - // Get 'limit' parameter - const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit); - if (limitErr) throw 'invalid limit param'; - - // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $.type(ID).optional.get(params.sinceId); - if (sinceIdErr) throw 'invalid sinceId param'; - - // Get 'untilId' parameter - const [untilId, untilIdErr] = $.type(ID).optional.get(params.untilId); - if (untilIdErr) throw 'invalid untilId param'; - - // Get 'sinceDate' parameter - const [sinceDate, sinceDateErr] = $.num.optional.get(params.sinceDate); - if (sinceDateErr) throw 'invalid sinceDate param'; - - // Get 'untilDate' parameter - const [untilDate, untilDateErr] = $.num.optional.get(params.untilDate); - if (untilDateErr) throw 'invalid untilDate param'; - - // Check if only one of sinceId, untilId, sinceDate, untilDate specified - if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { - throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; - } - - // Get 'includeMyRenotes' parameter - const [includeMyRenotes = true, includeMyRenotesErr] = $.bool.optional.get(params.includeMyRenotes); - if (includeMyRenotesErr) throw 'invalid includeMyRenotes param'; - - // Get 'includeRenotedMyNotes' parameter - const [includeRenotedMyNotes = true, includeRenotedMyNotesErr] = $.bool.optional.get(params.includeRenotedMyNotes); - if (includeRenotedMyNotesErr) throw 'invalid includeRenotedMyNotes param'; - - // Get 'mediaOnly' parameter - const [mediaOnly, mediaOnlyErr] = $.bool.optional.get(params.mediaOnly); - if (mediaOnlyErr) throw 'invalid mediaOnly param'; - - // Get 'listId' parameter - const [listId, listIdErr] = $.type(ID).get(params.listId); - if (listIdErr) throw 'invalid listId param'; + const [ps, psErr] = getParams(meta, params); + if (psErr) throw psErr; const [list, mutedUserIds] = await Promise.all([ // リストを取得 // Fetch the list UserList.findOne({ - _id: listId, + _id: ps.listId, userId: user._id }), @@ -122,7 +151,7 @@ export default async (params: any, user: ILocalUser) => { // つまり、「『自分の投稿かつRenote』ではない」を「『自分の投稿ではない』または『Renoteではない』」と表現します。 // for details: https://en.wikipedia.org/wiki/De_Morgan%27s_laws - if (includeMyRenotes === false) { + if (ps.includeMyRenotes === false) { query.$and.push({ $or: [{ userId: { $ne: user._id } @@ -138,7 +167,7 @@ export default async (params: any, user: ILocalUser) => { }); } - if (includeRenotedMyNotes === false) { + if (ps.includeRenotedMyNotes === false) { query.$and.push({ $or: [{ '_renote.userId': { $ne: user._id } @@ -154,29 +183,45 @@ export default async (params: any, user: ILocalUser) => { }); } - if (mediaOnly) { + if (ps.includeLocalRenotes === false) { + query.$and.push({ + $or: [{ + '_renote.user.host': { $ne: null } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + + if (ps.mediaOnly) { query.$and.push({ mediaIds: { $exists: true, $ne: [] } }); } - if (sinceId) { + if (ps.sinceId) { sort._id = 1; query._id = { - $gt: sinceId + $gt: ps.sinceId }; - } else if (untilId) { + } else if (ps.untilId) { query._id = { - $lt: untilId + $lt: ps.untilId }; - } else if (sinceDate) { + } else if (ps.sinceDate) { sort._id = 1; query.createdAt = { - $gt: new Date(sinceDate) + $gt: new Date(ps.sinceDate) }; - } else if (untilDate) { + } else if (ps.untilDate) { query.createdAt = { - $lt: new Date(untilDate) + $lt: new Date(ps.untilDate) }; } //#endregion @@ -184,7 +229,7 @@ export default async (params: any, user: ILocalUser) => { // Issue query const timeline = await Note .find(query, { - limit: limit, + limit: ps.limit, sort: sort }); diff --git a/src/server/api/endpoints/users/notes.ts b/src/server/api/endpoints/users/notes.ts index c60050d3cd..ff7855bde0 100644 --- a/src/server/api/endpoints/users/notes.ts +++ b/src/server/api/endpoints/users/notes.ts @@ -16,17 +16,13 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => if (usernameErr) return rej('invalid username param'); if (userId === undefined && username === undefined) { - return rej('userId or pair of username and host is required'); + return rej('userId or username is required'); } // Get 'host' parameter const [host, hostErr] = $.str.optional.get(params.host); if (hostErr) return rej('invalid host param'); - if (userId === undefined && host === undefined) { - return rej('userId or pair of username and host is required'); - } - // Get 'includeReplies' parameter const [includeReplies = true, includeRepliesErr] = $.bool.optional.get(params.includeReplies); if (includeRepliesErr) return rej('invalid includeReplies param'); diff --git a/src/server/api/private/signup.ts b/src/server/api/private/signup.ts index 563b6ca845..16ec33bcbf 100644 --- a/src/server/api/private/signup.ts +++ b/src/server/api/private/signup.ts @@ -92,7 +92,7 @@ export default async (ctx: Koa.Context) => { weight: null }, settings: { - autoWatch: true + autoWatch: false } }); diff --git a/src/server/file/send-drive-file.ts b/src/server/file/send-drive-file.ts index 1a76b0e41f..b904bda91b 100644 --- a/src/server/file/send-drive-file.ts +++ b/src/server/file/send-drive-file.ts @@ -1,5 +1,3 @@ -import * as fs from 'fs'; - import * as Koa from 'koa'; import * as send from 'koa-send'; import * as mongodb from 'mongodb'; @@ -51,23 +49,16 @@ export default async function(ctx: Koa.Context) { }; if ('thumbnail' in ctx.query) { - // 画像以外 - if (!file.contentType.startsWith('image/')) { - const readable = fs.createReadStream(`${__dirname}/assets/thumbnail-not-available.png`); - ctx.set('Content-Type', 'image/png'); - ctx.body = readable; - } else if (file.contentType == 'image/gif') { - // GIF - await sendRaw(); + const thumb = await DriveFileThumbnail.findOne({ + 'metadata.originalId': fileId + }); + + if (thumb != null) { + ctx.set('Content-Type', 'image/jpeg'); + const bucket = await getDriveFileThumbnailBucket(); + ctx.body = bucket.openDownloadStream(thumb._id); } else { - const thumb = await DriveFileThumbnail.findOne({ 'metadata.originalId': fileId }); - if (thumb != null) { - ctx.set('Content-Type', 'image/jpeg'); - const bucket = await getDriveFileThumbnailBucket(); - ctx.body = bucket.openDownloadStream(thumb._id); - } else { - await sendRaw(); - } + await sendRaw(); } } else { if ('download' in ctx.query) { diff --git a/src/server/web/url-preview.ts b/src/server/web/url-preview.ts index 99ee2eaebd..e96eb309fe 100644 --- a/src/server/web/url-preview.ts +++ b/src/server/web/url-preview.ts @@ -14,7 +14,9 @@ module.exports = async (ctx: Koa.Context) => { ctx.body = summary; } catch (e) { - ctx.status = 500; + ctx.status = 200; + ctx.set('Cache-Control', 'max-age=86400, immutable'); + ctx.body = '{}'; } }; diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts index 701d547776..da0d3fd82f 100644 --- a/src/services/drive/add-file.ts +++ b/src/services/drive/add-file.ts @@ -1,6 +1,5 @@ import { Buffer } from 'buffer'; import * as fs from 'fs'; -import * as stream from 'stream'; import * as mongodb from 'mongodb'; import * as crypto from 'crypto'; @@ -17,30 +16,52 @@ import { publishUserStream, publishDriveStream } from '../../stream'; import { isLocalUser, IUser, IRemoteUser } from '../../models/user'; import delFile from './delete-file'; import config from '../../config'; +import { getDriveFileThumbnailBucket } from '../../models/drive-file-thumbnail'; const log = debug('misskey:drive:add-file'); -async function save(readable: stream.Readable, name: string, type: string, hash: string, size: number, metadata: any): Promise<IDriveFile> { +async function save(path: string, name: string, type: string, hash: string, size: number, metadata: any): Promise<IDriveFile> { + let thumbnail: Buffer; + + if (['image/jpeg', 'image/png', 'image/webp'].includes(type)) { + thumbnail = await sharp(path) + .resize(300) + .jpeg({ + quality: 50, + progressive: true + }) + .toBuffer(); + } + if (config.drive && config.drive.storage == 'minio') { const minio = new Minio.Client(config.drive.config); - const id = uuid.v4(); - const obj = `${config.drive.prefix}/${id}`; + const key = `${config.drive.prefix}/${uuid.v4()}/${name}`; + const thumbnailKey = `${config.drive.prefix}/${uuid.v4()}/${name}.thumbnail.jpg`; const baseUrl = config.drive.baseUrl || `${ config.drive.config.secure ? 'https' : 'http' }://${ config.drive.config.endPoint }${ config.drive.config.port ? ':' + config.drive.config.port : '' }/${ config.drive.bucket }`; - await minio.putObject(config.drive.bucket, obj, readable, size, { + await minio.putObject(config.drive.bucket, key, fs.createReadStream(path), size, { 'Content-Type': type, 'Cache-Control': 'max-age=31536000, immutable' }); + if (thumbnail) { + await minio.putObject(config.drive.bucket, thumbnailKey, thumbnail, size, { + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'max-age=31536000, immutable' + }); + } + Object.assign(metadata, { withoutChunks: true, storage: 'minio', storageProps: { - id: id + key: key, + thumbnailKey: thumbnailKey }, - url: `${ baseUrl }/${ obj }` + url: `${ baseUrl }/${ key }`, + thumbnailUrl: thumbnail ? `${ baseUrl }/${ thumbnailKey }` : null }); const file = await DriveFile.insert({ @@ -57,12 +78,36 @@ async function save(readable: stream.Readable, name: string, type: string, hash: // Get MongoDB GridFS bucket const bucket = await getDriveFileBucket(); - return new Promise<IDriveFile>((resolve, reject) => { - const writeStream = bucket.openUploadStream(name, { contentType: type, metadata }); + const file = await new Promise<IDriveFile>((resolve, reject) => { + const writeStream = bucket.openUploadStream(name, { + contentType: type, + metadata + }); + writeStream.once('finish', resolve); writeStream.on('error', reject); - readable.pipe(writeStream); + + fs.createReadStream(path).pipe(writeStream); }); + + if (thumbnail) { + const thumbnailBucket = await getDriveFileThumbnailBucket(); + + await new Promise<IDriveFile>((resolve, reject) => { + const writeStream = thumbnailBucket.openUploadStream(name, { + contentType: 'image/jpeg', + metadata: { + originalId: file._id + } + }); + + writeStream.once('finish', resolve); + writeStream.on('error', reject); + writeStream.end(thumbnail); + }); + } + + return file; } } @@ -321,7 +366,7 @@ export default async function( } } } else { - driveFile = await (save(fs.createReadStream(path), detectedName, mime, hash, size, metadata)); + driveFile = await (save(path, detectedName, mime, hash, size, metadata)); } log(`drive file has been created ${driveFile._id}`); diff --git a/src/services/drive/delete-file.ts b/src/services/drive/delete-file.ts index 5494023f46..445d231d66 100644 --- a/src/services/drive/delete-file.ts +++ b/src/services/drive/delete-file.ts @@ -6,8 +6,18 @@ import config from '../../config'; export default async function(file: IDriveFile, isExpired = false) { if (file.metadata.storage == 'minio') { const minio = new Minio.Client(config.drive.config); - const obj = `${config.drive.prefix}/${file.metadata.storageProps.id}`; + + // 後方互換性のため、file.metadata.storageProps.key があるかどうかチェックしています。 + // 将来的には const obj = file.metadata.storageProps.key; とします。 + const obj = file.metadata.storageProps.key ? file.metadata.storageProps.key : `${config.drive.prefix}/${file.metadata.storageProps.id}`; await minio.removeObject(config.drive.bucket, obj); + + if (file.metadata.thumbnailUrl) { + // 後方互換性のため、file.metadata.storageProps.thumbnailKey があるかどうかチェックしています。 + // 将来的には const thumbnailObj = file.metadata.storageProps.thumbnailKey; とします。 + 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); + } } // チャンクをすべて削除 diff --git a/src/services/note/create.ts b/src/services/note/create.ts index 4f90a19f2c..521750dc84 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -95,6 +95,8 @@ type Option = { }; export default async (user: IUser, data: Option, silent = false) => new Promise<INote>(async (res, rej) => { + const isFirstNote = user.notesCount === 0; + if (data.createdAt == null) data.createdAt = new Date(); if (data.visibility == null) data.visibility = 'public'; if (data.viaMobile == null) data.viaMobile = false; @@ -164,6 +166,10 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< // Pack the note const noteObj = await pack(note); + if (isFirstNote) { + noteObj.isFirstNote = true; + } + const nm = new NotificationManager(user, note); const nmRelatedPromises = []; @@ -188,6 +194,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< // 通知 if (isLocalUser(data.reply._user)) { nm.push(data.reply.userId, 'reply'); + publishUserStream(data.reply.userId, 'reply', noteObj); } } @@ -209,7 +216,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< } // Publish event - if (!user._id.equals(data.renote.userId)) { + if (!user._id.equals(data.renote.userId) && isLocalUser(data.renote._user)) { publishUserStream(data.renote.userId, 'renote', noteObj); } } @@ -228,7 +235,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< async function renderActivity(data: Option, note: INote) { const content = data.renote && data.text == null - ? renderAnnounce(data.renote.uri ? data.renote.uri : await renderNote(data.renote)) + ? renderAnnounce(data.renote.uri ? data.renote.uri : await renderNote(data.renote), note) : renderCreate(await renderNote(note)); return packAp(content); @@ -321,8 +328,18 @@ async function insertNote(user: IUser, data: Option, tokens: ReturnType<typeof p : [], // 以下非正規化データ - _reply: data.reply ? { userId: data.reply.userId } : null, - _renote: data.renote ? { userId: data.renote.userId } : null, + _reply: data.reply ? { + userId: data.reply.userId, + user: { + host: data.reply._user.host + } + } : null, + _renote: data.renote ? { + userId: data.renote.userId, + user: { + host: data.renote._user.host + } + } : null, _user: { host: user.host, inbox: isRemoteUser(user) ? user.inbox : undefined diff --git a/webpack.config.ts b/webpack.config.ts index e60eb8347e..8dca4c0ee3 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -95,7 +95,8 @@ const consts = { _URL_: config.url, _LICENSE_: licenseHtml, _GOOGLE_MAPS_API_KEY_: config.google_maps_api_key, - _WELCOME_BG_URL_: config.welcome_bg_url + _WELCOME_BG_URL_: config.welcome_bg_url, + _TWITTER_INTEGRATION_: config.twitter != null }; const _consts: { [ key: string ]: any } = {};