diff --git a/.gitignore b/.gitignore index 42b1bde94f..2ae0f98c5e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /node_modules /built /uploads +/data npm-debug.log *.pem run.bat diff --git a/.travis.yml b/.travis.yml index 91e1244432..ed53af9e20 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,14 @@ # travis file # https://docs.travis-ci.com/user/customizing-the-build +branches: + except: + - release + language: node_js node_js: - - 7.10.0 + - 8.4.0 env: - CXX=g++-4.8 NODE_ENV=production diff --git a/.travis/.gitignore-release b/.travis/.gitignore-release index ad1d3724fc..ae1157b33e 100644 --- a/.travis/.gitignore-release +++ b/.travis/.gitignore-release @@ -6,3 +6,5 @@ !/tools !/elasticsearch !/package.json +!/.travis.yml +!/appveyor.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 72a584ddb0..f8018e4e25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,172 @@ -ChangeLog -========= +ChangeLog (Release Notes) +========================= 主に notable な changes を書いていきます +2807 (2017/11/02) +----------------- +* いい感じに + +2805 (2017/11/02) +----------------- +* いい感じに + +2801 (2017/11/01) +----------------- +* チャンネルのWatch実装 + +2799 (2017/11/01) +----------------- +* いい感じに + +2795 (2017/11/01) +----------------- +* いい感じに + +2793 (2017/11/01) +----------------- +* なんか + +2783 (2017/11/01) +----------------- +* なんか + +2777 (2017/11/01) +----------------- +* 細かいブラッシュアップ + +2775 (2017/11/01) +----------------- +* Fix: バグ修正 + +2769 (2017/11/01) +----------------- +* New: チャンネルシステム + +2752 (2017/10/30) +----------------- +* New: 未読の通知がある場合アイコンを表示するように + +2747 (2017/10/25) +----------------- +* Fix: 非ログイン状態ですべてのページが致命的な問題を発生させる (#89) + +2742 (2017/10/25) +----------------- +* New: トラブルシューティングを実装するなど + +2735 (2017/10/22) +----------------- +* New: モバイル版からでもクライアントバージョンを確認できるように + +2732 (2017/10/22) +----------------- +* 依存関係の更新など + +2584 (2017/09/08) +----------------- +* New: ユーザーページによく使うドメインを表示 (#771) +* New: よくリプライするユーザーをユーザーページに表示 (#770) + +2566 (2017/09/07) +----------------- +* New: 投稿することの多いキーワードをユーザーページに表示する (#768) +* l10n +* デザインの修正 + +2544 (2017/09/06) +----------------- +* 投稿のカテゴリに関する実験的な実装 +* l10n +* ユーザビリティの向上 + +2520 (2017/08/30) +----------------- +* デザインの調整 + +2518 (2017/08/30) +----------------- +* Fix: モバイル版のタイムラインからリアクションやメニューを開けない +* デザインの調整 + +2515 (2017/08/30) +----------------- +* New: 投稿のピン留め (#746) +* New: モバイル版のユーザーページに知り合いのフォロワーを表示するように +* New: ホームストリームにメッセージを流すことでlast_used_atを更新できるようにする (#745) +* その他細かな修正 + +2508 (2017/08/30) +----------------- +* New: モバイル版のユーザーページのアクティビティチャートを変更 +* New: モバイル版のユーザーページに最終ログイン日時を表示するように +* デザインの調整 + +2503 (2017/08/30) +----------------- +* デザインの調整 + +2502 (2017/08/30) +----------------- +* デザインの修正・調整 + +2501 (2017/08/30) +----------------- +* New: モバイルのユーザーページを刷新 + +2498 (2017/08/29) +----------------- +* Fix: repostのborder-radiusが効いていない (#743) +* テーマカラーを赤に戻してみた +* ユーザビリティの向上 +* デザインの調整 + +2493-2 (2017/08/29) +------------------- +* デザインの修正 + +2493 (2017/08/29) +----------------- +* デザインの変更など + +2491 (2017/08/29) +----------------- +* デザインの修正と調整 + +2489 (2017/08/29) +----------------- +* ユーザビリティの向上 +* デザインの調整 + +2487 (2017/08/29) +----------------- +* New: パスワードを変更する際に新しいパスワードを二度入力させる (#739) +* New: ドナーを表示する (#738) +* Fix: 投稿のリンクが機能していない問題を修正 +* Fix: アカウント作成フォームのユーザーページURLプレビューが正しく機能していなかった問題を修正 +* l10n +* デザインの調整 + +2470 (2017/08/29) +----------------- +* New: トークンを再生成できるように (#497) +* New: パスワードを変更する機能 (#364) + +2461 (2017/08/28) +----------------- +* Fix: モバイル版からアバターとバナーの設定を行えなかった問題を修正 +* デザインの修正 + +2458 (2017/08/28) +----------------- +* New: モバイル版からプロフィールを設定できるように +* New: モバイル版からサインアウトを行えるように +* New: 投稿ページに次の投稿/前の投稿リンクを作成 (#734) +* New: タイムラインの投稿をダブルクリックすることで詳細な情報が見れるように +* Fix: モバイル版でおすすめユーザーをフォローしてもタイムラインが更新されない (#736) +* Fix: モバイル版で設定にアクセスできない +* デザインの調整 +* 依存関係の更新 + 2380 ---- アプリケーションが作れない問題を修正 diff --git a/DONORS.md b/DONORS.md new file mode 100644 index 0000000000..da71c043ac --- /dev/null +++ b/DONORS.md @@ -0,0 +1,19 @@ +DONORS +====== + +(no particular order) + +* らふぁ +* 俺様 +* なぎうり +* スルメ https://surume.tk/ + +:heart: Thanks for donating, guys! + +--- + +Although you donated, you are not listed here? please contact to us! + +If you want to donate to Misskey, please get in touch with [@syuilo][syuilo-link]. + +[syuilo-link]: https://syuilo.com diff --git a/README.md b/README.md index 9d2d38149c..b777618f46 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Key features * Automatically updated timeline * Private messages * Free 1GB storage for each all users -* Mobile device support (smartphone, tablet, etc) +* Machine learning * Web API for third-party applications * No ads @@ -25,24 +25,23 @@ and more! You can touch with your own eyes at https://misskey.xyz/. Setup and Installation ---------------------------------------------------------------- -Please see [Setup and installation guide](./docs/setup.en.md). +If you want to run your own instance of Misskey, +please see [Setup and installation guide](./docs/setup.en.md). Contribution ---------------------------------------------------------------- Please see [Contribution guide](./CONTRIBUTING.md). +Release Notes +---------------------------------------------------------------- +Please see [ChangeLog](./CHANGELOG.md). + Sponsors & Backers ---------------------------------------------------------------- Misskey have no 100+ GitHub stars currently. However, donation are always welcome! If you want to donate to Misskey, please get in touch with [@syuilo][syuilo-link]. -Collaborators ----------------------------------------------------------------- -| ![syuilo][syuilo-icon] | ![Morisawa Aya][ayamorisawa-icon] | ![otofune][otofune-icon] | -|------------------------|-----------------------------------|---------------------------------| -| [syuilo][syuilo-link] | [Aya Morisawa][ayamorisawa-link] | [otofune][otofune-link] | - -[List of all contributors](https://github.com/syuilo/misskey/graphs/contributors) +**Note:** When you donate to Misskey, your name will be displayed in [donors](./DONORS.md). Copyright ---------------------------------------------------------------- @@ -61,7 +60,3 @@ Misskey is an open-source software licensed under [The MIT License](LICENSE). <!-- Collaborators Info --> [syuilo-link]: https://syuilo.com [syuilo-icon]: https://avatars2.githubusercontent.com/u/4439005?v=3&s=70 -[ayamorisawa-link]: https://github.com/ayamorisawa -[ayamorisawa-icon]: https://avatars0.githubusercontent.com/u/10798641?v=3&s=70 -[otofune-link]: https://github.com/otofune -[otofune-icon]: https://avatars0.githubusercontent.com/u/15062473?v=3&s=70 diff --git a/appveyor.yml b/appveyor.yml index d26cbc27e8..03a42b9b44 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,9 +1,13 @@ # appveyor file # http://www.appveyor.com/docs/appveyor-yml +branches: + except: + - release + environment: matrix: - - nodejs_version: 7.10.0 + - nodejs_version: 8.4.0 build: off diff --git a/docs/backup.md b/docs/backup.md new file mode 100644 index 0000000000..484564b314 --- /dev/null +++ b/docs/backup.md @@ -0,0 +1,22 @@ +How to backup your Misskey +========================== + +Make sure **mongodb-tools** installed. + +--- + +In your shell: +``` shell +$ mongodump --archive=db-backup +``` + +For details, plese see [mongodump docs](https://docs.mongodb.com/manual/reference/program/mongodump/). + +Restore +------- + +``` shell +$ mongorestore --archive=db-backup +``` + +For details, please see [mongorestore docs](https://docs.mongodb.com/manual/reference/program/mongorestore/). diff --git a/docs/setup.en.md b/docs/setup.en.md index 3e48935346..dbc0599b5a 100644 --- a/docs/setup.en.md +++ b/docs/setup.en.md @@ -25,6 +25,7 @@ Note that Misskey uses following subdomains: * **api**.*{primary domain}* * **auth**.*{primary domain}* * **about**.*{primary domain}* +* **ch**.*{primary domain}* * **stats**.*{primary domain}* * **status**.*{primary domain}* * **dev**.*{primary domain}* diff --git a/docs/setup.ja.md b/docs/setup.ja.md index 4f48a08088..602fd9b6a1 100644 --- a/docs/setup.ja.md +++ b/docs/setup.ja.md @@ -26,6 +26,7 @@ Misskeyは以下のサブドメインを使います: * **api**.*{primary domain}* * **auth**.*{primary domain}* * **about**.*{primary domain}* +* **ch**.*{primary domain}* * **stats**.*{primary domain}* * **status**.*{primary domain}* * **dev**.*{primary domain}* diff --git a/locales/en.yml b/locales/en.yml index 55a588f99f..52e8dfdb4b 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -1,4 +1,6 @@ common: + misskey: "Note everything and share it others using Misskey." + time: unknown: "unknown" future: "future" @@ -22,12 +24,21 @@ common: confused: "Confused" pudding: "Pudding" + post_categories: + music: "Music" + game: "Video Game" + anime: "Anime" + it: "IT" + gadgets: "Gadgets" + photography: "Photography" + input-message-here: "Enter message here" send: "Send" delete: "Delete" loading: "Loading" ok: "OK" update-available: "New version of Misskey is now available({newer}, current is {current}). Reload page to apply update." + my-token-regenerated: "Your token is just regenerated, so you will signout." tags: mk-messaging-form: @@ -55,8 +66,27 @@ common: mk-error: title: "Unable to connect to the server" - description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから再度お試しください。" + description: "There is a problem with Internet connection, or the server may be down or maintaining. Please {try again} later." thanks: "Thank you for using Misskey." + troubleshoot: "Troubleshoot" + + troubleshooter: + title: "TroubleShooting" + network: "Network connection" + checking-network: "Checking network connection" + internet: "Internet connection" + checking-internet: "Checking internet connection" + server: "Server connection" + checking-server: "Checking server connection" + finding: "Finding a problem" + no-network: "There is no Network connection" + no-network-desc: "Please make sure you are connected to the Network." + no-internet: "There is no Internet connection" + no-internet-desc: "Please make sure you are connected to the Internet." + no-server: "Unable to connect to the server" + no-server-desc: "The network connection of your PC is normal, but you could not connect to Misskey's server. There is a possibility that the server is down or maintaining, please try to access it again after a while." + success: "Successfully connect to the Misskey's server" + success-desc: "It seems to be able to connect normally. Please reload the page." mk-forkit: open-github-link: "View source on Github" @@ -76,6 +106,13 @@ common: show-result: "Show result" voted: "Voted" + mk-post-menu: + pin: "Pin" + pinned: "Pinned" + select: "Select category" + categorize: "Accept" + categorized: "Category reported. Thank you!" + mk-reaction-picker: choose-reaction: "Pick your reaction" @@ -127,8 +164,24 @@ common: mk-uploader: waiting: "Waiting" +ch: + tags: + mk-index: + new: "Create new channel" + channel-title: "Channel title" + + mk-channel-form: + textarea: "Write here" + upload: "Upload" + drive: "Drive" + post: "Do" + posting: "Doing" + desktop: tags: + mk-api-info: + regenerate-token: "Please enter the password" + mk-drive-browser-base-contextmenu: create-folder: "Create a folder" upload: "Upload a file" @@ -189,9 +242,19 @@ desktop: mk-drive-browser-nav-folder: drive: "Drive" + mk-nav-home-widget: + about: "About" + stats: "Stats" + status: "Status" + wiki: "Wiki" + donors: "Donors" + repository: "Repository" + develop: "Developers" + mk-ui-header-nav: home: "Home" messaging: "Messages" + ch: "Channels" info: "News" mk-ui-header-search: @@ -204,6 +267,14 @@ desktop: settings: "Settings" signout: "Sign out" + mk-password-setting: + reset: "Change your password" + enter-current-password: "Enter the current password" + enter-new-password: "Enter the new password" + enter-new-password-again: "Enter the new password again" + not-match: "New password not matched" + changed: "Password updated successfully" + mk-post-form: post-placeholder: "What's happening?" reply-placeholder: "Reply to this post..." @@ -231,6 +302,13 @@ desktop: attaches: "{} media attached" uploading-media: "Uploading {} media" + mk-post-page: + prev: "Previous post" + next: "Next post" + + mk-settings: + password: "Password" + mk-timeline-post: reposted-by: "Reposted by {}" reply: "Reply" @@ -289,6 +367,9 @@ desktop: mobile: tags: + mk-selectdrive-page: + select-file: "Select file(s)" + mk-drive-file-viewer: download: "Download" rename: "Rename" @@ -325,19 +406,46 @@ mobile: mk-notifications-page: notifications: "Notifications" + read-all: "Are you sure you want to mark all unread notifications as read?" mk-post-page: - submit: "Post" + title: "Post" + prev: "Previous post" + next: "Next post" mk-search-page: search: "Search" + mk-settings: + signed-in-as: "Signed in as {}" + mk-settings-page: profile: "Profile" applications: "Applications" twitter-integration: "Twitter integration" signin-history: "Sign in history" + api: "API" + link: "MisskeyLink" settings: "Settings" + signout: "Sign out" + + mk-profile-setting-page: + title: "Profile Settings" + + mk-profile-setting: + will-be-published: "These profiles will be published." + name: "Name" + location: "Location" + description: "Description" + birthday: "Birthday" + avatar: "Avatar" + banner: "Banner" + avatar-saved: "Avatar updated successfully" + banner-saved: "Banner updated successfully" + set-avatar: "Choose an avatar" + set-banner: "Choose a banner" + save: "Save" + saved: "Profile updated successfully" mk-user-followers-page: followers-of: "Followers of {}" @@ -400,6 +508,7 @@ mobile: home: "Home" notifications: "Notifications" messaging: "Messages" + ch: "Channels" drive: "Drive" settings: "Settings" about: "About Misskey" @@ -416,12 +525,46 @@ mobile: no-posts-with-media: "There is no posts with media" mk-user: - is-followed: "Followed you" + follows-you: "Follows you" following: "Following" followers: "Followers" - posts: "Timeline" + posts: "Posts" + overview: "Overview" + timeline: "Timeline" media: "Media" + mk-user-overview: + recent-posts: "Recent posts" + images: "Images" + activity: "Activity" + keywords: "Keywords" + domains: "Domains" + frequently-replied-users: "Frequently talking users" + followers-you-know: "Followers you know" + last-used-at: "Last used at" + + mk-user-overview-posts: + loading: "Loading" + no-posts: "No posts" + + mk-user-overview-photos: + loading: "Loading" + no-photos: "No photos" + + mk-user-overview-keywords: + no-keywords: "No keywords" + + mk-user-overview-domains: + no-domains: "No domains" + + mk-user-overview-frequently-replied-users: + loading: "Loading" + no-users: "No users" + + mk-user-overview-followers-you-know: + loading: "Loading" + no-users: "No users" + mk-users-list: all: "All" known: "You know" diff --git a/locales/ja.yml b/locales/ja.yml index e5b2beaed1..dcd012bb89 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -1,4 +1,6 @@ common: + misskey: "Misskeyに何でも投稿して皆と共有しましょう。" + time: unknown: "なぞのじかん" future: "未来" @@ -22,12 +24,21 @@ common: confused: "こまこまのこまり" pudding: "Pudding" + post_categories: + music: "音楽" + game: "ゲーム" + anime: "アニメ" + it: "IT" + gadgets: "ガジェット" + photography: "写真" + input-message-here: "ここにメッセージを入力" send: "送信" delete: "削除" loading: "読み込み中" ok: "わかった" update-available: "Misskeyの新しいバージョンがあります({newer}。現在{current}を利用中)。ページを再度読み込みすると更新が適用されます。" + my-token-regenerated: "あなたのトークンが更新されたのでサインアウトします。" tags: mk-messaging-form: @@ -55,8 +66,27 @@ common: mk-error: title: "サーバーに接続できません" - description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから再度お試しください。" + description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから{再度お試し}ください。" thanks: "いつもMisskeyをご利用いただきありがとうございます。" + troubleshoot: "トラブルシュート" + + troubleshooter: + title: "トラブルシューティング" + network: "ネットワーク接続" + checking-network: "ネットワーク接続を確認中" + internet: "インターネット接続" + checking-internet: "インターネット接続を確認中" + server: "サーバー接続" + checking-server: "サーバー接続を確認中" + finding: "問題を調べています" + no-network: "ネットワークに接続されていません" + no-network-desc: "お使いのPCのネットワーク接続が正常か確認してください。" + no-internet: "インターネットに接続されていません" + no-internet-desc: "ネットワークには接続されていますが、インターネットには接続されていないようです。お使いのPCのインターネット接続が正常か確認してください。" + no-server: "Misskeyのサーバーに接続できません" + no-server-desc: "お使いのPCのインターネット接続は正常ですが、Misskeyのサーバーには接続できませんでした。サーバーがダウンまたはメンテナンスしている可能性があるので、しばらくしてから再度御アクセスください。" + success: "Misskeyのサーバーに接続できました" + success-desc: "正常に接続できるようです。ページを再度読み込みしてください。" mk-forkit: open-github-link: "View source on Github" @@ -76,6 +106,13 @@ common: show-result: "結果を見る" voted: "投票済み" + mk-post-menu: + pin: "ピン留め" + pinned: "ピン留めしました" + select: "カテゴリを選択" + categorize: "決定" + categorized: "カテゴリを報告しました。これによりMisskeyが賢くなり、投稿の自動カテゴライズに役立てられます。ご協力ありがとうございました。" + mk-reaction-picker: choose-reaction: "リアクションを選択" @@ -127,8 +164,24 @@ common: mk-uploader: waiting: "待機中" +ch: + tags: + mk-index: + new: "チャンネルを作成" + channel-title: "チャンネルのタイトル" + + mk-channel-form: + textarea: "書いて" + upload: "アップロード" + drive: "ドライブ" + post: "やる" + posting: "やってます" + desktop: tags: + mk-api-info: + regenerate-token: "パスワードを入力してください" + mk-drive-browser-base-contextmenu: create-folder: "フォルダーを作成" upload: "ファイルをアップロード" @@ -189,9 +242,19 @@ desktop: mk-drive-browser-nav-folder: drive: "ドライブ" + mk-nav-home-widget: + about: "Misskeyについて" + stats: "統計" + status: "ステータス" + wiki: "Wiki" + donors: "ドナー" + repository: "リポジトリ" + develop: "開発者" + mk-ui-header-nav: home: "ホーム" messaging: "メッセージ" + ch: "チャンネル" info: "お知らせ" mk-ui-header-search: @@ -204,6 +267,14 @@ desktop: settings: "設定" signout: "サインアウト" + mk-password-setting: + reset: "パスワードを変更する" + enter-current-password: "現在のパスワードを入力してください" + enter-new-password: "新しいパスワードを入力してください" + enter-new-password-again: "もう一度新しいパスワードを入力してください" + not-match: "新しいパスワードが一致しません" + changed: "パスワードを変更しました" + mk-post-form: post-placeholder: "いまどうしてる?" reply-placeholder: "この投稿への返信..." @@ -231,6 +302,13 @@ desktop: attaches: "添付: {}メディア" uploading-media: "{}個のメディアをアップロード中" + mk-post-page: + prev: "前の投稿" + next: "次の投稿" + + mk-settings: + password: "パスワード" + mk-timeline-post: reposted-by: "{}がRepost" reply: "返信" @@ -289,6 +367,9 @@ desktop: mobile: tags: + mk-selectdrive-page: + select-file: "ファイルを選択" + mk-drive-file-viewer: download: "ダウンロード" rename: "名前を変更" @@ -325,19 +406,46 @@ mobile: mk-notifications-page: notifications: "通知" + read-all: "すべての通知を既読にしますか?" mk-post-page: - submit: "投稿" + title: "投稿" + prev: "前の投稿" + next: "次の投稿" mk-search-page: search: "検索" + mk-settings: + signed-in-as: "{}としてサインイン中" + mk-settings-page: profile: "プロフィール" applications: "アプリケーション" twitter-integration: "Twitter連携" signin-history: "ログイン履歴" + api: "API" + link: "Misskeyリンク" settings: "設定" + signout: "サインアウト" + + mk-profile-setting-page: + title: "プロフィール設定" + + mk-profile-setting: + will-be-published: "これらのプロフィールは公開されます。" + name: "名前" + location: "場所" + description: "自己紹介" + birthday: "誕生日" + avatar: "アバター" + banner: "バナー" + avatar-saved: "アバターを保存しました" + banner-saved: "バナーを保存しました" + set-avatar: "アバターを選択する" + set-banner: "バナーを選択する" + save: "保存" + saved: "プロフィールを保存しました" mk-user-followers-page: followers-of: "{}のフォロワー" @@ -400,6 +508,7 @@ mobile: home: "ホーム" notifications: "通知" messaging: "メッセージ" + ch: "チャンネル" search: "検索" drive: "ドライブ" settings: "設定" @@ -416,13 +525,46 @@ mobile: no-posts-with-media: "メディア付き投稿はありません。" mk-user: - is-followed: "フォローされています" + follows-you: "フォローされています" following: "フォロー" followers: "フォロワー" - posts: "タイムライン" - posts-count: "ポスト" + posts: "投稿" + overview: "概要" + timeline: "タイムライン" media: "メディア" + mk-user-overview: + recent-posts: "最近の投稿" + images: "画像" + activity: "アクティビティ" + keywords: "キーワード" + domains: "頻出ドメイン" + frequently-replied-users: "よく会話するユーザー" + followers-you-know: "知り合いのフォロワー" + last-used-at: "最終ログイン" + + mk-user-overview-posts: + loading: "読み込み中" + no-posts: "投稿はありません" + + mk-user-overview-photos: + loading: "読み込み中" + no-photos: "写真はありません" + + mk-user-overview-keywords: + no-keywords: "キーワードはありません(十分な数の投稿をしていない可能性があります)" + + mk-user-overview-domains: + no-domains: "よく表れるドメインは検出されませんでした" + + mk-user-overview-frequently-replied-users: + loading: "読み込み中" + no-users: "よく会話するユーザーはいません" + + mk-user-overview-followers-you-know: + loading: "読み込み中" + no-users: "知り合いのユーザーはいません" + mk-users-list: all: "すべて" known: "知り合い" diff --git a/package.json b/package.json index ff3f6c79fe..9295d3f1d6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "misskey", "author": "syuilo <i@syuilo.com>", - "version": "0.0.2380", + "version": "0.0.2807", "license": "MIT", "description": "A miniblog-based SNS", "bugs": "https://github.com/syuilo/misskey/issues", @@ -18,22 +18,22 @@ "clean": "gulp clean", "cleanall": "gulp cleanall", "lint": "gulp lint", - "test": "gulp test" + "test": "gulp test" }, "devDependencies": { "@types/bcryptjs": "2.4.1", - "@types/body-parser": "1.16.4", - "@types/chai": "4.0.3", - "@types/chai-http": "3.0.2", - "@types/chalk": "0.4.31", - "@types/compression": "0.0.33", + "@types/body-parser": "1.16.5", + "@types/chai": "4.0.4", + "@types/chai-http": "3.0.3", + "@types/chalk": "2.2.0", + "@types/compression": "0.0.34", "@types/cors": "2.8.1", "@types/debug": "0.0.30", - "@types/deep-equal": "1.0.0", + "@types/deep-equal": "1.0.1", "@types/elasticsearch": "5.0.14", - "@types/event-stream": "3.3.31", - "@types/express": "4.0.36", - "@types/gm": "1.17.32", + "@types/event-stream": "3.3.32", + "@types/express": "4.0.37", + "@types/gm": "1.17.33", "@types/gulp": "4.0.3", "@types/gulp-htmlmin": "1.3.30", "@types/gulp-mocha": "0.0.30", @@ -47,82 +47,88 @@ "@types/is-root": "1.0.0", "@types/is-url": "1.2.28", "@types/js-yaml": "3.9.0", - "@types/mocha": "2.2.41", - "@types/mongodb": "2.2.10", - "@types/monk": "1.0.5", - "@types/morgan": "1.7.32", - "@types/ms": "0.7.29", + "@types/mocha": "2.2.44", + "@types/mongodb": "2.2.13", + "@types/monk": "1.0.6", + "@types/morgan": "1.7.35", + "@types/ms": "0.7.30", "@types/multer": "1.3.2", - "@types/node": "8.0.24", + "@types/node": "8.0.47", "@types/ratelimiter": "2.1.28", - "@types/redis": "2.6.0", - "@types/request": "2.0.1", + "@types/redis": "2.8.1", + "@types/request": "2.0.7", "@types/rimraf": "2.0.0", - "@types/riot": "3.6.0", + "@types/riot": "3.6.1", "@types/serve-favicon": "2.2.28", - "@types/uuid": "3.4.0", - "@types/webpack": "3.0.9", - "@types/webpack-stream": "3.2.7", + "@types/uuid": "3.4.3", + "@types/webpack": "3.0.14", + "@types/uuid": "3.4.3", + "@types/webpack": "3.0.13", + "@types/webpack-stream": "3.2.8", "@types/websocket": "0.0.34", - "chai": "4.1.1", + "awesome-typescript-loader": "3.3.0", + "chai": "4.1.2", "chai-http": "3.0.0", - "css-loader": "0.28.5", + "css-loader": "0.28.7", "event-stream": "3.3.4", "gulp": "3.9.1", "gulp-cssnano": "2.1.2", - "gulp-imagemin": "3.3.0", "gulp-htmlmin": "3.0.0", + "gulp-imagemin": "3.4.0", "gulp-mocha": "4.3.1", "gulp-pug": "3.3.0", "gulp-rename": "1.2.2", "gulp-replace": "0.6.1", "gulp-tslint": "8.1.2", - "gulp-typescript": "3.2.1", + "gulp-typescript": "3.2.2", "gulp-uglify": "3.0.0", "gulp-util": "3.0.8", - "mocha": "3.5.0", + "mocha": "3.5.3", "riot-tag-loader": "1.0.0", "string-replace-webpack-plugin": "0.1.3", - "style-loader": "0.18.2", + "style-loader": "0.19.0", "stylus": "0.54.5", "stylus-loader": "3.0.1", "swagger-jsdoc": "1.9.7", - "tslint": "5.6.0", + "tslint": "5.7.0", "uglify-es": "3.0.27", - "uglify-es-webpack-plugin": "0.10.0", "uglify-js": "git+https://github.com/mishoo/UglifyJS2.git#harmony", - "webpack": "3.5.5" + "uglifyjs-webpack-plugin": "1.0.1", + "webpack": "3.8.1" }, "dependencies": { + "@prezzemolo/rap": "0.1.2", "accesses": "2.5.0", - "animejs": "2.0.2", + "animejs": "2.2.0", "autwh": "0.0.1", "bcryptjs": "2.4.3", - "body-parser": "1.17.2", - "cafy": "2.4.0", - "chalk": "2.1.0", - "compression": "1.7.0", + "body-parser": "1.18.2", + "cafy": "3.0.0", + "chalk": "2.3.0", + "compression": "1.7.1", "cors": "2.8.4", - "cropperjs": "1.0.0-rc.3", + "cropperjs": "1.1.3", "crypto": "1.0.1", - "debug": "3.0.0", + "debug": "3.1.0", "deep-equal": "1.0.1", "deepcopy": "0.6.3", - "diskusage": "^0.2.2", + "diskusage": "0.2.2", "download": "6.2.5", "elasticsearch": "13.3.1", "escape-regexp": "0.0.1", "express": "4.15.4", - "file-type": "6.1.0", + "file-type": "7.2.0", "fuckadblock": "3.2.1", "gm": "1.23.0", - "inquirer": "3.2.2", + "inquirer": "3.3.0", "is-root": "1.0.0", "is-url": "1.2.2", - "js-yaml": "3.9.1", - "mongodb": "2.2.31", - "monk": "6.0.3", - "morgan": "1.8.2", + "js-yaml": "3.10.0", + "mecab-async": "^0.1.0", + "moji": "^0.5.1", + "mongodb": "2.2.33", + "monk": "6.0.5", + "morgan": "1.9.0", "ms": "2.0.0", "multer": "1.3.0", "nprogress": "0.2.0", @@ -130,26 +136,26 @@ "page": "1.7.1", "pictograph": "2.0.4", "prominence": "0.2.0", - "pug": "2.0.0-rc.3", + "pug": "2.0.0-rc.4", "ratelimiter": "3.0.3", "recaptcha-promise": "0.1.3", - "reconnecting-websocket": "3.2.0", + "reconnecting-websocket": "3.2.2", "redis": "2.8.0", - "request": "2.81.0", - "rimraf": "2.6.1", - "riot": "3.6.2", + "request": "2.83.0", + "rimraf": "2.6.2", + "riot": "3.7.4", "rndstr": "1.0.0", "s-age": "1.1.0", - "serve-favicon": "2.4.3", + "serve-favicon": "2.4.5", "summaly": "2.0.3", "syuilo-password-strength": "0.0.1", "tcp-port-used": "0.1.2", "textarea-caret": "3.0.2", "ts-node": "3.3.0", - "typescript": "2.4.2", + "typescript": "2.6.1", "uuid": "3.1.0", "vhost": "3.0.2", - "websocket": "1.0.24", + "websocket": "1.0.25", "xev": "2.0.0" } } diff --git a/src/api/authenticate.ts b/src/api/authenticate.ts index d4cc3fc41f..b289959ac1 100644 --- a/src/api/authenticate.ts +++ b/src/api/authenticate.ts @@ -1,6 +1,6 @@ import * as express from 'express'; import App from './models/app'; -import User from './models/user'; +import { default as User, IUser } from './models/user'; import AccessToken from './models/access-token'; import isNativeToken from './common/is-native-token'; @@ -13,10 +13,10 @@ export interface IAuthContext { /** * Authenticated user */ - user: any; + user: IUser; /** - * Weather if the request is via the User-Native Token or not + * Whether requested with a User-Native Token */ isSecure: boolean; } @@ -25,11 +25,15 @@ export default (req: express.Request) => new Promise<IAuthContext>(async (resolv const token = req.body['i'] as string; if (token == null) { - return resolve({ app: null, user: null, isSecure: false }); + return resolve({ + app: null, + user: null, + isSecure: false + }); } if (isNativeToken(token)) { - const user = await User + const user: IUser = await User .findOne({ token: token }); if (user === null) { @@ -56,6 +60,10 @@ export default (req: express.Request) => new Promise<IAuthContext>(async (resolv const user = await User .findOne({ _id: accessToken.user_id }); - return resolve({ app: app, user: user, isSecure: false }); + return resolve({ + app: app, + user: user, + isSecure: false + }); } }); diff --git a/src/api/bot/core.ts b/src/api/bot/core.ts new file mode 100644 index 0000000000..53fb18119e --- /dev/null +++ b/src/api/bot/core.ts @@ -0,0 +1,398 @@ +import * as EventEmitter from 'events'; +import * as bcrypt from 'bcryptjs'; + +import User, { IUser, init as initUser } from '../models/user'; + +import getPostSummary from '../../common/get-post-summary'; +import getUserSummary from '../../common/get-user-summary'; + +import Othello, { ai as othelloAi } from '../../common/othello'; + +const hmm = [ + '?', + 'ふぅ~む...?', + 'ちょっと何言ってるかわからないです', + '「ヘルプ」と言うと利用可能な操作が確認できますよ' +]; + +/** + * Botの頭脳 + */ +export default class BotCore extends EventEmitter { + public user: IUser = null; + + private context: Context = null; + + constructor(user?: IUser) { + super(); + + this.user = user; + } + + public clearContext() { + this.setContext(null); + } + + public setContext(context: Context) { + this.context = context; + this.emit('updated'); + + if (context) { + context.on('updated', () => { + this.emit('updated'); + }); + } + } + + public export() { + return { + user: this.user, + context: this.context ? this.context.export() : null + }; + } + + protected _import(data) { + this.user = data.user ? initUser(data.user) : null; + this.setContext(data.context ? Context.import(this, data.context) : null); + } + + public static import(data) { + const bot = new BotCore(); + bot._import(data); + return bot; + } + + public async q(query: string): Promise<string | void> { + if (this.context != null) { + return await this.context.q(query); + } + + if (/^@[a-zA-Z0-9-]+$/.test(query)) { + return await this.showUserCommand(query); + } + + switch (query) { + case 'ping': + return 'PONG'; + + case 'help': + case 'ヘルプ': + return '利用可能なコマンド一覧です:\n' + + 'help: これです\n' + + 'me: アカウント情報を見ます\n' + + 'login, signin: サインインします\n' + + 'logout, signout: サインアウトします\n' + + 'post: 投稿します\n' + + 'tl: タイムラインを見ます\n' + + '@<ユーザー名>: ユーザーを表示します'; + + case 'me': + return this.user ? `${this.user.name}としてサインインしています。\n\n${getUserSummary(this.user)}` : 'サインインしていません'; + + case 'login': + case 'signin': + case 'ログイン': + case 'サインイン': + if (this.user != null) return '既にサインインしていますよ!'; + this.setContext(new SigninContext(this)); + return await this.context.greet(); + + case 'logout': + case 'signout': + case 'ログアウト': + case 'サインアウト': + if (this.user == null) return '今はサインインしてないですよ!'; + this.signout(); + return 'ご利用ありがとうございました <3'; + + case 'post': + case '投稿': + if (this.user == null) return 'まずサインインしてください。'; + this.setContext(new PostContext(this)); + return await this.context.greet(); + + case 'tl': + case 'タイムライン': + return await this.tlCommand(); + + case 'guessing-game': + case '数当てゲーム': + this.setContext(new GuessingGameContext(this)); + return await this.context.greet(); + + case 'othello': + case 'オセロ': + this.setContext(new OthelloContext(this)); + return await this.context.greet(); + + default: + return hmm[Math.floor(Math.random() * hmm.length)]; + } + } + + public signin(user: IUser) { + this.user = user; + this.emit('signin', user); + this.emit('updated'); + } + + public signout() { + const user = this.user; + this.user = null; + this.emit('signout', user); + this.emit('updated'); + } + + public async refreshUser() { + this.user = await User.findOne({ + _id: this.user._id + }, { + fields: { + data: false + } + }); + + this.emit('updated'); + } + + public async tlCommand(): Promise<string | void> { + if (this.user == null) return 'まずサインインしてください。'; + + const tl = await require('../endpoints/posts/timeline')({ + limit: 5 + }, this.user); + + const text = tl + .map(post => getPostSummary(post)) + .join('\n-----\n'); + + return text; + } + + public async showUserCommand(q: string): Promise<string | void> { + try { + const user = await require('../endpoints/users/show')({ + username: q.substr(1) + }, this.user); + + const text = getUserSummary(user); + + return text; + } catch (e) { + return `問題が発生したようです...: ${e}`; + } + } +} + +abstract class Context extends EventEmitter { + protected bot: BotCore; + + public abstract async greet(): Promise<string>; + public abstract async q(query: string): Promise<string>; + public abstract export(): any; + + constructor(bot: BotCore) { + super(); + this.bot = bot; + } + + public static import(bot: BotCore, data: any) { + if (data.type == 'guessing-game') return GuessingGameContext.import(bot, data.content); + if (data.type == 'othello') return OthelloContext.import(bot, data.content); + if (data.type == 'post') return PostContext.import(bot, data.content); + if (data.type == 'signin') return SigninContext.import(bot, data.content); + return null; + } +} + +class SigninContext extends Context { + private temporaryUser: IUser = null; + + public async greet(): Promise<string> { + return 'まずユーザー名を教えてください:'; + } + + public async q(query: string): Promise<string> { + if (this.temporaryUser == null) { + // Fetch user + const user: IUser = await User.findOne({ + username_lower: query.toLowerCase() + }, { + fields: { + data: false + } + }); + + if (user === null) { + return `${query}というユーザーは存在しませんでした... もう一度教えてください:`; + } else { + this.temporaryUser = user; + this.emit('updated'); + return `パスワードを教えてください:`; + } + } else { + // Compare password + const same = bcrypt.compareSync(query, this.temporaryUser.password); + + if (same) { + this.bot.signin(this.temporaryUser); + this.bot.clearContext(); + return `${this.temporaryUser.name}さん、おかえりなさい!`; + } else { + return `パスワードが違います... もう一度教えてください:`; + } + } + } + + public export() { + return { + type: 'signin', + content: { + temporaryUser: this.temporaryUser + } + }; + } + + public static import(bot: BotCore, data: any) { + const context = new SigninContext(bot); + context.temporaryUser = data.temporaryUser; + return context; + } +} + +class PostContext extends Context { + public async greet(): Promise<string> { + return '内容:'; + } + + public async q(query: string): Promise<string> { + await require('../endpoints/posts/create')({ + text: query + }, this.bot.user); + this.bot.clearContext(); + return '投稿しましたよ!'; + } + + public export() { + return { + type: 'post' + }; + } + + public static import(bot: BotCore, data: any) { + const context = new PostContext(bot); + return context; + } +} + +class GuessingGameContext extends Context { + private secret: number; + private history: number[] = []; + + public async greet(): Promise<string> { + this.secret = Math.floor(Math.random() * 100); + this.emit('updated'); + return '0~100の秘密の数を当ててみてください:'; + } + + public async q(query: string): Promise<string> { + if (query == 'やめる') { + this.bot.clearContext(); + return 'やめました。'; + } + + const guess = parseInt(query, 10); + + if (isNaN(guess)) { + return '整数で推測してください。「やめる」と言うとゲームをやめます。'; + } + + const firsttime = this.history.indexOf(guess) === -1; + + this.history.push(guess); + this.emit('updated'); + + if (this.secret < guess) { + return firsttime ? `${guess}よりも小さいですね` : `もう一度言いますが${guess}より小さいですよ`; + } else if (this.secret > guess) { + return firsttime ? `${guess}よりも大きいですね` : `もう一度言いますが${guess}より大きいですよ`; + } else { + this.bot.clearContext(); + return `正解です🎉 (${this.history.length}回目で当てました)`; + } + } + + public export() { + return { + type: 'guessing-game', + content: { + secret: this.secret, + history: this.history + } + }; + } + + public static import(bot: BotCore, data: any) { + const context = new GuessingGameContext(bot); + context.secret = data.secret; + context.history = data.history; + return context; + } +} + +class OthelloContext extends Context { + private othello: Othello = null; + + constructor(bot: BotCore) { + super(bot); + + this.othello = new Othello(); + } + + public async greet(): Promise<string> { + return this.othello.toPatternString('black'); + } + + public async q(query: string): Promise<string> { + if (query == 'やめる') { + this.bot.clearContext(); + return 'オセロをやめました。'; + } + + const n = parseInt(query, 10); + + if (isNaN(n)) { + return '番号で指定してください。「やめる」と言うとゲームをやめます。'; + } + + this.othello.setByNumber('black', n); + const s = this.othello.toString() + '\n\n...(AI)...\n\n'; + othelloAi('white', this.othello); + if (this.othello.getPattern('black').length === 0) { + this.bot.clearContext(); + const blackCount = this.othello.board.map(row => row.filter(s => s == 'black').length).reduce((a, b) => a + b); + const whiteCount = this.othello.board.map(row => row.filter(s => s == 'white').length).reduce((a, b) => a + b); + const winner = blackCount == whiteCount ? '引き分け' : blackCount > whiteCount ? '黒の勝ち' : '白の勝ち'; + return this.othello.toString() + `\n\n~終了~\n\n黒${blackCount}、白${whiteCount}で${winner}です。`; + } else { + this.emit('updated'); + return s + this.othello.toPatternString('black'); + } + } + + public export() { + return { + type: 'othello', + content: { + board: this.othello.board + } + }; + } + + public static import(bot: BotCore, data: any) { + const context = new OthelloContext(bot); + context.othello = new Othello(); + context.othello.board = data.board; + return context; + } +} diff --git a/src/api/bot/interfaces/line.ts b/src/api/bot/interfaces/line.ts new file mode 100644 index 0000000000..0caa71ed2b --- /dev/null +++ b/src/api/bot/interfaces/line.ts @@ -0,0 +1,234 @@ +import * as EventEmitter from 'events'; +import * as express from 'express'; +import * as request from 'request'; +import * as crypto from 'crypto'; +import User from '../../models/user'; +import config from '../../../conf'; +import BotCore from '../core'; +import _redis from '../../../db/redis'; +import prominence = require('prominence'); +import getPostSummary from '../../../common/get-post-summary'; + +const redis = prominence(_redis); + +// SEE: https://developers.line.me/media/messaging-api/messages/sticker_list.pdf +const stickers = [ + '297', + '298', + '299', + '300', + '301', + '302', + '303', + '304', + '305', + '306', + '307' +]; + +class LineBot extends BotCore { + private replyToken: string; + + private reply(messages: any[]) { + request.post({ + url: 'https://api.line.me/v2/bot/message/reply', + headers: { + 'Authorization': `Bearer ${config.line_bot.channel_access_token}` + }, + json: { + replyToken: this.replyToken, + messages: messages + } + }, (err, res, body) => { + if (err) { + console.error(err); + return; + } + }); + } + + public async react(ev: any): Promise<void> { + this.replyToken = ev.replyToken; + + switch (ev.type) { + // メッセージ + case 'message': + switch (ev.message.type) { + // テキスト + case 'text': + const res = await this.q(ev.message.text); + if (res == null) return; + // 返信 + this.reply([{ + type: 'text', + text: res + }]); + break; + + // スタンプ + case 'sticker': + // スタンプで返信 + this.reply([{ + type: 'sticker', + packageId: '4', + stickerId: stickers[Math.floor(Math.random() * stickers.length)] + }]); + break; + } + break; + + // postback + case 'postback': + const data = ev.postback.data; + const cmd = data.split('|')[0]; + const arg = data.split('|')[1]; + switch (cmd) { + case 'showtl': + this.showUserTimelinePostback(arg); + break; + } + break; + } + } + + public static import(data) { + const bot = new LineBot(); + bot._import(data); + return bot; + } + + public async showUserCommand(q: string) { + const user = await require('../../endpoints/users/show')({ + username: q.substr(1) + }, this.user); + + const actions = []; + + actions.push({ + type: 'postback', + label: 'タイムラインを見る', + data: `showtl|${user.id}` + }); + + if (user.twitter) { + actions.push({ + type: 'uri', + label: 'Twitterアカウントを見る', + uri: `https://twitter.com/${user.twitter.screen_name}` + }); + } + + actions.push({ + type: 'uri', + label: 'Webで見る', + uri: `${config.url}/${user.username}` + }); + + this.reply([{ + type: 'template', + altText: await super.showUserCommand(q), + template: { + type: 'buttons', + thumbnailImageUrl: `${user.avatar_url}?thumbnail&size=1024`, + title: `${user.name} (@${user.username})`, + text: user.description || '(no description)', + actions: actions + } + }]); + } + + public async showUserTimelinePostback(userId: string) { + const tl = await require('../../endpoints/users/posts')({ + user_id: userId, + limit: 5 + }, this.user); + + const text = `${tl[0].user.name}さんのタイムラインはこちらです:\n\n` + tl + .map(post => getPostSummary(post)) + .join('\n-----\n'); + + this.reply([{ + type: 'text', + text: text + }]); + } +} + +module.exports = async (app: express.Application) => { + if (config.line_bot == null) return; + + const handler = new EventEmitter(); + + handler.on('event', async (ev) => { + + const sourceId = ev.source.userId; + const sessionId = `line-bot-sessions:${sourceId}`; + + const session = await redis.get(sessionId); + let bot: LineBot; + + if (session == null) { + const user = await User.findOne({ + line: { + user_id: sourceId + } + }); + + bot = new LineBot(user); + + bot.on('signin', user => { + User.update(user._id, { + $set: { + line: { + user_id: sourceId + } + } + }); + }); + + bot.on('signout', user => { + User.update(user._id, { + $set: { + line: { + user_id: null + } + } + }); + }); + + redis.set(sessionId, JSON.stringify(bot.export())); + } else { + bot = LineBot.import(JSON.parse(session)); + } + + bot.on('updated', () => { + redis.set(sessionId, JSON.stringify(bot.export())); + }); + + if (session != null) bot.refreshUser(); + + bot.react(ev); + }); + + app.post('/hooks/line', (req, res, next) => { + // req.headers['x-line-signature'] は常に string ですが、型定義の都合上 + // string | string[] になっているので string を明示しています + const sig1 = req.headers['x-line-signature'] as string; + + const hash = crypto.createHmac('SHA256', config.line_bot.channel_secret) + .update((req as any).rawBody); + + const sig2 = hash.digest('base64'); + + // シグネチャ比較 + if (sig1 === sig2) { + req.body.events.forEach(ev => { + handler.emit('event', ev); + }); + + res.sendStatus(200); + } else { + res.sendStatus(400); + } + }); +}; diff --git a/src/api/common/add-file-to-drive.ts b/src/api/common/add-file-to-drive.ts index 714eeb520d..f9c22ccacd 100644 --- a/src/api/common/add-file-to-drive.ts +++ b/src/api/common/add-file-to-drive.ts @@ -4,14 +4,27 @@ import * as gm from 'gm'; import * as debug from 'debug'; import fileType = require('file-type'); import prominence = require('prominence'); -import DriveFile from '../models/drive-file'; +import DriveFile, { getGridFSBucket } from '../models/drive-file'; import DriveFolder from '../models/drive-folder'; import serialize from '../serializers/drive-file'; import event from '../event'; import config from '../../conf'; +import { Duplex } from 'stream'; const log = debug('misskey:register-drive-file'); +const addToGridFS = (name, binary, metadata): Promise<any> => new Promise(async (resolve, reject) => { + const dataStream = new Duplex(); + dataStream.push(binary); + dataStream.push(null); + + const bucket = await getGridFSBucket(); + const writeStream = bucket.openUploadStream(name, { metadata }); + writeStream.once('finish', (doc) => { resolve(doc); }); + writeStream.on('error', reject); + dataStream.pipe(writeStream); +}); + /** * Add file to drive * @@ -58,7 +71,7 @@ export default ( // Generate hash const hash = crypto - .createHash('sha256') + .createHash('md5') .update(data) .digest('hex') as string; @@ -67,8 +80,8 @@ export default ( if (!force) { // Check if there is a file with the same hash const much = await DriveFile.findOne({ - user_id: user._id, - hash: hash + md5: hash, + 'metadata.user_id': user._id }); if (much !== null) { @@ -82,13 +95,13 @@ export default ( // Calculate drive usage const usage = ((await DriveFile .aggregate([ - { $match: { user_id: user._id } }, + { $match: { 'metadata.user_id': user._id } }, { $project: { - datasize: true + length: true }}, { $group: { _id: null, - usage: { $sum: '$datasize' } + usage: { $sum: '$length' } }} ]))[0] || { usage: 0 @@ -131,21 +144,15 @@ export default ( } // Create DriveFile document - const file = await DriveFile.insert({ - created_at: new Date(), + const file = await addToGridFS(`${user._id}/${name}`, data, { user_id: user._id, folder_id: folder !== null ? folder._id : null, - data: data, - datasize: size, type: mime, name: name, comment: comment, - hash: hash, properties: properties }); - delete file.data; - log(`drive file has been created ${file._id}`); resolve(file); diff --git a/src/api/common/generate-native-user-token.ts b/src/api/common/generate-native-user-token.ts new file mode 100644 index 0000000000..2082b89a5a --- /dev/null +++ b/src/api/common/generate-native-user-token.ts @@ -0,0 +1,3 @@ +import rndstr from 'rndstr'; + +export default () => `!${rndstr('a-zA-Z0-9', 32)}`; diff --git a/src/api/common/read-notification.ts b/src/api/common/read-notification.ts new file mode 100644 index 0000000000..3009cc5d08 --- /dev/null +++ b/src/api/common/read-notification.ts @@ -0,0 +1,52 @@ +import * as mongo from 'mongodb'; +import { default as Notification, INotification } from '../models/notification'; +import publishUserStream from '../event'; + +/** + * Mark as read notification(s) + */ +export default ( + user: string | mongo.ObjectID, + message: string | string[] | INotification | INotification[] | mongo.ObjectID | mongo.ObjectID[] +) => new Promise<any>(async (resolve, reject) => { + + const userId = mongo.ObjectID.prototype.isPrototypeOf(user) + ? user + : new mongo.ObjectID(user); + + const ids: mongo.ObjectID[] = Array.isArray(message) + ? mongo.ObjectID.prototype.isPrototypeOf(message[0]) + ? (message as mongo.ObjectID[]) + : typeof message[0] === 'string' + ? (message as string[]).map(m => new mongo.ObjectID(m)) + : (message as INotification[]).map(m => m._id) + : mongo.ObjectID.prototype.isPrototypeOf(message) + ? [(message as mongo.ObjectID)] + : typeof message === 'string' + ? [new mongo.ObjectID(message)] + : [(message as INotification)._id]; + + // Update documents + await Notification.update({ + _id: { $in: ids }, + is_read: false + }, { + $set: { + is_read: true + } + }, { + multi: true + }); + + // Calc count of my unread notifications + const count = await Notification + .count({ + notifiee_id: userId, + is_read: false + }); + + if (count == 0) { + // 全ての(いままで未読だった)通知を(これで)読みましたよというイベントを発行 + publishUserStream(userId, 'read_all_notifications'); + } +}); diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index 5bbc480a8e..afefce39e5 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -159,6 +159,18 @@ const endpoints: Endpoint[] = [ }, kind: 'account-write' }, + { + name: 'i/change_password', + withCredential: true + }, + { + name: 'i/regenerate_token', + withCredential: true + }, + { + name: 'i/pin', + kind: 'account-write' + }, { name: 'i/appdata/get', withCredential: true @@ -183,6 +195,11 @@ const endpoints: Endpoint[] = [ withCredential: true, kind: 'notification-read' }, + { + name: 'notifications/get_unread_count', + withCredential: true, + kind: 'notification-read' + }, { name: 'notifications/delete', withCredential: true, @@ -193,11 +210,6 @@ const endpoints: Endpoint[] = [ withCredential: true, kind: 'notification-write' }, - { - name: 'notifications/mark_as_read', - withCredential: true, - kind: 'notification-write' - }, { name: 'notifications/mark_as_read_all', withCredential: true, @@ -314,6 +326,9 @@ const endpoints: Endpoint[] = [ withCredential: true, kind: 'account-read' }, + { + name: 'users/get_frequently_replied_users' + }, { name: 'following/create', @@ -382,6 +397,10 @@ const endpoints: Endpoint[] = [ name: 'posts/trend', withCredential: true }, + { + name: 'posts/categorize', + withCredential: true + }, { name: 'posts/reactions', withCredential: true @@ -455,8 +474,33 @@ const endpoints: Endpoint[] = [ name: 'messaging/messages/create', withCredential: true, kind: 'messaging-write' - } - + }, + { + name: 'channels/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 3, + minInterval: ms('10seconds') + } + }, + { + name: 'channels/show' + }, + { + name: 'channels/posts' + }, + { + name: 'channels/watch', + withCredential: true + }, + { + name: 'channels/unwatch', + withCredential: true + }, + { + name: 'channels' + }, ]; export default endpoints; diff --git a/src/api/endpoints/aggregation/posts.ts b/src/api/endpoints/aggregation/posts.ts index 48ee225129..9d8bccbdb2 100644 --- a/src/api/endpoints/aggregation/posts.ts +++ b/src/api/endpoints/aggregation/posts.ts @@ -19,7 +19,7 @@ module.exports = params => new Promise(async (res, rej) => { .aggregate([ { $project: { repost_id: '$repost_id', - reply_to_id: '$reply_to_id', + reply_id: '$reply_id', created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST }}, { $project: { @@ -34,7 +34,7 @@ module.exports = params => new Promise(async (res, rej) => { then: 'repost', else: { $cond: { - if: { $ne: ['$reply_to_id', null] }, + if: { $ne: ['$reply_id', null] }, then: 'reply', else: 'post' } diff --git a/src/api/endpoints/aggregation/posts/reply.ts b/src/api/endpoints/aggregation/posts/reply.ts index 02a60c8969..b114c34e1e 100644 --- a/src/api/endpoints/aggregation/posts/reply.ts +++ b/src/api/endpoints/aggregation/posts/reply.ts @@ -26,7 +26,7 @@ module.exports = (params) => new Promise(async (res, rej) => { const datas = await Post .aggregate([ - { $match: { reply_to: post._id } }, + { $match: { reply: post._id } }, { $project: { created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST }}, diff --git a/src/api/endpoints/aggregation/users/activity.ts b/src/api/endpoints/aggregation/users/activity.ts index 5a3e78c441..102a71d7cb 100644 --- a/src/api/endpoints/aggregation/users/activity.ts +++ b/src/api/endpoints/aggregation/users/activity.ts @@ -40,7 +40,7 @@ module.exports = (params) => new Promise(async (res, rej) => { { $match: { user_id: user._id } }, { $project: { repost_id: '$repost_id', - reply_to_id: '$reply_to_id', + reply_id: '$reply_id', created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST }}, { $project: { @@ -55,7 +55,7 @@ module.exports = (params) => new Promise(async (res, rej) => { then: 'repost', else: { $cond: { - if: { $ne: ['$reply_to_id', null] }, + if: { $ne: ['$reply_id', null] }, then: 'reply', else: 'post' } diff --git a/src/api/endpoints/aggregation/users/post.ts b/src/api/endpoints/aggregation/users/post.ts index c964815a0c..c6a75eee39 100644 --- a/src/api/endpoints/aggregation/users/post.ts +++ b/src/api/endpoints/aggregation/users/post.ts @@ -34,7 +34,7 @@ module.exports = (params) => new Promise(async (res, rej) => { { $match: { user_id: user._id } }, { $project: { repost_id: '$repost_id', - reply_to_id: '$reply_to_id', + reply_id: '$reply_id', created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST }}, { $project: { @@ -49,7 +49,7 @@ module.exports = (params) => new Promise(async (res, rej) => { then: 'repost', else: { $cond: { - if: { $ne: ['$reply_to_id', null] }, + if: { $ne: ['$reply_id', null] }, then: 'reply', else: 'post' } diff --git a/src/api/endpoints/channels.ts b/src/api/endpoints/channels.ts new file mode 100644 index 0000000000..e10c943896 --- /dev/null +++ b/src/api/endpoints/channels.ts @@ -0,0 +1,59 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../models/channel'; +import serialize from '../serializers/channel'; + +/** + * Get all channels + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'max_id' parameter + const [maxId, maxIdErr] = $(params.max_id).optional.id().$; + if (maxIdErr) return rej('invalid max_id param'); + + // Check if both of since_id and max_id is specified + if (sinceId && maxId) { + return rej('cannot set since_id and max_id'); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = {} as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (maxId) { + query._id = { + $lt: maxId + }; + } + + // Issue query + const channels = await Channel + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(channels.map(async channel => + await serialize(channel, me)))); +}); diff --git a/src/api/endpoints/channels/create.ts b/src/api/endpoints/channels/create.ts new file mode 100644 index 0000000000..a8d7c29dc1 --- /dev/null +++ b/src/api/endpoints/channels/create.ts @@ -0,0 +1,39 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../models/channel'; +import Watching from '../../models/channel-watching'; +import serialize from '../../serializers/channel'; + +/** + * Create a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'title' parameter + const [title, titleErr] = $(params.title).string().range(1, 100).$; + if (titleErr) return rej('invalid title param'); + + // Create a channel + const channel = await Channel.insert({ + created_at: new Date(), + user_id: user._id, + title: title, + index: 0, + watching_count: 1 + }); + + // Response + res(await serialize(channel)); + + // Create Watching + await Watching.insert({ + created_at: new Date(), + user_id: user._id, + channel_id: channel._id + }); +}); diff --git a/src/api/endpoints/channels/posts.ts b/src/api/endpoints/channels/posts.ts new file mode 100644 index 0000000000..fa91fb93ee --- /dev/null +++ b/src/api/endpoints/channels/posts.ts @@ -0,0 +1,79 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import { default as Channel, IChannel } from '../../models/channel'; +import { default as Post, IPost } from '../../models/post'; +import serialize from '../../serializers/post'; + +/** + * Show a posts of a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 1000, limitErr] = $(params.limit).optional.number().range(1, 1000).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'max_id' parameter + const [maxId, maxIdErr] = $(params.max_id).optional.id().$; + if (maxIdErr) return rej('invalid max_id param'); + + // Check if both of since_id and max_id is specified + if (sinceId && maxId) { + return rej('cannot set since_id and max_id'); + } + + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + // Fetch channel + const channel: IChannel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + //#region Construct query + const sort = { + _id: -1 + }; + + const query = { + channel_id: channel._id + } as any; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (maxId) { + query._id = { + $lt: maxId + }; + } + //#endregion Construct query + + // Issue query + const posts = await Post + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(posts.map(async (post) => + await serialize(post, user) + ))); +}); diff --git a/src/api/endpoints/channels/show.ts b/src/api/endpoints/channels/show.ts new file mode 100644 index 0000000000..8861e54594 --- /dev/null +++ b/src/api/endpoints/channels/show.ts @@ -0,0 +1,31 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import { default as Channel, IChannel } from '../../models/channel'; +import serialize from '../../serializers/channel'; + +/** + * Show a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + // Fetch channel + const channel: IChannel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + // Serialize + res(await serialize(channel, user)); +}); diff --git a/src/api/endpoints/channels/unwatch.ts b/src/api/endpoints/channels/unwatch.ts new file mode 100644 index 0000000000..19d3be118a --- /dev/null +++ b/src/api/endpoints/channels/unwatch.ts @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../models/channel'; +import Watching from '../../models/channel-watching'; + +/** + * Unwatch a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + //#region Fetch channel + const channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + //#endregion + + //#region Check whether not watching + const exist = await Watching.findOne({ + user_id: user._id, + channel_id: channel._id, + deleted_at: { $exists: false } + }); + + if (exist === null) { + return rej('already not watching'); + } + //#endregion + + // Delete watching + await Watching.update({ + _id: exist._id + }, { + $set: { + deleted_at: new Date() + } + }); + + // Send response + res(); + + // Decrement watching count + Channel.update(channel._id, { + $inc: { + watching_count: -1 + } + }); +}); diff --git a/src/api/endpoints/channels/watch.ts b/src/api/endpoints/channels/watch.ts new file mode 100644 index 0000000000..030e0dd411 --- /dev/null +++ b/src/api/endpoints/channels/watch.ts @@ -0,0 +1,58 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../models/channel'; +import Watching from '../../models/channel-watching'; + +/** + * Watch a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + //#region Fetch channel + const channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + //#endregion + + //#region Check whether already watching + const exist = await Watching.findOne({ + user_id: user._id, + channel_id: channel._id, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return rej('already watching'); + } + //#endregion + + // Create Watching + await Watching.insert({ + created_at: new Date(), + user_id: user._id, + channel_id: channel._id + }); + + // Send response + res(); + + // Increment watching count + Channel.update(channel._id, { + $inc: { + watching_count: 1 + } + }); +}); diff --git a/src/api/endpoints/drive.ts b/src/api/endpoints/drive.ts index 41ad6301d7..d92473633a 100644 --- a/src/api/endpoints/drive.ts +++ b/src/api/endpoints/drive.ts @@ -14,16 +14,16 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Calculate drive usage const usage = ((await DriveFile .aggregate([ - { $match: { user_id: user._id } }, + { $match: { 'metadata.user_id': user._id } }, { $project: { - datasize: true + length: true } }, { $group: { _id: null, - usage: { $sum: '$datasize' } + usage: { $sum: '$length' } } } ]))[0] || { diff --git a/src/api/endpoints/drive/files.ts b/src/api/endpoints/drive/files.ts index a68ae34817..53b48a8bec 100644 --- a/src/api/endpoints/drive/files.ts +++ b/src/api/endpoints/drive/files.ts @@ -13,35 +13,35 @@ import serialize from '../../serializers/drive-file'; * @param {any} app * @return {Promise<any>} */ -module.exports = (params, user, app) => new Promise(async (res, rej) => { +module.exports = async (params, user, app) => { // Get 'limit' parameter const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; - if (limitErr) return rej('invalid limit param'); + if (limitErr) throw 'invalid limit param'; // Get 'since_id' parameter const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; - if (sinceIdErr) return rej('invalid since_id param'); + if (sinceIdErr) throw 'invalid since_id param'; // Get 'max_id' parameter const [maxId, maxIdErr] = $(params.max_id).optional.id().$; - if (maxIdErr) return rej('invalid max_id param'); + if (maxIdErr) throw 'invalid max_id param'; // Check if both of since_id and max_id is specified if (sinceId && maxId) { - return rej('cannot set since_id and max_id'); + throw 'cannot set since_id and max_id'; } // Get 'folder_id' parameter const [folderId = null, folderIdErr] = $(params.folder_id).optional.nullable.id().$; - if (folderIdErr) return rej('invalid folder_id param'); + if (folderIdErr) throw 'invalid folder_id param'; // Construct query const sort = { _id: -1 }; const query = { - user_id: user._id, - folder_id: folderId + 'metadata.user_id': user._id, + 'metadata.folder_id': folderId } as any; if (sinceId) { sort._id = 1; @@ -57,14 +57,11 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { // Issue query const files = await DriveFile .find(query, { - fields: { - data: false - }, limit: limit, sort: sort }); // Serialize - res(await Promise.all(files.map(async file => - await serialize(file)))); -}); + const _files = await Promise.all(files.map(file => serialize(file))); + return _files; +}; diff --git a/src/api/endpoints/drive/files/find.ts b/src/api/endpoints/drive/files/find.ts index cd0b33f2ca..1c818131d7 100644 --- a/src/api/endpoints/drive/files/find.ts +++ b/src/api/endpoints/drive/files/find.ts @@ -24,13 +24,9 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Issue query const files = await DriveFile .find({ - name: name, - user_id: user._id, - folder_id: folderId - }, { - fields: { - data: false - } + 'metadata.name': name, + 'metadata.user_id': user._id, + 'metadata.folder_id': folderId }); // Serialize diff --git a/src/api/endpoints/drive/files/show.ts b/src/api/endpoints/drive/files/show.ts index 8dbc297e4f..3c7cf774f9 100644 --- a/src/api/endpoints/drive/files/show.ts +++ b/src/api/endpoints/drive/files/show.ts @@ -12,28 +12,26 @@ import serialize from '../../../serializers/drive-file'; * @param {any} user * @return {Promise<any>} */ -module.exports = (params, user) => new Promise(async (res, rej) => { +module.exports = async (params, user) => { // Get 'file_id' parameter const [fileId, fileIdErr] = $(params.file_id).id().$; - if (fileIdErr) return rej('invalid file_id param'); + if (fileIdErr) throw 'invalid file_id param'; // Fetch file const file = await DriveFile .findOne({ _id: fileId, - user_id: user._id - }, { - fields: { - data: false - } + 'metadata.user_id': user._id }); if (file === null) { - return rej('file-not-found'); + throw 'file-not-found'; } // Serialize - res(await serialize(file, { + const _file = await serialize(file, { detail: true - })); -}); + }); + + return _file; +}; diff --git a/src/api/endpoints/drive/files/update.ts b/src/api/endpoints/drive/files/update.ts index 1cfbdd8f0b..d7b858c2ba 100644 --- a/src/api/endpoints/drive/files/update.ts +++ b/src/api/endpoints/drive/files/update.ts @@ -24,11 +24,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { const file = await DriveFile .findOne({ _id: fileId, - user_id: user._id - }, { - fields: { - data: false - } + 'metadata.user_id': user._id }); if (file === null) { @@ -38,7 +34,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'name' parameter const [name, nameErr] = $(params.name).optional.string().pipe(validateFileName).$; if (nameErr) return rej('invalid name param'); - if (name) file.name = name; + if (name) file.metadata.name = name; // Get 'folder_id' parameter const [folderId, folderIdErr] = $(params.folder_id).optional.nullable.id().$; @@ -46,7 +42,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (folderId !== undefined) { if (folderId === null) { - file.folder_id = null; + file.metadata.folder_id = null; } else { // Fetch folder const folder = await DriveFolder @@ -59,14 +55,14 @@ module.exports = (params, user) => new Promise(async (res, rej) => { return rej('folder-not-found'); } - file.folder_id = folder._id; + file.metadata.folder_id = folder._id; } } - DriveFile.update(file._id, { + await DriveFile.update(file._id, { $set: { - name: file.name, - folder_id: file.folder_id + 'metadata.name': file.metadata.name, + 'metadata.folder_id': file.metadata.folder_id } }); diff --git a/src/api/endpoints/drive/folders/find.ts b/src/api/endpoints/drive/folders/find.ts index cdf055839a..a5eb8e015d 100644 --- a/src/api/endpoints/drive/folders/find.ts +++ b/src/api/endpoints/drive/folders/find.ts @@ -30,6 +30,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { }); // Serialize - res(await Promise.all(folders.map(async folder => - await serialize(folder)))); + res(await Promise.all(folders.map(folder => serialize(folder)))); }); diff --git a/src/api/endpoints/drive/folders/update.ts b/src/api/endpoints/drive/folders/update.ts index eec2757878..4f2e3d2a7a 100644 --- a/src/api/endpoints/drive/folders/update.ts +++ b/src/api/endpoints/drive/folders/update.ts @@ -4,7 +4,7 @@ import $ from 'cafy'; import DriveFolder from '../../../models/drive-folder'; import { isValidFolderName } from '../../../models/drive-folder'; -import serialize from '../../../serializers/drive-file'; +import serialize from '../../../serializers/drive-folder'; import event from '../../../event'; /** diff --git a/src/api/endpoints/i/appdata/set.ts b/src/api/endpoints/i/appdata/set.ts index 24f192de6b..9c3dbe185b 100644 --- a/src/api/endpoints/i/appdata/set.ts +++ b/src/api/endpoints/i/appdata/set.ts @@ -21,7 +21,7 @@ module.exports = (params, user, app, isSecure) => new Promise(async (res, rej) = const [data, dataError] = $(params.data).optional.object() .pipe(obj => { const hasInvalidData = Object.entries(obj).some(([k, v]) => - $(k).string().match(/^[a-z_]+$/).isNg() && $(v).string().isNg()); + $(k).string().match(/^[a-z_]+$/).nok() && $(v).string().nok()); return !hasInvalidData; }).$; if (dataError) return rej('invalid data param'); diff --git a/src/api/endpoints/i/change_password.ts b/src/api/endpoints/i/change_password.ts new file mode 100644 index 0000000000..faceded29d --- /dev/null +++ b/src/api/endpoints/i/change_password.ts @@ -0,0 +1,42 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import User from '../../models/user'; + +/** + * Change password + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'current_password' parameter + const [currentPassword, currentPasswordErr] = $(params.current_password).string().$; + if (currentPasswordErr) return rej('invalid current_password param'); + + // Get 'new_password' parameter + const [newPassword, newPasswordErr] = $(params.new_password).string().$; + if (newPasswordErr) return rej('invalid new_password param'); + + // Compare password + const same = bcrypt.compareSync(currentPassword, user.password); + + if (!same) { + return rej('incorrect password'); + } + + // Generate hash of password + const salt = bcrypt.genSaltSync(8); + const hash = bcrypt.hashSync(newPassword, salt); + + await User.update(user._id, { + $set: { + password: hash + } + }); + + res(); +}); diff --git a/src/api/endpoints/i/notifications.ts b/src/api/endpoints/i/notifications.ts index 5575fb7412..607e0768a4 100644 --- a/src/api/endpoints/i/notifications.ts +++ b/src/api/endpoints/i/notifications.ts @@ -5,6 +5,7 @@ import $ from 'cafy'; import Notification from '../../models/notification'; import serialize from '../../serializers/notification'; import getFriends from '../../common/get-friends'; +import read from '../../common/read-notification'; /** * Get notifications @@ -91,17 +92,6 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Mark as read all if (notifications.length > 0 && markAsRead) { - const ids = notifications - .filter(x => x.is_read == false) - .map(x => x._id); - - // Update documents - await Notification.update({ - _id: { $in: ids } - }, { - $set: { is_read: true } - }, { - multi: true - }); + read(user._id, notifications); } }); diff --git a/src/api/endpoints/i/pin.ts b/src/api/endpoints/i/pin.ts new file mode 100644 index 0000000000..a94950d22b --- /dev/null +++ b/src/api/endpoints/i/pin.ts @@ -0,0 +1,44 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../models/user'; +import Post from '../../models/post'; +import serialize from '../../serializers/user'; + +/** + * Pin post + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Fetch pinee + const post = await Post.findOne({ + _id: postId, + user_id: user._id + }); + + if (post === null) { + return rej('post not found'); + } + + await User.update(user._id, { + $set: { + pinned_post_id: post._id + } + }); + + // Serialize + const iObj = await serialize(user, user, { + detail: true + }); + + // Send response + res(iObj); +}); diff --git a/src/api/endpoints/i/regenerate_token.ts b/src/api/endpoints/i/regenerate_token.ts new file mode 100644 index 0000000000..f96d10ebfc --- /dev/null +++ b/src/api/endpoints/i/regenerate_token.ts @@ -0,0 +1,42 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import User from '../../models/user'; +import event from '../../event'; +import generateUserToken from '../../common/generate-native-user-token'; + +/** + * Regenerate native token + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'password' parameter + const [password, passwordErr] = $(params.password).string().$; + if (passwordErr) return rej('invalid password param'); + + // Compare password + const same = bcrypt.compareSync(password, user.password); + + if (!same) { + return rej('incorrect password'); + } + + // Generate secret + const secret = generateUserToken(); + + await User.update(user._id, { + $set: { + token: secret + } + }); + + res(); + + // Publish event + event(user._id, 'my_token_regenerated'); +}); diff --git a/src/api/endpoints/messaging/messages/create.ts b/src/api/endpoints/messaging/messages/create.ts index 8af55d850c..149852c093 100644 --- a/src/api/endpoints/messaging/messages/create.ts +++ b/src/api/endpoints/messaging/messages/create.ts @@ -54,9 +54,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (fileId !== undefined) { file = await DriveFile.findOne({ _id: fileId, - user_id: user._id - }, { - data: false + 'metadata.user_id': user._id }); if (file === null) { diff --git a/src/api/endpoints/notifications/get_unread_count.ts b/src/api/endpoints/notifications/get_unread_count.ts new file mode 100644 index 0000000000..9514e78713 --- /dev/null +++ b/src/api/endpoints/notifications/get_unread_count.ts @@ -0,0 +1,23 @@ +/** + * Module dependencies + */ +import Notification from '../../models/notification'; + +/** + * Get count of unread notifications + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const count = await Notification + .count({ + notifiee_id: user._id, + is_read: false + }); + + res({ + count: count + }); +}); diff --git a/src/api/endpoints/notifications/mark_as_read.ts b/src/api/endpoints/notifications/mark_as_read.ts deleted file mode 100644 index 5cce33e850..0000000000 --- a/src/api/endpoints/notifications/mark_as_read.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Module dependencies - */ -import $ from 'cafy'; -import Notification from '../../models/notification'; -import serialize from '../../serializers/notification'; -import event from '../../event'; - -/** - * Mark as read a notification - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} - */ -module.exports = (params, user) => new Promise(async (res, rej) => { - const [notificationId, notificationIdErr] = $(params.notification_id).id().$; - if (notificationIdErr) return rej('invalid notification_id param'); - - // Get notification - const notification = await Notification - .findOne({ - _id: notificationId, - i: user._id - }); - - if (notification === null) { - return rej('notification-not-found'); - } - - // Update - notification.is_read = true; - Notification.update({ _id: notification._id }, { - $set: { - is_read: true - } - }); - - // Response - res(); - - // Serialize - const notificationObj = await serialize(notification); - - // Publish read_notification event - event(user._id, 'read_notification', notificationObj); -}); diff --git a/src/api/endpoints/notifications/mark_as_read_all.ts b/src/api/endpoints/notifications/mark_as_read_all.ts new file mode 100644 index 0000000000..3550e344c4 --- /dev/null +++ b/src/api/endpoints/notifications/mark_as_read_all.ts @@ -0,0 +1,32 @@ +/** + * Module dependencies + */ +import Notification from '../../models/notification'; +import event from '../../event'; + +/** + * Mark as read all notifications + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Update documents + await Notification.update({ + notifiee_id: user._id, + is_read: false + }, { + $set: { + is_read: true + } + }, { + multi: true + }); + + // Response + res(); + + // 全ての通知を読みましたよというイベントを発行 + event(user._id, 'read_all_notifications'); +}); diff --git a/src/api/endpoints/posts.ts b/src/api/endpoints/posts.ts index 23b9bd0b66..f6efcc108d 100644 --- a/src/api/endpoints/posts.ts +++ b/src/api/endpoints/posts.ts @@ -62,7 +62,7 @@ module.exports = (params) => new Promise(async (res, rej) => { } if (reply != undefined) { - query.reply_to_id = reply ? { $exists: true, $ne: null } : null; + query.reply_id = reply ? { $exists: true, $ne: null } : null; } if (repost != undefined) { diff --git a/src/api/endpoints/posts/categorize.ts b/src/api/endpoints/posts/categorize.ts new file mode 100644 index 0000000000..3530ba6bc4 --- /dev/null +++ b/src/api/endpoints/posts/categorize.ts @@ -0,0 +1,52 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post from '../../models/post'; + +/** + * Categorize a post + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + if (!user.is_pro) { + return rej('This endpoint is available only from a Pro account'); + } + + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Get categorizee + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + if (post.is_category_verified) { + return rej('This post already has the verified category'); + } + + // Get 'category' parameter + const [category, categoryErr] = $(params.category).string().or([ + 'music', 'game', 'anime', 'it', 'gadgets', 'photography' + ]).$; + if (categoryErr) return rej('invalid category param'); + + // Set category + Post.update({ _id: post._id }, { + $set: { + category: category, + is_category_verified: true + } + }); + + // Send response + res(); +}); diff --git a/src/api/endpoints/posts/context.ts b/src/api/endpoints/posts/context.ts index cd5f15f481..bad59a6bee 100644 --- a/src/api/endpoints/posts/context.ts +++ b/src/api/endpoints/posts/context.ts @@ -49,13 +49,13 @@ module.exports = (params, user) => new Promise(async (res, rej) => { return; } - if (p.reply_to_id) { - await get(p.reply_to_id); + if (p.reply_id) { + await get(p.reply_id); } } - if (post.reply_to_id) { - await get(post.reply_to_id); + if (post.reply_id) { + await get(post.reply_id); } // Serialize diff --git a/src/api/endpoints/posts/create.ts b/src/api/endpoints/posts/create.ts index eb979402c4..4f4b7e2e83 100644 --- a/src/api/endpoints/posts/create.ts +++ b/src/api/endpoints/posts/create.ts @@ -4,16 +4,17 @@ import $ from 'cafy'; import deepEqual = require('deep-equal'); import parse from '../../common/text'; -import Post from '../../models/post'; -import { isValidText } from '../../models/post'; -import User from '../../models/user'; +import { default as Post, IPost, isValidText } from '../../models/post'; +import { default as User, IUser } from '../../models/user'; +import { default as Channel, IChannel } from '../../models/channel'; import Following from '../../models/following'; import DriveFile from '../../models/drive-file'; import Watching from '../../models/post-watching'; +import ChannelWatching from '../../models/channel-watching'; import serialize from '../../serializers/post'; import notify from '../../common/notify'; import watch from '../../common/watch-post'; -import event from '../../event'; +import { default as event, publishChannelStream } from '../../event'; import config from '../../../conf'; /** @@ -24,7 +25,7 @@ import config from '../../../conf'; * @param {any} app * @return {Promise<any>} */ -module.exports = (params, user, app) => new Promise(async (res, rej) => { +module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { // Get 'text' parameter const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$; if (textErr) return rej('invalid text'); @@ -43,9 +44,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { // SELECT _id const entity = await DriveFile.findOne({ _id: mediaId, - user_id: user._id - }, { - _id: true + 'metadata.user_id': user._id }); if (entity === null) { @@ -62,7 +61,8 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { const [repostId, repostIdErr] = $(params.repost_id).optional.id().$; if (repostIdErr) return rej('invalid repost_id'); - let repost = null; + let repost: IPost = null; + let isQuote = false; if (repostId !== undefined) { // Fetch repost to post repost = await Post.findOne({ @@ -84,43 +84,86 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { } }); + isQuote = text != null || files != null; + // 直近と同じRepost対象かつ引用じゃなかったらエラー if (latestPost && latestPost.repost_id && latestPost.repost_id.equals(repost._id) && - text === undefined && files === null) { + !isQuote) { return rej('cannot repost same post that already reposted in your latest post'); } // 直近がRepost対象かつ引用じゃなかったらエラー if (latestPost && latestPost._id.equals(repost._id) && - text === undefined && files === null) { + !isQuote) { return rej('cannot repost your latest post'); } } - // Get 'in_reply_to_post_id' parameter - const [inReplyToPostId, inReplyToPostIdErr] = $(params.reply_to_id).optional.id().$; - if (inReplyToPostIdErr) return rej('invalid in_reply_to_post_id'); + // Get 'reply_id' parameter + const [replyId, replyIdErr] = $(params.reply_id).optional.id().$; + if (replyIdErr) return rej('invalid reply_id'); - let inReplyToPost = null; - if (inReplyToPostId !== undefined) { + let reply: IPost = null; + if (replyId !== undefined) { // Fetch reply - inReplyToPost = await Post.findOne({ - _id: inReplyToPostId + reply = await Post.findOne({ + _id: replyId }); - if (inReplyToPost === null) { + if (reply === null) { return rej('in reply to post is not found'); } // 返信対象が引用でないRepostだったらエラー - if (inReplyToPost.repost_id && !inReplyToPost.text && !inReplyToPost.media_ids) { + if (reply.repost_id && !reply.text && !reply.media_ids) { return rej('cannot reply to repost'); } } + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).optional.id().$; + if (channelIdErr) return rej('invalid channel_id'); + + let channel: IChannel = null; + if (channelId !== undefined) { + // Fetch channel + channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + // 返信対象の投稿がこのチャンネルじゃなかったらダメ + if (reply && !channelId.equals(reply.channel_id)) { + return rej('チャンネル内部からチャンネル外部の投稿に返信することはできません'); + } + + // Repost対象の投稿がこのチャンネルじゃなかったらダメ + if (repost && !channelId.equals(repost.channel_id)) { + return rej('チャンネル内部からチャンネル外部の投稿をRepostすることはできません'); + } + + // 引用ではないRepostはダメ + if (repost && !isQuote) { + return rej('チャンネル内部では引用ではないRepostをすることはできません'); + } + } else { + // 返信対象の投稿がチャンネルへの投稿だったらダメ + if (reply && reply.channel_id != null) { + return rej('チャンネル外部からチャンネル内部の投稿に返信することはできません'); + } + + // Repost対象の投稿がチャンネルへの投稿だったらダメ + if (repost && repost.channel_id != null) { + return rej('チャンネル外部からチャンネル内部の投稿をRepostすることはできません'); + } + } + // Get 'poll' parameter const [poll, pollErr] = $(params.poll).optional.strict.object() .have('choices', $().array('string') @@ -148,15 +191,15 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { if (user.latest_post) { if (deepEqual({ text: user.latest_post.text, - reply: user.latest_post.reply_to_id ? user.latest_post.reply_to_id.toString() : null, + reply: user.latest_post.reply_id ? user.latest_post.reply_id.toString() : null, repost: user.latest_post.repost_id ? user.latest_post.repost_id.toString() : null, media_ids: (user.latest_post.media_ids || []).map(id => id.toString()) }, { - text: text, - reply: inReplyToPost ? inReplyToPost._id.toString() : null, - repost: repost ? repost._id.toString() : null, - media_ids: (files || []).map(file => file._id.toString()) - })) { + text: text, + reply: reply ? reply._id.toString() : null, + repost: repost ? repost._id.toString() : null, + media_ids: (files || []).map(file => file._id.toString()) + })) { return rej('duplicate'); } } @@ -164,8 +207,10 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { // 投稿を作成 const post = await Post.insert({ created_at: new Date(), + channel_id: channel ? channel._id : undefined, + index: channel ? channel.index + 1 : undefined, media_ids: files ? files.map(file => file._id) : undefined, - reply_to_id: inReplyToPost ? inReplyToPost._id : undefined, + reply_id: reply ? reply._id : undefined, repost_id: repost ? repost._id : undefined, poll: poll, text: text, @@ -179,8 +224,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { // Reponse res(postObj); - // ----------------------------------------------------------- - // Post processes + //#region Post processes User.update({ _id: user._id }, { $set: { @@ -203,23 +247,51 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { } } - // Publish event to myself's stream - event(user._id, 'post', postObj); + // タイムラインへの投稿 + if (!channel) { + // Publish event to myself's stream + event(user._id, 'post', postObj); - // Fetch all followers - const followers = await Following - .find({ - followee_id: user._id, - // 削除されたドキュメントは除く - deleted_at: { $exists: false } - }, { - follower_id: true, - _id: false + // Fetch all followers + const followers = await Following + .find({ + followee_id: user._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }, { + follower_id: true, + _id: false + }); + + // Publish event to followers stream + followers.forEach(following => + event(following.follower_id, 'post', postObj)); + } + + // チャンネルへの投稿 + if (channel) { + // Increment channel index(posts count) + Channel.update({ _id: channel._id }, { + $inc: { + index: 1 + } }); - // Publish event to followers stream - followers.forEach(following => - event(following.follower_id, 'post', postObj)); + // Publish event to channel + publishChannelStream(channel._id, 'post', postObj); + + // Get channel watchers + const watches = await ChannelWatching.find({ + channel_id: channel._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }); + + // チャンネルの視聴者(のタイムライン)に配信 + watches.forEach(w => { + event(w.user_id, 'post', postObj); + }); + } // Increment my posts count User.update({ _id: user._id }, { @@ -229,23 +301,23 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { }); // If has in reply to post - if (inReplyToPost) { + if (reply) { // Increment replies count - Post.update({ _id: inReplyToPost._id }, { + Post.update({ _id: reply._id }, { $inc: { replies_count: 1 } }); // 自分自身へのリプライでない限りは通知を作成 - notify(inReplyToPost.user_id, user._id, 'reply', { + notify(reply.user_id, user._id, 'reply', { post_id: post._id }); // Fetch watchers Watching .find({ - post_id: inReplyToPost._id, + post_id: reply._id, user_id: { $ne: user._id }, // 削除されたドキュメントは除く deleted_at: { $exists: false } @@ -265,10 +337,10 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { // この投稿をWatchする // TODO: ユーザーが「返信したときに自動でWatchする」設定を // オフにしていた場合はしない - watch(user._id, inReplyToPost); + watch(user._id, reply); // Add mention - addMention(inReplyToPost.user_id, 'reply'); + addMention(reply.user_id, 'reply'); } // If it is repost @@ -369,7 +441,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { if (mentionee == null) return; // 既に言及されたユーザーに対する返信や引用repostの場合も無視 - if (inReplyToPost && inReplyToPost.user_id.equals(mentionee._id)) return; + if (reply && reply.user_id.equals(mentionee._id)) return; if (repost && repost.user_id.equals(mentionee._id)) return; // Add mention @@ -406,4 +478,6 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { } }); } + + //#endregion }); diff --git a/src/api/endpoints/posts/replies.ts b/src/api/endpoints/posts/replies.ts index 89f4d99841..3fd6a46769 100644 --- a/src/api/endpoints/posts/replies.ts +++ b/src/api/endpoints/posts/replies.ts @@ -40,7 +40,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Issue query const replies = await Post - .find({ reply_to_id: post._id }, { + .find({ reply_id: post._id }, { limit: limit, skip: offset, sort: { diff --git a/src/api/endpoints/posts/timeline.ts b/src/api/endpoints/posts/timeline.ts index 314e992344..203413e23a 100644 --- a/src/api/endpoints/posts/timeline.ts +++ b/src/api/endpoints/posts/timeline.ts @@ -2,7 +2,9 @@ * Module dependencies */ import $ from 'cafy'; +import rap from '@prezzemolo/rap'; import Post from '../../models/post'; +import ChannelWatching from '../../models/channel-watching'; import getFriends from '../../common/get-friends'; import serialize from '../../serializers/post'; @@ -14,36 +16,62 @@ import serialize from '../../serializers/post'; * @param {any} app * @return {Promise<any>} */ -module.exports = (params, user, app) => new Promise(async (res, rej) => { +module.exports = async (params, user, app) => { // Get 'limit' parameter const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; - if (limitErr) return rej('invalid limit param'); + if (limitErr) throw 'invalid limit param'; // Get 'since_id' parameter const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; - if (sinceIdErr) return rej('invalid since_id param'); + if (sinceIdErr) throw 'invalid since_id param'; // Get 'max_id' parameter const [maxId, maxIdErr] = $(params.max_id).optional.id().$; - if (maxIdErr) return rej('invalid max_id param'); + if (maxIdErr) throw 'invalid max_id param'; // Check if both of since_id and max_id is specified if (sinceId && maxId) { - return rej('cannot set since_id and max_id'); + throw 'cannot set since_id and max_id'; } - // ID list of the user $self and other users who the user follows - const followingIds = await getFriends(user._id); + const { followingIds, watchChannelIds } = await rap({ + // ID list of the user itself and other users who the user follows + followingIds: getFriends(user._id), + // Watchしているチャンネルを取得 + watchChannelIds: ChannelWatching.find({ + user_id: user._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }).then(watches => watches.map(w => w.channel_id)) + }); - // Construct query + //#region Construct query const sort = { _id: -1 }; + const query = { - user_id: { - $in: followingIds - } + $or: [{ + // フォローしている人のタイムラインへの投稿 + user_id: { + $in: followingIds + }, + // 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る + $or: [{ + channel_id: { + $exists: false + } + }, { + channel_id: null + }] + }, { + // Watchしているチャンネルへの投稿 + channel_id: { + $in: watchChannelIds + } + }] } as any; + if (sinceId) { sort._id = 1; query._id = { @@ -54,6 +82,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { $lt: maxId }; } + //#endregion // Issue query const timeline = await Post @@ -63,7 +92,6 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { }); // Serialize - res(await Promise.all(timeline.map(async post => - await serialize(post, user) - ))); -}); + const _timeline = await Promise.all(timeline.map(post => serialize(post, user))); + return _timeline; +}; diff --git a/src/api/endpoints/posts/trend.ts b/src/api/endpoints/posts/trend.ts index 3277206d26..64a195dff1 100644 --- a/src/api/endpoints/posts/trend.ts +++ b/src/api/endpoints/posts/trend.ts @@ -48,7 +48,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { } as any; if (reply != undefined) { - query.reply_to_id = reply ? { $exists: true, $ne: null } : null; + query.reply_id = reply ? { $exists: true, $ne: null } : null; } if (repost != undefined) { diff --git a/src/api/endpoints/users/get_frequently_replied_users.ts b/src/api/endpoints/users/get_frequently_replied_users.ts new file mode 100644 index 0000000000..bb0f3b4cea --- /dev/null +++ b/src/api/endpoints/users/get_frequently_replied_users.ts @@ -0,0 +1,96 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post from '../../models/post'; +import User from '../../models/user'; +import serialize from '../../serializers/user'; + +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).id().$; + if (userIdErr) return rej('invalid user_id param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + // Fetch recent posts + const recentPosts = await Post.find({ + user_id: user._id, + reply_id: { + $exists: true, + $ne: null + } + }, { + sort: { + _id: -1 + }, + limit: 1000, + fields: { + _id: false, + reply_id: true + } + }); + + // 投稿が少なかったら中断 + if (recentPosts.length === 0) { + return res([]); + } + + const replyTargetPosts = await Post.find({ + _id: { + $in: recentPosts.map(p => p.reply_id) + }, + user_id: { + $ne: user._id + } + }, { + fields: { + _id: false, + user_id: true + } + }); + + const repliedUsers = {}; + + // Extract replies from recent posts + replyTargetPosts.forEach(post => { + const userId = post.user_id.toString(); + if (repliedUsers[userId]) { + repliedUsers[userId]++; + } else { + repliedUsers[userId] = 1; + } + }); + + // Calc peak + let peak = 0; + Object.keys(repliedUsers).forEach(user => { + if (repliedUsers[user] > peak) peak = repliedUsers[user]; + }); + + // Sort replies by frequency + const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]); + + // Lookup top 10 replies + const topRepliedUsers = repliedUsersSorted.slice(0, 10); + + // Make replies object (includes weights) + const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ + user: await serialize(user, me, { detail: true }), + weight: repliedUsers[user] / peak + }))); + + // Response + res(repliesObj); +}); diff --git a/src/api/endpoints/users/posts.ts b/src/api/endpoints/users/posts.ts index e37b660773..d8204b8b80 100644 --- a/src/api/endpoints/users/posts.ts +++ b/src/api/endpoints/users/posts.ts @@ -85,7 +85,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { } if (!includeReplies) { - query.reply_to_id = null; + query.reply_id = null; } if (withMedia) { diff --git a/src/api/event.ts b/src/api/event.ts index 9613a9f7cc..909b0d2556 100644 --- a/src/api/event.ts +++ b/src/api/event.ts @@ -25,6 +25,10 @@ class MisskeyEvent { this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); } + public publishChannelStream(channelId: ID, type: string, value?: any): void { + this.publish(`channel-stream:${channelId}`, type, typeof value === 'undefined' ? null : value); + } + private publish(channel: string, type: string, value?: any): void { const message = value == null ? { type: type } : @@ -41,3 +45,5 @@ export default ev.publishUserStream.bind(ev); export const publishPostStream = ev.publishPostStream.bind(ev); export const publishMessagingStream = ev.publishMessagingStream.bind(ev); + +export const publishChannelStream = ev.publishChannelStream.bind(ev); diff --git a/src/api/models/access-token.ts b/src/api/models/access-token.ts index 2a8a512ddc..9985be5013 100644 --- a/src/api/models/access-token.ts +++ b/src/api/models/access-token.ts @@ -2,7 +2,7 @@ import db from '../../db/mongodb'; const collection = db.get('access_tokens'); -(collection as any).index('token'); // fuck type definition -(collection as any).index('hash'); // fuck type definition +(collection as any).createIndex('token'); // fuck type definition +(collection as any).createIndex('hash'); // fuck type definition export default collection as any; // fuck type definition diff --git a/src/api/models/app.ts b/src/api/models/app.ts index bf5dc80c2c..68f2f448b0 100644 --- a/src/api/models/app.ts +++ b/src/api/models/app.ts @@ -2,9 +2,9 @@ import db from '../../db/mongodb'; const collection = db.get('apps'); -(collection as any).index('name_id'); // fuck type definition -(collection as any).index('name_id_lower'); // fuck type definition -(collection as any).index('secret'); // fuck type definition +(collection as any).createIndex('name_id'); // fuck type definition +(collection as any).createIndex('name_id_lower'); // fuck type definition +(collection as any).createIndex('secret'); // fuck type definition export default collection as any; // fuck type definition diff --git a/src/api/models/channel-watching.ts b/src/api/models/channel-watching.ts new file mode 100644 index 0000000000..6184ae408d --- /dev/null +++ b/src/api/models/channel-watching.ts @@ -0,0 +1,3 @@ +import db from '../../db/mongodb'; + +export default db.get('channel_watching') as any; // fuck type definition diff --git a/src/api/models/channel.ts b/src/api/models/channel.ts new file mode 100644 index 0000000000..c80e84dbc8 --- /dev/null +++ b/src/api/models/channel.ts @@ -0,0 +1,14 @@ +import * as mongo from 'mongodb'; +import db from '../../db/mongodb'; + +const collection = db.get('channels'); + +export default collection as any; // fuck type definition + +export type IChannel = { + _id: mongo.ObjectID; + created_at: Date; + title: string; + user_id: mongo.ObjectID; + index: number; +}; diff --git a/src/api/models/drive-file.ts b/src/api/models/drive-file.ts index 4c7204b1f4..8968d065cd 100644 --- a/src/api/models/drive-file.ts +++ b/src/api/models/drive-file.ts @@ -1,11 +1,22 @@ -import db from '../../db/mongodb'; +import * as mongodb from 'mongodb'; +import monkDb, { nativeDbConn } from '../../db/mongodb'; -const collection = db.get('drive_files'); +const collection = monkDb.get('drive_files.files'); -(collection as any).index('hash'); // fuck type definition +(collection as any).createIndex('hash'); // fuck type definition export default collection as any; // fuck type definition +const getGridFSBucket = async (): Promise<mongodb.GridFSBucket> => { + const db = await nativeDbConn(); + const bucket = new mongodb.GridFSBucket(db, { + bucketName: 'drive_files' + }); + return bucket; +}; + +export { getGridFSBucket }; + export function validateFileName(name: string): boolean { return ( (name.trim().length > 0) && diff --git a/src/api/models/notification.ts b/src/api/models/notification.ts index 1c1f429a0d..1065e8baaa 100644 --- a/src/api/models/notification.ts +++ b/src/api/models/notification.ts @@ -1,3 +1,8 @@ +import * as mongo from 'mongodb'; import db from '../../db/mongodb'; export default db.get('notifications') as any; // fuck type definition + +export interface INotification { + _id: mongo.ObjectID; +} diff --git a/src/api/models/post.ts b/src/api/models/post.ts index baab63f991..7584ce182d 100644 --- a/src/api/models/post.ts +++ b/src/api/models/post.ts @@ -1,3 +1,5 @@ +import * as mongo from 'mongodb'; + import db from '../../db/mongodb'; export default db.get('posts') as any; // fuck type definition @@ -5,3 +7,16 @@ export default db.get('posts') as any; // fuck type definition export function isValidText(text: string): boolean { return text.length <= 1000 && text.trim() != ''; } + +export type IPost = { + _id: mongo.ObjectID; + channel_id: mongo.ObjectID; + created_at: Date; + media_ids: mongo.ObjectID[]; + reply_id: mongo.ObjectID; + repost_id: mongo.ObjectID; + poll: {}; // todo + text: string; + user_id: mongo.ObjectID; + app_id: mongo.ObjectID; +}; diff --git a/src/api/models/user.ts b/src/api/models/user.ts index cd16459891..b2f3af09fa 100644 --- a/src/api/models/user.ts +++ b/src/api/models/user.ts @@ -1,9 +1,12 @@ +import * as mongo from 'mongodb'; + import db from '../../db/mongodb'; +import { IPost } from './post'; const collection = db.get('users'); -(collection as any).index('username'); // fuck type definition -(collection as any).index('token'); // fuck type definition +(collection as any).createIndex('username'); // fuck type definition +(collection as any).createIndex('token'); // fuck type definition export default collection as any; // fuck type definition @@ -31,6 +34,50 @@ export function isValidBirthday(birthday: string): boolean { return typeof birthday == 'string' && /^([0-9]{4})\-([0-9]{2})-([0-9]{2})$/.test(birthday); } -export interface IUser { +export type IUser = { + _id: mongo.ObjectID; + created_at: Date; + email: string; + followers_count: number; + following_count: number; + links: string[]; name: string; + password: string; + posts_count: number; + drive_capacity: number; + username: string; + username_lower: string; + token: string; + avatar_id: mongo.ObjectID; + banner_id: mongo.ObjectID; + data: any; + twitter: { + access_token: string; + access_token_secret: string; + user_id: string; + screen_name: string; + }; + line: { + user_id: string; + }; + description: string; + profile: { + location: string; + birthday: string; // 'YYYY-MM-DD' + tags: string[]; + }; + last_used_at: Date; + latest_post: IPost; + pinned_post_id: mongo.ObjectID; + is_pro: boolean; + is_suspended: boolean; + keywords: string[]; +}; + +export function init(user): IUser { + user._id = new mongo.ObjectID(user._id); + user.avatar_id = new mongo.ObjectID(user.avatar_id); + user.banner_id = new mongo.ObjectID(user.banner_id); + user.pinned_post_id = new mongo.ObjectID(user.pinned_post_id); + return user; } diff --git a/src/api/private/signin.ts b/src/api/private/signin.ts index afa83e50c3..c7dc243980 100644 --- a/src/api/private/signin.ts +++ b/src/api/private/signin.ts @@ -1,6 +1,6 @@ import * as express from 'express'; import * as bcrypt from 'bcryptjs'; -import User from '../models/user'; +import { default as User, IUser } from '../models/user'; import Signin from '../models/signin'; import serialize from '../serializers/signin'; import event from '../event'; @@ -23,7 +23,7 @@ export default async (req: express.Request, res: express.Response) => { } // Fetch user - const user = await User.findOne({ + const user: IUser = await User.findOne({ username_lower: username.toLowerCase() }, { fields: { diff --git a/src/api/private/signup.ts b/src/api/private/signup.ts index 2375c22845..bcc17a876d 100644 --- a/src/api/private/signup.ts +++ b/src/api/private/signup.ts @@ -1,10 +1,10 @@ import * as express from 'express'; import * as bcrypt from 'bcryptjs'; -import rndstr from 'rndstr'; import recaptcha = require('recaptcha-promise'); -import User from '../models/user'; +import { default as User, IUser } from '../models/user'; import { validateUsername, validatePassword } from '../models/user'; import serialize from '../serializers/user'; +import generateUserToken from '../common/generate-native-user-token'; import config from '../../conf'; recaptcha.init({ @@ -58,10 +58,10 @@ export default async (req: express.Request, res: express.Response) => { const hash = bcrypt.hashSync(password, salt); // Generate secret - const secret = `!${rndstr('a-zA-Z0-9', 32)}`; + const secret = generateUserToken(); // Create account - const account = await User.insert({ + const account: IUser = await User.insert({ token: secret, avatar_id: null, banner_id: null, diff --git a/src/api/serializers/channel.ts b/src/api/serializers/channel.ts new file mode 100644 index 0000000000..3cba39aa16 --- /dev/null +++ b/src/api/serializers/channel.ts @@ -0,0 +1,66 @@ +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import { IUser } from '../models/user'; +import { default as Channel, IChannel } from '../models/channel'; +import Watching from '../models/channel-watching'; + +/** + * Serialize a channel + * + * @param channel target + * @param me? serializee + * @return response + */ +export default ( + channel: string | mongo.ObjectID | IChannel, + me?: string | mongo.ObjectID | IUser +) => new Promise<any>(async (resolve, reject) => { + + let _channel: any; + + // Populate the channel if 'channel' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(channel)) { + _channel = await Channel.findOne({ + _id: channel + }); + } else if (typeof channel === 'string') { + _channel = await Channel.findOne({ + _id: new mongo.ObjectID(channel) + }); + } else { + _channel = deepcopy(channel); + } + + // Rename _id to id + _channel.id = _channel._id; + delete _channel._id; + + // Remove needless properties + delete _channel.user_id; + + // Me + const meId: mongo.ObjectID = me + ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? me as mongo.ObjectID + : typeof me === 'string' + ? new mongo.ObjectID(me) + : (me as IUser)._id + : null; + + if (me) { + //#region Watchしているかどうか + const watch = await Watching.findOne({ + user_id: meId, + channel_id: _channel.id, + deleted_at: { $exists: false } + }); + + _channel.is_watching = watch !== null; + //#endregion + } + + resolve(_channel); +}); diff --git a/src/api/serializers/drive-file.ts b/src/api/serializers/drive-file.ts index b4e2ab064a..2af7db5726 100644 --- a/src/api/serializers/drive-file.ts +++ b/src/api/serializers/drive-file.ts @@ -31,44 +31,40 @@ export default ( if (mongo.ObjectID.prototype.isPrototypeOf(file)) { _file = await DriveFile.findOne({ _id: file - }, { - fields: { - data: false - } - }); + }); } else if (typeof file === 'string') { _file = await DriveFile.findOne({ _id: new mongo.ObjectID(file) - }, { - fields: { - data: false - } - }); + }); } else { _file = deepcopy(file); } - // Rename _id to id - _file.id = _file._id; - delete _file._id; + if (!_file) return reject('invalid file arg.'); - delete _file.data; + // rendered target + let _target: any = {}; - _file.url = `${config.drive_url}/${_file.id}/${encodeURIComponent(_file.name)}`; + _target.id = _file._id; + _target.created_at = _file.uploadDate; - if (opts.detail && _file.folder_id) { + _target = Object.assign(_target, _file.metadata); + + _target.url = `${config.drive_url}/${_target.id}/${encodeURIComponent(_target.name)}`; + + if (opts.detail && _target.folder_id) { // Populate folder - _file.folder = await serializeDriveFolder(_file.folder_id, { + _target.folder = await serializeDriveFolder(_target.folder_id, { detail: true }); } - if (opts.detail && _file.tags) { + if (opts.detail && _target.tags) { // Populate tags - _file.tags = await _file.tags.map(async (tag: any) => + _target.tags = await _target.tags.map(async (tag: any) => await serializeDriveTag(tag) ); } - resolve(_file); + resolve(_target); }); diff --git a/src/api/serializers/drive-folder.ts b/src/api/serializers/drive-folder.ts index a428464108..6ebf454a28 100644 --- a/src/api/serializers/drive-folder.ts +++ b/src/api/serializers/drive-folder.ts @@ -44,7 +44,7 @@ const self = ( }); const childFilesCount = await DriveFile.count({ - folder_id: _folder.id + 'metadata.folder_id': _folder.id }); _folder.folders_count = childFoldersCount; diff --git a/src/api/serializers/post.ts b/src/api/serializers/post.ts index 3c96884dd1..03fd120772 100644 --- a/src/api/serializers/post.ts +++ b/src/api/serializers/post.ts @@ -3,33 +3,45 @@ */ import * as mongo from 'mongodb'; import deepcopy = require('deepcopy'); -import Post from '../models/post'; +import { default as Post, IPost } from '../models/post'; import Reaction from '../models/post-reaction'; +import { IUser } from '../models/user'; import Vote from '../models/poll-vote'; import serializeApp from './app'; +import serializeChannel from './channel'; import serializeUser from './user'; import serializeDriveFile from './drive-file'; import parse from '../common/text'; +import rap from '@prezzemolo/rap'; /** * Serialize a post * - * @param {any} post - * @param {any} me? - * @param {any} options? - * @return {Promise<any>} + * @param post target + * @param me? serializee + * @param options? serialize options + * @return response */ -const self = ( - post: any, - me?: any, +const self = async ( + post: string | mongo.ObjectID | IPost, + me?: string | mongo.ObjectID | IUser, options?: { detail: boolean } -) => new Promise<any>(async (resolve, reject) => { +) => { const opts = options || { detail: true, }; + // Me + const meId: mongo.ObjectID = me + ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? me as mongo.ObjectID + : typeof me === 'string' + ? new mongo.ObjectID(me) + : (me as IUser)._id + : null; + let _post: any; // Populate the post if 'post' is ID @@ -45,6 +57,8 @@ const self = ( _post = deepcopy(post); } + if (!_post) throw 'invalid post arg.'; + const id = _post._id; // Rename _id to id @@ -59,62 +73,120 @@ const self = ( } // Populate user - _post.user = await serializeUser(_post.user_id, me); + _post.user = serializeUser(_post.user_id, meId); // Populate app if (_post.app_id) { - _post.app = await serializeApp(_post.app_id); + _post.app = serializeApp(_post.app_id); } + // Populate channel + if (_post.channel_id) { + _post.channel = serializeChannel(_post.channel_id); + } + + // Populate media if (_post.media_ids) { - // Populate media - _post.media = await Promise.all(_post.media_ids.map(async fileId => - await serializeDriveFile(fileId) + _post.media = Promise.all(_post.media_ids.map(fileId => + serializeDriveFile(fileId) )); } - if (_post.reply_to_id && opts.detail) { - // Populate reply to post - _post.reply_to = await self(_post.reply_to_id, me, { - detail: false - }); - } - - if (_post.repost_id && opts.detail) { - // Populate repost - _post.repost = await self(_post.repost_id, me, { - detail: _post.text == null - }); - } - - // Poll - if (me && _post.poll && opts.detail) { - const vote = await Vote - .findOne({ - user_id: me._id, - post_id: id + // When requested a detailed post data + if (opts.detail) { + // Get previous post info + _post.prev = (async () => { + const prev = await Post.findOne({ + user_id: _post.user_id, + _id: { + $lt: id + } + }, { + fields: { + _id: true + }, + sort: { + _id: -1 + } }); + return prev ? prev._id : null; + })(); - if (vote != null) { - _post.poll.choices.filter(c => c.id == vote.choice)[0].is_voted = true; + // Get next post info + _post.next = (async () => { + const next = await Post.findOne({ + user_id: _post.user_id, + _id: { + $gt: id + } + }, { + fields: { + _id: true + }, + sort: { + _id: 1 + } + }); + return next ? next._id : null; + })(); + + if (_post.reply_id) { + // Populate reply to post + _post.reply = self(_post.reply_id, meId, { + detail: false + }); + } + + if (_post.repost_id) { + // Populate repost + _post.repost = self(_post.repost_id, meId, { + detail: _post.text == null + }); + } + + // Poll + if (meId && _post.poll) { + _post.poll = (async (poll) => { + const vote = await Vote + .findOne({ + user_id: meId, + post_id: id + }); + + if (vote != null) { + const myChoice = poll.choices + .filter(c => c.id == vote.choice)[0]; + + myChoice.is_voted = true; + } + + return poll; + })(_post.poll); + } + + // Fetch my reaction + if (meId) { + _post.my_reaction = (async () => { + const reaction = await Reaction + .findOne({ + user_id: meId, + post_id: id, + deleted_at: { $exists: false } + }); + + if (reaction) { + return reaction.reaction; + } + + return null; + })(); } } - // Fetch my reaction - if (me && opts.detail) { - const reaction = await Reaction - .findOne({ - user_id: me._id, - post_id: id, - deleted_at: { $exists: false } - }); + // resolve promises in _post object + _post = await rap(_post); - if (reaction) { - _post.my_reaction = reaction.reaction; - } - } - - resolve(_post); -}); + return _post; +}; export default self; diff --git a/src/api/serializers/user.ts b/src/api/serializers/user.ts index bdbc749589..0d24d6cc04 100644 --- a/src/api/serializers/user.ts +++ b/src/api/serializers/user.ts @@ -3,22 +3,24 @@ */ import * as mongo from 'mongodb'; import deepcopy = require('deepcopy'); -import User from '../models/user'; +import { default as User, IUser } from '../models/user'; +import serializePost from './post'; import Following from '../models/following'; import getFriends from '../common/get-friends'; import config from '../../conf'; +import rap from '@prezzemolo/rap'; /** * Serialize a user * - * @param {any} user - * @param {any} me? - * @param {any} options? - * @return {Promise<any>} + * @param user target + * @param me? serializee + * @param options? serialize options + * @return response */ export default ( - user: any, - me?: any, + user: string | mongo.ObjectID | IUser, + me?: string | mongo.ObjectID | IUser, options?: { detail?: boolean, includeSecrets?: boolean @@ -36,7 +38,9 @@ export default ( data: false } : { data: false, - profile: false + profile: false, + keywords: false, + domains: false }; // Populate the user if 'user' is ID @@ -52,14 +56,16 @@ export default ( _user = deepcopy(user); } + if (!_user) return reject('invalid user arg.'); + // Me - if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { - if (typeof me === 'string') { - me = new mongo.ObjectID(me); - } else { - me = me._id; - } - } + const meId: mongo.ObjectID = me + ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? me as mongo.ObjectID + : typeof me === 'string' + ? new mongo.ObjectID(me) + : (me as IUser)._id + : null; // Rename _id to id _user.id = _user._id; @@ -76,6 +82,7 @@ export default ( delete _user.twitter.access_token; delete _user.twitter.access_token_secret; } + delete _user.line; // Visible via only the official client if (!opts.includeSecrets) { @@ -91,51 +98,65 @@ export default ( ? `${config.drive_url}/${_user.banner_id}` : null; - if (!me || !me.equals(_user.id) || !opts.detail) { + if (!meId || !meId.equals(_user.id) || !opts.detail) { delete _user.avatar_id; delete _user.banner_id; delete _user.drive_capacity; } - if (me && !me.equals(_user.id)) { + if (meId && !meId.equals(_user.id)) { // If the user is following - const follow = await Following.findOne({ - follower_id: me, - followee_id: _user.id, - deleted_at: { $exists: false } - }); - _user.is_following = follow !== null; + _user.is_following = (async () => { + const follow = await Following.findOne({ + follower_id: meId, + followee_id: _user.id, + deleted_at: { $exists: false } + }); + return follow !== null; + })(); // If the user is followed - const follow2 = await Following.findOne({ - follower_id: _user.id, - followee_id: me, - deleted_at: { $exists: false } - }); - _user.is_followed = follow2 !== null; + _user.is_followed = (async () => { + const follow2 = await Following.findOne({ + follower_id: _user.id, + followee_id: meId, + deleted_at: { $exists: false } + }); + return follow2 !== null; + })(); } - if (me && !me.equals(_user.id) && opts.detail) { - const myFollowingIds = await getFriends(me); + if (opts.detail) { + if (_user.pinned_post_id) { + // Populate pinned post + _user.pinned_post = serializePost(_user.pinned_post_id, meId, { + detail: true + }); + } - // Get following you know count - const followingYouKnowCount = await Following.count({ - followee_id: { $in: myFollowingIds }, - follower_id: _user.id, - deleted_at: { $exists: false } - }); - _user.following_you_know_count = followingYouKnowCount; + if (meId && !meId.equals(_user.id)) { + const myFollowingIds = await getFriends(meId); - // Get followers you know count - const followersYouKnowCount = await Following.count({ - followee_id: _user.id, - follower_id: { $in: myFollowingIds }, - deleted_at: { $exists: false } - }); - _user.followers_you_know_count = followersYouKnowCount; + // Get following you know count + _user.following_you_know_count = Following.count({ + followee_id: { $in: myFollowingIds }, + follower_id: _user.id, + deleted_at: { $exists: false } + }); + + // Get followers you know count + _user.followers_you_know_count = Following.count({ + followee_id: _user.id, + follower_id: { $in: myFollowingIds }, + deleted_at: { $exists: false } + }); + } } + // resolve promises in _user object + _user = await rap(_user); + resolve(_user); }); /* diff --git a/src/api/server.ts b/src/api/server.ts index c98167eb3e..3de32d9eab 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -19,7 +19,12 @@ app.disable('x-powered-by'); app.set('etag', false); app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.json({ - type: ['application/json', 'text/plain'] + type: ['application/json', 'text/plain'], + verify: (req, res, buf, encoding) => { + if (buf && buf.length) { + (req as any).rawBody = buf.toString(encoding || 'utf8'); + } + } })); app.use(cors({ origin: true @@ -54,4 +59,6 @@ app.use((req, res, next) => { require('./service/github')(app); require('./service/twitter')(app); +require('./bot/interfaces/line')(app); + module.exports = app; diff --git a/src/api/service/github.ts b/src/api/service/github.ts index a631808ba5..1c78267c0f 100644 --- a/src/api/service/github.ts +++ b/src/api/service/github.ts @@ -111,12 +111,12 @@ module.exports = async (app: express.Application) => { handler.on('watch', event => { const sender = event.sender; - post(`Starred by **${sender.login}**`); + post(`⭐️ Starred by **${sender.login}** ⭐️`); }); handler.on('fork', event => { const repo = event.forkee; - post(`Forked:\n${repo.html_url}`); + post(`🍴 Forked:\n${repo.html_url} 🍴`); }); handler.on('pull_request', event => { diff --git a/src/api/stream/channel.ts b/src/api/stream/channel.ts new file mode 100644 index 0000000000..d67d77cbf4 --- /dev/null +++ b/src/api/stream/channel.ts @@ -0,0 +1,12 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient): void { + const channel = request.resourceURL.query.channel; + + // Subscribe channel stream + subscriber.subscribe(`misskey:channel-stream:${channel}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); +} diff --git a/src/api/stream/home.ts b/src/api/stream/home.ts index 2ab8d3025b..7c8f3bfec8 100644 --- a/src/api/stream/home.ts +++ b/src/api/stream/home.ts @@ -2,7 +2,9 @@ import * as websocket from 'websocket'; import * as redis from 'redis'; import * as debug from 'debug'; +import User from '../models/user'; import serializePost from '../serializers/post'; +import readNotification from '../common/read-notification'; const log = debug('misskey'); @@ -35,6 +37,20 @@ export default function homeStream(request: websocket.request, connection: webso const msg = JSON.parse(data.utf8Data); switch (msg.type) { + case 'alive': + // Update lastUsedAt + User.update({ _id: user._id }, { + $set: { + last_used_at: new Date() + } + }); + break; + + case 'read_notification': + if (!msg.id) return; + readNotification(user._id, msg.id); + break; + case 'capture': if (!msg.id) return; const postId = msg.id; diff --git a/src/api/stream/server.ts b/src/api/stream/server.ts index 6de5337499..0db6643d40 100644 --- a/src/api/stream/server.ts +++ b/src/api/stream/server.ts @@ -14,7 +14,6 @@ export default function homeStream(request: websocket.request, connection: webso ev.addListener('stats', onStats); connection.on('close', () => { - console.log('yooo'); ev.removeListener('stats', onStats); }); } diff --git a/src/api/streaming.ts b/src/api/streaming.ts index c71132100c..0e512fb210 100644 --- a/src/api/streaming.ts +++ b/src/api/streaming.ts @@ -2,13 +2,14 @@ import * as http from 'http'; import * as websocket from 'websocket'; import * as redis from 'redis'; import config from '../conf'; -import User from './models/user'; +import { default as User, IUser } from './models/user'; import AccessToken from './models/access-token'; import isNativeToken from './common/is-native-token'; import homeStream from './stream/home'; import messagingStream from './stream/messaging'; import serverStream from './stream/server'; +import channelStream from './stream/channel'; module.exports = (server: http.Server) => { /** @@ -26,14 +27,6 @@ module.exports = (server: http.Server) => { return; } - const user = await authenticate(connection, request.resourceURL.query.i); - - if (user == null) { - connection.send('authentication-failed'); - connection.close(); - return; - } - // Connect to Redis const subscriber = redis.createClient( config.redis.port, config.redis.host); @@ -43,6 +36,19 @@ module.exports = (server: http.Server) => { subscriber.quit(); }); + if (request.resourceURL.pathname === '/channel') { + channelStream(request, connection, subscriber); + return; + } + + const user = await authenticate(request.resourceURL.query.i); + + if (user == null) { + connection.send('authentication-failed'); + connection.close(); + return; + } + const channel = request.resourceURL.pathname === '/' ? homeStream : request.resourceURL.pathname === '/messaging' ? messagingStream : @@ -56,7 +62,11 @@ module.exports = (server: http.Server) => { }); }; -function authenticate(connection: websocket.connection, token: string): Promise<any> { +/** + * 接続してきたユーザーを取得します + * @param token 送信されてきたトークン + */ +function authenticate(token: string): Promise<IUser> { if (token == null) { return Promise.resolve(null); } @@ -64,8 +74,7 @@ function authenticate(connection: websocket.connection, token: string): Promise< return new Promise(async (resolve, reject) => { if (isNativeToken(token)) { // Fetch user - // SELECT _id - const user = await User + const user: IUser = await User .findOne({ token: token }); @@ -81,13 +90,8 @@ function authenticate(connection: websocket.connection, token: string): Promise< } // Fetch user - // SELECT _id - const user = await User - .findOne({ _id: accessToken.user_id }, { - fields: { - _id: true - } - }); + const user: IUser = await User + .findOne({ _id: accessToken.user_id }); resolve(user); } diff --git a/src/web/app/common/scripts/get-post-summary.js b/src/common/get-post-summary.ts similarity index 57% rename from src/web/app/common/scripts/get-post-summary.js rename to src/common/get-post-summary.ts index 83eda8f6b4..6e8f65708e 100644 --- a/src/web/app/common/scripts/get-post-summary.js +++ b/src/common/get-post-summary.ts @@ -1,5 +1,15 @@ -const summarize = post => { - let summary = post.text ? post.text : ''; +/** + * 投稿を表す文字列を取得します。 + * @param {*} post 投稿 + */ +const summarize = (post: any): string => { + let summary = ''; + + // チャンネル + summary += post.channel ? `${post.channel.title}:` : ''; + + // 本文 + summary += post.text ? post.text : ''; // メディアが添付されているとき if (post.media) { @@ -12,9 +22,9 @@ const summarize = post => { } // 返信のとき - if (post.reply_to_id) { - if (post.reply_to) { - summary += ` RE: ${summarize(post.reply_to)}`; + if (post.reply_id) { + if (post.reply) { + summary += ` RE: ${summarize(post.reply)}`; } else { summary += ' RE: ...'; } diff --git a/src/common/get-user-summary.ts b/src/common/get-user-summary.ts new file mode 100644 index 0000000000..1bec2f9a26 --- /dev/null +++ b/src/common/get-user-summary.ts @@ -0,0 +1,12 @@ +import { IUser } from '../api/models/user'; + +/** + * ユーザーを表す文字列を取得します。 + * @param user ユーザー + */ +export default function(user: IUser): string { + return `${user.name} (@${user.username})\n` + + `${user.posts_count}投稿、${user.following_count}フォロー、${user.followers_count}フォロワー\n` + + `場所: ${user.profile.location}、誕生日: ${user.profile.birthday}\n` + + `「${user.description}」`; +} diff --git a/src/common/othello.ts b/src/common/othello.ts new file mode 100644 index 0000000000..858fc33158 --- /dev/null +++ b/src/common/othello.ts @@ -0,0 +1,268 @@ +const BOARD_SIZE = 8; + +export default class Othello { + public board: Array<Array<'black' | 'white'>>; + + /** + * ゲームを初期化します + */ + constructor() { + this.board = [ + [null, null, null, null, null, null, null, null], + [null, null, null, null, null, null, null, null], + [null, null, null, null, null, null, null, null], + [null, null, null, 'black', 'white', null, null, null], + [null, null, null, 'white', 'black', null, null, null], + [null, null, null, null, null, null, null, null], + [null, null, null, null, null, null, null, null], + [null, null, null, null, null, null, null, null] + ]; + } + + public setByNumber(color, n) { + const ps = this.getPattern(color); + this.set(color, ps[n][0], ps[n][1]); + } + + private write(color, x, y) { + this.board[y][x] = color; + } + + /** + * 石を配置します + */ + public set(color, x, y) { + this.write(color, x, y); + + const reverses = this.getReverse(color, x, y); + + reverses.forEach(r => { + switch (r[0]) { + case 0: // 上 + for (let c = 0, _y = y - 1; c < r[1]; c++, _y--) { + this.write(color, x, _y); + } + break; + + case 1: // 右上 + for (let c = 0, i = 1; c < r[1]; c++, i++) { + this.write(color, x + i, y - i); + } + break; + + case 2: // 右 + for (let c = 0, _x = x + 1; c < r[1]; c++, _x++) { + this.write(color, _x, y); + } + break; + + case 3: // 右下 + for (let c = 0, i = 1; c < r[1]; c++, i++) { + this.write(color, x + i, y + i); + } + break; + + case 4: // 下 + for (let c = 0, _y = y + 1; c < r[1]; c++, _y++) { + this.write(color, x, _y); + } + break; + + case 5: // 左下 + for (let c = 0, i = 1; c < r[1]; c++, i++) { + this.write(color, x - i, y + i); + } + break; + + case 6: // 左 + for (let c = 0, _x = x - 1; c < r[1]; c++, _x--) { + this.write(color, _x, y); + } + break; + + case 7: // 左上 + for (let c = 0, i = 1; c < r[1]; c++, i++) { + this.write(color, x - i, y - i); + } + break; + } + }); + } + + /** + * 打つことができる場所を取得します + */ + public getPattern(myColor): number[][] { + const result = []; + this.board.forEach((stones, y) => stones.forEach((stone, x) => { + if (stone != null) return; + if (this.canReverse(myColor, x, y)) result.push([x, y]); + })); + return result; + } + + /** + * 指定の位置に石を打つことができるかどうか(相手の石を1つでも反転させられるか)を取得します + */ + public canReverse(myColor, targetx, targety): boolean { + return this.getReverse(myColor, targetx, targety) !== null; + } + + private getReverse(myColor, targetx, targety): number[] { + const opponentColor = myColor == 'black' ? 'white' : 'black'; + + const createIterater = () => { + let opponentStoneFound = false; + let breaked = false; + return (x, y): any => { + if (breaked) { + return; + } else if (this.board[y][x] == myColor && opponentStoneFound) { + return true; + } else if (this.board[y][x] == myColor && !opponentStoneFound) { + breaked = true; + } else if (this.board[y][x] == opponentColor) { + opponentStoneFound = true; + } else { + breaked = true; + } + }; + }; + + const res = []; + + let iterate; + + // 上 + iterate = createIterater(); + for (let c = 0, y = targety - 1; y >= 0; c++, y--) { + if (iterate(targetx, y)) { + res.push([0, c]); + break; + } + } + + // 右上 + iterate = createIterater(); + for (let c = 0, i = 1; i <= Math.min(BOARD_SIZE - targetx, targety); c++, i++) { + if (iterate(targetx + i, targety - i)) { + res.push([1, c]); + break; + } + } + + // 右 + iterate = createIterater(); + for (let c = 0, x = targetx + 1; x < BOARD_SIZE; c++, x++) { + if (iterate(x, targety)) { + res.push([2, c]); + break; + } + } + + // 右下 + iterate = createIterater(); + for (let c = 0, i = 1; i < Math.min(BOARD_SIZE - targetx, BOARD_SIZE - targety); c++, i++) { + if (iterate(targetx + i, targety + i)) { + res.push([3, c]); + break; + } + } + + // 下 + iterate = createIterater(); + for (let c = 0, y = targety + 1; y < BOARD_SIZE; c++, y++) { + if (iterate(targetx, y)) { + res.push([4, c]); + break; + } + } + + // 左下 + iterate = createIterater(); + for (let c = 0, i = 1; i < Math.min(targetx, BOARD_SIZE - targety); c++, i++) { + if (iterate(targetx - i, targety + i)) { + res.push([5, c]); + break; + } + } + + // 左 + iterate = createIterater(); + for (let c = 0, x = targetx - 1; x >= 0; c++, x--) { + if (iterate(x, targety)) { + res.push([6, c]); + break; + } + } + + // 左上 + iterate = createIterater(); + for (let c = 0, i = 1; i <= Math.min(targetx, targety); c++, i++) { + if (iterate(targetx - i, targety - i)) { + res.push([7, c]); + break; + } + } + + return res.length === 0 ? null : res; + } + + public toString(): string { + //return this.board.map(row => row.map(state => state === 'black' ? '●' : state === 'white' ? '○' : '┼').join('')).join('\n'); + return this.board.map(row => row.map(state => state === 'black' ? '⚫️' : state === 'white' ? '⚪️' : '🔹').join('')).join('\n'); + } + + public toPatternString(color): string { + //const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; + const num = ['0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟', '🍏', '🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🍈', '🍒', '🍑', '🍍']; + + const pattern = this.getPattern(color); + + return this.board.map((row, y) => row.map((state, x) => { + const i = pattern.findIndex(p => p[0] == x && p[1] == y); + //return state === 'black' ? '●' : state === 'white' ? '○' : i != -1 ? num[i] : '┼'; + return state === 'black' ? '⚫️' : state === 'white' ? '⚪️' : i != -1 ? num[i] : '🔹'; + }).join('')).join('\n'); + } +} + +export function ai(color: string, othello: Othello) { + const opponentColor = color == 'black' ? 'white' : 'black'; + + function think() { + // 打てる場所を取得 + const ps = othello.getPattern(color); + + if (ps.length > 0) { // 打てる場所がある場合 + // 角を取得 + const corners = ps.filter(p => + // 左上 + (p[0] == 0 && p[1] == 0) || + // 右上 + (p[0] == (BOARD_SIZE - 1) && p[1] == 0) || + // 右下 + (p[0] == (BOARD_SIZE - 1) && p[1] == (BOARD_SIZE - 1)) || + // 左下 + (p[0] == 0 && p[1] == (BOARD_SIZE - 1)) + ); + + if (corners.length > 0) { // どこかしらの角に打てる場合 + // 打てる角からランダムに選択して打つ + const p = corners[Math.floor(Math.random() * corners.length)]; + othello.set(color, p[0], p[1]); + } else { // 打てる角がない場合 + // 打てる場所からランダムに選択して打つ + const p = ps[Math.floor(Math.random() * ps.length)]; + othello.set(color, p[0], p[1]); + } + + // 相手の打つ場所がない場合続けてAIのターン + if (othello.getPattern(opponentColor).length === 0) { + think(); + } + } + } + + think(); +} diff --git a/src/config.ts b/src/config.ts index 8f4ada5af9..d37d227a41 100644 --- a/src/config.ts +++ b/src/config.ts @@ -68,6 +68,13 @@ type Source = { hook_secret: string; username: string; }; + line_bot?: { + channel_secret: string; + channel_access_token: string; + }; + analysis?: { + mecab_command?: string; + }; }; /** @@ -81,6 +88,7 @@ type Mixin = { api_url: string; auth_url: string; about_url: string; + ch_url: string; stats_url: string; status_url: string; dev_url: string; @@ -115,6 +123,7 @@ export default function load() { mixin.secondary_scheme = config.secondary_url.substr(0, config.secondary_url.indexOf('://')); mixin.api_url = `${mixin.scheme}://api.${mixin.host}`; mixin.auth_url = `${mixin.scheme}://auth.${mixin.host}`; + mixin.ch_url = `${mixin.scheme}://ch.${mixin.host}`; mixin.dev_url = `${mixin.scheme}://dev.${mixin.host}`; mixin.about_url = `${mixin.scheme}://about.${mixin.host}`; mixin.stats_url = `${mixin.scheme}://stats.${mixin.host}`; diff --git a/src/const.json b/src/const.json index 1032ed538f..eeb304c9f3 100644 --- a/src/const.json +++ b/src/const.json @@ -1,5 +1,4 @@ { - "themeColor": "#87bb35", - "themeColorForeground": "#fff", - "idea": ["#f13049", "#f43636"] + "themeColor": "#f43636", + "themeColorForeground": "#fff" } diff --git a/src/db/mongodb.ts b/src/db/mongodb.ts index 6ee7f4534f..c978e6460f 100644 --- a/src/db/mongodb.ts +++ b/src/db/mongodb.ts @@ -1,11 +1,38 @@ -import * as mongo from 'monk'; - import config from '../conf'; const uri = config.mongodb.user && config.mongodb.pass - ? `mongodb://${config.mongodb.user}:${config.mongodb.pass}@${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}` - : `mongodb://${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`; +? `mongodb://${config.mongodb.user}:${config.mongodb.pass}@${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}` +: `mongodb://${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`; + +/** + * monk + */ +import * as mongo from 'monk'; const db = mongo(uri); export default db; + +/** + * MongoDB native module (officialy) + */ +import * as mongodb from 'mongodb'; + +let mdb: mongodb.Db; + +const nativeDbConn = async (): Promise<mongodb.Db> => { + if (mdb) return mdb; + + const db = await ((): Promise<mongodb.Db> => new Promise((resolve, reject) => { + mongodb.MongoClient.connect(uri, (e, db) => { + if (e) return reject(e); + resolve(db); + }); + }))(); + + mdb = db; + + return db; +}; + +export { nativeDbConn }; diff --git a/src/docs/api/entities/post.pug b/src/docs/api/entities/post.pug index e505d3fcb6..954f172717 100644 --- a/src/docs/api/entities/post.pug +++ b/src/docs/api/entities/post.pug @@ -52,11 +52,11 @@ block content td Number td 返信数 tr.optional - td reply_to + td reply td: a(href='./post', target='_blank') Post td 返信先の投稿 tr.nullable - td reply_to_id + td reply_id td ID td 返信先の投稿のID tr.optional @@ -90,7 +90,7 @@ block content { "created_at": "2016-12-10T00:28:50.114Z", "media_ids": null, - "reply_to_id": "584a16b15860fc52320137e3", + "reply_id": "584a16b15860fc52320137e3", "repost_id": null, "text": "小日向美穂だぞ!", "user_id": "5848bf7764e572683f4402f8", @@ -117,10 +117,10 @@ block content "is_following": true, "is_followed": true }, - "reply_to": { + "reply": { "created_at": "2016-12-09T02:28:01.563Z", "media_ids": null, - "reply_to_id": "5849d35e547e4249be329884", + "reply_id": "5849d35e547e4249be329884", "repost_id": null, "text": "アイコン小日向美穂?", "user_id": "57d01a501fdf2d07be417afe", diff --git a/src/file/server.ts b/src/file/server.ts index ee67cf7860..375f29487d 100644 --- a/src/file/server.ts +++ b/src/file/server.ts @@ -9,7 +9,7 @@ import * as cors from 'cors'; import * as mongodb from 'mongodb'; import * as gm from 'gm'; -import File from '../api/models/drive-file'; +import DriveFile, { getGridFSBucket } from '../api/models/drive-file'; /** * Init app @@ -97,17 +97,28 @@ app.get('/:id', async (req, res) => { return; } - const file = await File.findOne({ _id: new mongodb.ObjectID(req.params.id) }); + const fileId = new mongodb.ObjectID(req.params.id); + const file = await DriveFile.findOne({ _id: fileId }); if (file == null) { - res.status(404).sendFile(`${__dirname} / assets / dummy.png`); - return; - } else if (file.data == null) { - res.sendStatus(400); + res.status(404).sendFile(`${__dirname}/assets/dummy.png`); return; } - send(file.data.buffer, file.type, req, res); + const bucket = await getGridFSBucket(); + + const buffer = await ((id): Promise<Buffer> => new Promise((resolve, reject) => { + const chunks = []; + const readableStream = bucket.openDownloadStream(id); + readableStream.on('data', chunk => { + chunks.push(chunk); + }); + readableStream.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + }))(fileId); + + send(buffer, file.metadata.type, req, res); }); app.get('/:id/:name', async (req, res) => { @@ -117,17 +128,28 @@ app.get('/:id/:name', async (req, res) => { return; } - const file = await File.findOne({ _id: new mongodb.ObjectID(req.params.id) }); + const fileId = new mongodb.ObjectID(req.params.id); + const file = await DriveFile.findOne({ _id: fileId }); if (file == null) { res.status(404).sendFile(`${__dirname}/assets/dummy.png`); return; - } else if (file.data == null) { - res.sendStatus(400); - return; } - send(file.data.buffer, file.type, req, res); + const bucket = await getGridFSBucket(); + + const buffer = await ((id): Promise<Buffer> => new Promise((resolve, reject) => { + const chunks = []; + const readableStream = bucket.openDownloadStream(id); + readableStream.on('data', chunk => { + chunks.push(chunk); + }); + readableStream.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + }))(fileId); + + send(buffer, file.metadata.type, req, res); }); module.exports = app; diff --git a/src/tools/analysis/core.ts b/src/tools/analysis/core.ts new file mode 100644 index 0000000000..20e5fa6c51 --- /dev/null +++ b/src/tools/analysis/core.ts @@ -0,0 +1,49 @@ +const bayes = require('./naive-bayes.js'); + +const MeCab = require('./mecab'); +import Post from '../../api/models/post'; + +/** + * 投稿を学習したり与えられた投稿のカテゴリを予測します + */ +export default class Categorizer { + private classifier: any; + private mecab: any; + + constructor() { + this.mecab = new MeCab(); + + // BIND ----------------------------------- + this.tokenizer = this.tokenizer.bind(this); + } + + private tokenizer(text: string) { + const tokens = this.mecab.parseSync(text) + // 名詞だけに制限 + .filter(token => token[1] === '名詞') + // 取り出し + .map(token => token[0]); + + return tokens; + } + + public async init() { + this.classifier = bayes({ + tokenizer: this.tokenizer + }); + + // 訓練データ取得 + const verifiedPosts = await Post.find({ + is_category_verified: true + }); + + // 学習 + verifiedPosts.forEach(post => { + this.classifier.learn(post.text, post.category); + }); + } + + public async predict(text) { + return this.classifier.categorize(text); + } +} diff --git a/src/tools/analysis/extract-user-domains.ts b/src/tools/analysis/extract-user-domains.ts new file mode 100644 index 0000000000..bc120f5c17 --- /dev/null +++ b/src/tools/analysis/extract-user-domains.ts @@ -0,0 +1,120 @@ +import * as URL from 'url'; + +import Post from '../../api/models/post'; +import User from '../../api/models/user'; +import parse from '../../api/common/text'; + +process.on('unhandledRejection', console.dir); + +function tokenize(text: string) { + if (text == null) return []; + + // パース + const ast = parse(text); + + const domains = ast + // URLを抽出 + .filter(t => t.type == 'url' || t.type == 'link') + .map(t => URL.parse(t.url).hostname); + + return domains; +} + +// Fetch all users +User.find({}, { + fields: { + _id: true + } +}).then(users => { + let i = -1; + + const x = cb => { + if (++i == users.length) return cb(); + extractDomainsOne(users[i]._id).then(() => x(cb), err => { + console.error(err); + setTimeout(() => { + i--; + x(cb); + }, 1000); + }); + }; + + x(() => { + console.log('complete'); + }); +}); + +function extractDomainsOne(id) { + return new Promise(async (resolve, reject) => { + process.stdout.write(`extracting domains of ${id} ...`); + + // Fetch recent posts + const recentPosts = await Post.find({ + user_id: id, + text: { + $exists: true + } + }, { + sort: { + _id: -1 + }, + limit: 10000, + fields: { + _id: false, + text: true + } + }); + + // 投稿が少なかったら中断 + if (recentPosts.length < 100) { + process.stdout.write(' >>> -\n'); + return resolve(); + } + + const domains = {}; + + // Extract domains from recent posts + recentPosts.forEach(post => { + const domainsOfPost = tokenize(post.text); + + domainsOfPost.forEach(domain => { + if (domains[domain]) { + domains[domain]++; + } else { + domains[domain] = 1; + } + }); + }); + + // Calc peak + let peak = 0; + Object.keys(domains).forEach(domain => { + if (domains[domain] > peak) peak = domains[domain]; + }); + + // Sort domains by frequency + const domainsSorted = Object.keys(domains).sort((a, b) => domains[b] - domains[a]); + + // Lookup top 10 domains + const topDomains = domainsSorted.slice(0, 10); + + process.stdout.write(' >>> ' + topDomains.join(', ') + '\n'); + + // Make domains object (includes weights) + const domainsObj = topDomains.map(domain => ({ + domain: domain, + weight: domains[domain] / peak + })); + + // Save + User.update({ _id: id }, { + $set: { + domains: domainsObj + } + }).then(() => { + resolve(); + }, err => { + reject(err); + }); + }); +} diff --git a/src/tools/analysis/extract-user-keywords.ts b/src/tools/analysis/extract-user-keywords.ts new file mode 100644 index 0000000000..b99ca93211 --- /dev/null +++ b/src/tools/analysis/extract-user-keywords.ts @@ -0,0 +1,154 @@ +const moji = require('moji'); + +const MeCab = require('./mecab'); +import Post from '../../api/models/post'; +import User from '../../api/models/user'; +import parse from '../../api/common/text'; + +process.on('unhandledRejection', console.dir); + +const stopwords = [ + 'ー', + + 'の', 'に', 'は', 'を', 'た', 'が', 'で', 'て', 'と', 'し', 'れ', 'さ', + 'ある', 'いる', 'も', 'する', 'から', 'な', 'こと', 'として', 'い', 'や', 'れる', + 'など', 'なっ', 'ない', 'この', 'ため', 'その', 'あっ', 'よう', 'また', 'もの', + 'という', 'あり', 'まで', 'られ', 'なる', 'へ', 'か', 'だ', 'これ', 'によって', + 'により', 'おり', 'より', 'による', 'ず', 'なり', 'られる', 'において', 'ば', 'なかっ', + 'なく', 'しかし', 'について', 'せ', 'だっ', 'その後', 'できる', 'それ', 'う', 'ので', + 'なお', 'のみ', 'でき', 'き', 'つ', 'における', 'および', 'いう', 'さらに', 'でも', + 'ら', 'たり', 'その他', 'に関する', 'たち', 'ます', 'ん', 'なら', 'に対して', '特に', + 'せる', '及び', 'これら', 'とき', 'では', 'にて', 'ほか', 'ながら', 'うち', 'そして', + 'とともに', 'ただし', 'かつて', 'それぞれ', 'または', 'お', 'ほど', 'ものの', 'に対する', + 'ほとんど', 'と共に', 'といった', 'です', 'とも', 'ところ', 'ここ', '感じ', '気持ち', + 'あと', '自分', 'すき', '()', + + 'about', 'after', 'all', 'also', 'am', 'an', 'and', 'another', 'any', 'are', 'as', 'at', 'be', + 'because', 'been', 'before', 'being', 'between', 'both', 'but', 'by', 'came', 'can', + 'come', 'could', 'did', 'do', 'each', 'for', 'from', 'get', 'got', 'has', 'had', + 'he', 'have', 'her', 'here', 'him', 'himself', 'his', 'how', 'if', 'in', 'into', + 'is', 'it', 'like', 'make', 'many', 'me', 'might', 'more', 'most', 'much', 'must', + 'my', 'never', 'now', 'of', 'on', 'only', 'or', 'other', 'our', 'out', 'over', + 'said', 'same', 'see', 'should', 'since', 'some', 'still', 'such', 'take', 'than', + 'that', 'the', 'their', 'them', 'then', 'there', 'these', 'they', 'this', 'those', + 'through', 'to', 'too', 'under', 'up', 'very', 'was', 'way', 'we', 'well', 'were', + 'what', 'where', 'which', 'while', 'who', 'with', 'would', 'you', 'your', 'a', 'i' +]; + +const mecab = new MeCab(); + +function tokenize(text: string) { + if (text == null) return []; + + // パース + const ast = parse(text); + + const plain = ast + // テキストのみ(URLなどを除外するという意) + .filter(t => t.type == 'text' || t.type == 'bold') + .map(t => t.content) + .join(''); + + const tokens = mecab.parseSync(plain) + // キーワードのみ + .filter(token => token[1] == '名詞' && (token[2] == '固有名詞' || token[2] == '一般')) + // 取り出し(&整形(全角を半角にしたり大文字を小文字で統一したり)) + .map(token => moji(token[0]).convert('ZE', 'HE').convert('HK', 'ZK').toString().toLowerCase()) + // ストップワードなど + .filter(word => + stopwords.indexOf(word) === -1 && + word.length > 1 && + word.indexOf('!') === -1 && + word.indexOf('!') === -1 && + word.indexOf('?') === -1 && + word.indexOf('?') === -1); + + return tokens; +} + +// Fetch all users +User.find({}, { + fields: { + _id: true + } +}).then(users => { + let i = -1; + + const x = cb => { + if (++i == users.length) return cb(); + extractKeywordsOne(users[i]._id).then(() => x(cb), err => { + console.error(err); + setTimeout(() => { + i--; + x(cb); + }, 1000); + }); + }; + + x(() => { + console.log('complete'); + }); +}); + +function extractKeywordsOne(id) { + return new Promise(async (resolve, reject) => { + process.stdout.write(`extracting keywords of ${id} ...`); + + // Fetch recent posts + const recentPosts = await Post.find({ + user_id: id, + text: { + $exists: true + } + }, { + sort: { + _id: -1 + }, + limit: 10000, + fields: { + _id: false, + text: true + } + }); + + // 投稿が少なかったら中断 + if (recentPosts.length < 300) { + process.stdout.write(' >>> -\n'); + return resolve(); + } + + const keywords = {}; + + // Extract keywords from recent posts + recentPosts.forEach(post => { + const keywordsOfPost = tokenize(post.text); + + keywordsOfPost.forEach(keyword => { + if (keywords[keyword]) { + keywords[keyword]++; + } else { + keywords[keyword] = 1; + } + }); + }); + + // Sort keywords by frequency + const keywordsSorted = Object.keys(keywords).sort((a, b) => keywords[b] - keywords[a]); + + // Lookup top 10 keywords + const topKeywords = keywordsSorted.slice(0, 10); + + process.stdout.write(' >>> ' + topKeywords.join(', ') + '\n'); + + // Save + User.update({ _id: id }, { + $set: { + keywords: topKeywords + } + }).then(() => { + resolve(); + }, err => { + reject(err); + }); + }); +} diff --git a/src/tools/analysis/mecab.js b/src/tools/analysis/mecab.js new file mode 100644 index 0000000000..82f7d6d529 --- /dev/null +++ b/src/tools/analysis/mecab.js @@ -0,0 +1,85 @@ +// Original source code: https://github.com/hecomi/node-mecab-async +// CUSTOMIZED BY SYUILO + +var exec = require('child_process').exec; +var execSync = require('child_process').execSync; +var sq = require('shell-quote'); + +const config = require('../../conf').default; + +// for backward compatibility +var MeCab = function() {}; + +MeCab.prototype = { + command : config.analysis.mecab_command ? config.analysis.mecab_command : 'mecab', + _format: function(arrayResult) { + var result = []; + if (!arrayResult) { return result; } + // Reference: http://mecab.googlecode.com/svn/trunk/mecab/doc/index.html + // 表層形\t品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用形,活用型,原形,読み,発音 + arrayResult.forEach(function(parsed) { + if (parsed.length <= 8) { return; } + result.push({ + kanji : parsed[0], + lexical : parsed[1], + compound : parsed[2], + compound2 : parsed[3], + compound3 : parsed[4], + conjugation : parsed[5], + inflection : parsed[6], + original : parsed[7], + reading : parsed[8], + pronunciation : parsed[9] || '' + }); + }); + return result; + }, + _shellCommand : function(str) { + return sq.quote(['echo', str]) + ' | ' + this.command; + }, + _parseMeCabResult : function(result) { + return result.split('\n').map(function(line) { + return line.replace('\t', ',').split(','); + }); + }, + parse : function(str, callback) { + process.nextTick(function() { // for bug + exec(MeCab._shellCommand(str), function(err, result) { + if (err) { return callback(err); } + callback(err, MeCab._parseMeCabResult(result).slice(0,-2)); + }); + }); + }, + parseSync : function(str) { + var result = execSync(MeCab._shellCommand(str)); + return MeCab._parseMeCabResult(String(result)).slice(0, -2); + }, + parseFormat : function(str, callback) { + MeCab.parse(str, function(err, result) { + if (err) { return callback(err); } + callback(err, MeCab._format(result)); + }); + }, + parseSyncFormat : function(str) { + return MeCab._format(MeCab.parseSync(str)); + }, + _wakatsu : function(arr) { + return arr.map(function(data) { return data[0]; }); + }, + wakachi : function(str, callback) { + MeCab.parse(str, function(err, arr) { + if (err) { return callback(err); } + callback(null, MeCab._wakatsu(arr)); + }); + }, + wakachiSync : function(str) { + var arr = MeCab.parseSync(str); + return MeCab._wakatsu(arr); + } +}; + +for (var x in MeCab.prototype) { + MeCab[x] = MeCab.prototype[x]; +} + +module.exports = MeCab; diff --git a/src/tools/analysis/naive-bayes.js b/src/tools/analysis/naive-bayes.js new file mode 100644 index 0000000000..78f07153cf --- /dev/null +++ b/src/tools/analysis/naive-bayes.js @@ -0,0 +1,302 @@ +// Original source code: https://github.com/ttezel/bayes/blob/master/lib/naive_bayes.js (commit: 2c20d3066e4fc786400aaedcf3e42987e52abe3c) +// CUSTOMIZED BY SYUILO + +/* + Expose our naive-bayes generator function +*/ +module.exports = function (options) { + return new Naivebayes(options) +} + +// keys we use to serialize a classifier's state +var STATE_KEYS = module.exports.STATE_KEYS = [ + 'categories', 'docCount', 'totalDocuments', 'vocabulary', 'vocabularySize', + 'wordCount', 'wordFrequencyCount', 'options' +]; + +/** + * Initializes a NaiveBayes instance from a JSON state representation. + * Use this with classifier.toJson(). + * + * @param {String} jsonStr state representation obtained by classifier.toJson() + * @return {NaiveBayes} Classifier + */ +module.exports.fromJson = function (jsonStr) { + var parsed; + try { + parsed = JSON.parse(jsonStr) + } catch (e) { + throw new Error('Naivebayes.fromJson expects a valid JSON string.') + } + // init a new classifier + var classifier = new Naivebayes(parsed.options) + + // override the classifier's state + STATE_KEYS.forEach(function (k) { + if (!parsed[k]) { + throw new Error('Naivebayes.fromJson: JSON string is missing an expected property: `'+k+'`.') + } + classifier[k] = parsed[k] + }) + + return classifier +} + +/** + * Given an input string, tokenize it into an array of word tokens. + * This is the default tokenization function used if user does not provide one in `options`. + * + * @param {String} text + * @return {Array} + */ +var defaultTokenizer = function (text) { + //remove punctuation from text - remove anything that isn't a word char or a space + var rgxPunctuation = /[^(a-zA-ZA-Яa-я0-9_)+\s]/g + + var sanitized = text.replace(rgxPunctuation, ' ') + + return sanitized.split(/\s+/) +} + +/** + * Naive-Bayes Classifier + * + * This is a naive-bayes classifier that uses Laplace Smoothing. + * + * Takes an (optional) options object containing: + * - `tokenizer` => custom tokenization function + * + */ +function Naivebayes (options) { + // set options object + this.options = {} + if (typeof options !== 'undefined') { + if (!options || typeof options !== 'object' || Array.isArray(options)) { + throw TypeError('NaiveBayes got invalid `options`: `' + options + '`. Pass in an object.') + } + this.options = options + } + + this.tokenizer = this.options.tokenizer || defaultTokenizer + + //initialize our vocabulary and its size + this.vocabulary = {} + this.vocabularySize = 0 + + //number of documents we have learned from + this.totalDocuments = 0 + + //document frequency table for each of our categories + //=> for each category, how often were documents mapped to it + this.docCount = {} + + //for each category, how many words total were mapped to it + this.wordCount = {} + + //word frequency table for each category + //=> for each category, how frequent was a given word mapped to it + this.wordFrequencyCount = {} + + //hashmap of our category names + this.categories = {} +} + +/** + * Initialize each of our data structure entries for this new category + * + * @param {String} categoryName + */ +Naivebayes.prototype.initializeCategory = function (categoryName) { + if (!this.categories[categoryName]) { + this.docCount[categoryName] = 0 + this.wordCount[categoryName] = 0 + this.wordFrequencyCount[categoryName] = {} + this.categories[categoryName] = true + } + return this +} + +/** + * train our naive-bayes classifier by telling it what `category` + * the `text` corresponds to. + * + * @param {String} text + * @param {String} class + */ +Naivebayes.prototype.learn = function (text, category) { + var self = this + + //initialize category data structures if we've never seen this category + self.initializeCategory(category) + + //update our count of how many documents mapped to this category + self.docCount[category]++ + + //update the total number of documents we have learned from + self.totalDocuments++ + + //normalize the text into a word array + var tokens = self.tokenizer(text) + + //get a frequency count for each token in the text + var frequencyTable = self.frequencyTable(tokens) + + /* + Update our vocabulary and our word frequency count for this category + */ + + Object + .keys(frequencyTable) + .forEach(function (token) { + //add this word to our vocabulary if not already existing + if (!self.vocabulary[token]) { + self.vocabulary[token] = true + self.vocabularySize++ + } + + var frequencyInText = frequencyTable[token] + + //update the frequency information for this word in this category + if (!self.wordFrequencyCount[category][token]) + self.wordFrequencyCount[category][token] = frequencyInText + else + self.wordFrequencyCount[category][token] += frequencyInText + + //update the count of all words we have seen mapped to this category + self.wordCount[category] += frequencyInText + }) + + return self +} + +/** + * Determine what category `text` belongs to. + * + * @param {String} text + * @return {String} category + */ +Naivebayes.prototype.categorize = function (text) { + var self = this + , maxProbability = -Infinity + , chosenCategory = null + + var tokens = self.tokenizer(text) + var frequencyTable = self.frequencyTable(tokens) + + //iterate thru our categories to find the one with max probability for this text + Object + .keys(self.categories) + .forEach(function (category) { + + //start by calculating the overall probability of this category + //=> out of all documents we've ever looked at, how many were + // mapped to this category + var categoryProbability = self.docCount[category] / self.totalDocuments + + //take the log to avoid underflow + var logProbability = Math.log(categoryProbability) + + //now determine P( w | c ) for each word `w` in the text + Object + .keys(frequencyTable) + .forEach(function (token) { + var frequencyInText = frequencyTable[token] + var tokenProbability = self.tokenProbability(token, category) + + // console.log('token: %s category: `%s` tokenProbability: %d', token, category, tokenProbability) + + //determine the log of the P( w | c ) for this word + logProbability += frequencyInText * Math.log(tokenProbability) + }) + + if (logProbability > maxProbability) { + maxProbability = logProbability + chosenCategory = category + } + }) + + return chosenCategory +} + +/** + * Calculate probability that a `token` belongs to a `category` + * + * @param {String} token + * @param {String} category + * @return {Number} probability + */ +Naivebayes.prototype.tokenProbability = function (token, category) { + //how many times this word has occurred in documents mapped to this category + var wordFrequencyCount = this.wordFrequencyCount[category][token] || 0 + + //what is the count of all words that have ever been mapped to this category + var wordCount = this.wordCount[category] + + //use laplace Add-1 Smoothing equation + return ( wordFrequencyCount + 1 ) / ( wordCount + this.vocabularySize ) +} + +/** + * Build a frequency hashmap where + * - the keys are the entries in `tokens` + * - the values are the frequency of each entry in `tokens` + * + * @param {Array} tokens Normalized word array + * @return {Object} + */ +Naivebayes.prototype.frequencyTable = function (tokens) { + var frequencyTable = Object.create(null) + + tokens.forEach(function (token) { + if (!frequencyTable[token]) + frequencyTable[token] = 1 + else + frequencyTable[token]++ + }) + + return frequencyTable +} + +/** + * Dump the classifier's state as a JSON string. + * @return {String} Representation of the classifier. + */ +Naivebayes.prototype.toJson = function () { + var state = {} + var self = this + STATE_KEYS.forEach(function (k) { + state[k] = self[k] + }) + + var jsonStr = JSON.stringify(state) + + return jsonStr +} + +// (original method) +Naivebayes.prototype.export = function () { + var state = {} + var self = this + STATE_KEYS.forEach(function (k) { + state[k] = self[k] + }) + + return state +} + +module.exports.import = function (data) { + var parsed = data + + // init a new classifier + var classifier = new Naivebayes() + + // override the classifier's state + STATE_KEYS.forEach(function (k) { + if (!parsed[k]) { + throw new Error('Naivebayes.import: data is missing an expected property: `'+k+'`.') + } + classifier[k] = parsed[k] + }) + + return classifier +} diff --git a/src/tools/analysis/predict-all-post-category.ts b/src/tools/analysis/predict-all-post-category.ts new file mode 100644 index 0000000000..058c4f99ef --- /dev/null +++ b/src/tools/analysis/predict-all-post-category.ts @@ -0,0 +1,35 @@ +import Post from '../../api/models/post'; +import Core from './core'; + +const c = new Core(); + +c.init().then(() => { + // 全ての(人間によって証明されていない)投稿を取得 + Post.find({ + text: { + $exists: true + }, + is_category_verified: { + $ne: true + } + }, { + sort: { + _id: -1 + }, + fields: { + _id: true, + text: true + } + }).then(posts => { + posts.forEach(post => { + console.log(`predicting... ${post._id}`); + const category = c.predict(post.text); + + Post.update({ _id: post._id }, { + $set: { + category: category + } + }); + }); + }); +}); diff --git a/src/tools/analysis/predict-user-interst.ts b/src/tools/analysis/predict-user-interst.ts new file mode 100644 index 0000000000..99bdfa4206 --- /dev/null +++ b/src/tools/analysis/predict-user-interst.ts @@ -0,0 +1,45 @@ +import Post from '../../api/models/post'; +import User from '../../api/models/user'; + +export async function predictOne(id) { + console.log(`predict interest of ${id} ...`); + + // TODO: repostなども含める + const recentPosts = await Post.find({ + user_id: id, + category: { + $exists: true + } + }, { + sort: { + _id: -1 + }, + limit: 1000, + fields: { + _id: false, + category: true + } + }); + + const categories = {}; + + recentPosts.forEach(post => { + if (categories[post.category]) { + categories[post.category]++; + } else { + categories[post.category] = 1; + } + }); +} + +export async function predictAll() { + const allUsers = await User.find({}, { + fields: { + _id: true + } + }); + + allUsers.forEach(user => { + predictOne(user._id); + }); +} diff --git a/src/tsconfig.json b/src/tsconfig.json index ecff047a74..36600eed2b 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "allowJs": true, "noEmitOnError": false, "noImplicitAny": false, "noImplicitReturns": true, diff --git a/src/utils/type.ts b/src/utils/type.ts new file mode 100644 index 0000000000..ba6ea0be77 --- /dev/null +++ b/src/utils/type.ts @@ -0,0 +1,3 @@ +// https://github.com/Microsoft/TypeScript/issues/12215 +export type Diff<T extends string, U extends string> = ({ [P in T]: P } & { [P in U]: never } & { [x: string]: never })[T]; +export type Omit<T, K extends keyof T> = { [P in Diff<keyof T, K>]: T[P] }; diff --git a/src/web/app/base.styl b/src/web/app/app.styl similarity index 94% rename from src/web/app/base.styl rename to src/web/app/app.styl index 81c039f0a3..94faba73d4 100644 --- a/src/web/app/base.styl +++ b/src/web/app/app.styl @@ -5,8 +5,6 @@ json('../../const.json') $theme-color = themeColor $theme-color-foreground = themeColorForeground -@import './reset' - /* ::selection background $theme-color @@ -14,6 +12,9 @@ $theme-color-foreground = themeColorForeground */ * + position relative + box-sizing border-box + background-clip padding-box !important tap-highlight-color rgba($theme-color, 0.7) -webkit-tap-highlight-color rgba($theme-color, 0.7) @@ -29,6 +30,9 @@ html &, * cursor progress !important +body + overflow-wrap break-word + #error padding 32px color #fff diff --git a/src/web/app/auth/style.styl b/src/web/app/auth/style.styl index 046a5ff6ee..bd25e1b572 100644 --- a/src/web/app/auth/style.styl +++ b/src/web/app/auth/style.styl @@ -1,4 +1,5 @@ -@import "../base" +@import "../app" +@import "../reset" html background #eee diff --git a/src/web/app/ch/router.js b/src/web/app/ch/router.js new file mode 100644 index 0000000000..424158f403 --- /dev/null +++ b/src/web/app/ch/router.js @@ -0,0 +1,32 @@ +import * as riot from 'riot'; +const route = require('page'); +let page = null; + +export default me => { + route('/', index); + route('/:channel', channel); + route('*', notFound); + + function index() { + mount(document.createElement('mk-index')); + } + + function channel(ctx) { + const el = document.createElement('mk-channel'); + el.setAttribute('id', ctx.params.channel); + mount(el); + } + + function notFound() { + mount(document.createElement('mk-not-found')); + } + + // EXEC + route(); +}; + +function mount(content) { + if (page) page.unmount(); + const body = document.getElementById('app'); + page = riot.mount(body.appendChild(content))[0]; +} diff --git a/src/web/app/ch/script.js b/src/web/app/ch/script.js new file mode 100644 index 0000000000..760d405c52 --- /dev/null +++ b/src/web/app/ch/script.js @@ -0,0 +1,18 @@ +/** + * Channels + */ + +// Style +import './style.styl'; + +require('./tags'); +import init from '../init'; +import route from './router'; + +/** + * init + */ +init(me => { + // Start routing + route(me); +}); diff --git a/src/web/app/ch/style.styl b/src/web/app/ch/style.styl new file mode 100644 index 0000000000..21ca648cbe --- /dev/null +++ b/src/web/app/ch/style.styl @@ -0,0 +1,10 @@ +@import "../app" + +html + padding 8px + background #efefef + +#wait + top auto + bottom 15px + left 15px diff --git a/src/web/app/ch/tags/channel.tag b/src/web/app/ch/tags/channel.tag new file mode 100644 index 0000000000..4ae62e7b39 --- /dev/null +++ b/src/web/app/ch/tags/channel.tag @@ -0,0 +1,403 @@ +<mk-channel> + <mk-header/> + <hr> + <main if={ !fetching }> + <h1>{ channel.title }</h1> + + <div if={ SIGNIN }> + <p if={ channel.is_watching }>このチャンネルをウォッチしています <a onclick={ unwatch }>ウォッチ解除</a></p> + <p if={ !channel.is_watching }><a onclick={ watch }>このチャンネルをウォッチする</a></p> + </div> + + <div class="share"> + <mk-twitter-button/> + <mk-line-button/> + </div> + + <div class="body"> + <p if={ postsFetching }>読み込み中<mk-ellipsis/></p> + <div if={ !postsFetching }> + <p if={ posts == null || posts.length == 0 }>まだ投稿がありません</p> + <virtual if={ posts != null }> + <mk-channel-post each={ post in posts.slice().reverse() } post={ post } form={ parent.refs.form }/> + </virtual> + </div> + </div> + <hr> + <mk-channel-form if={ SIGNIN } channel={ channel } ref="form"/> + <div if={ !SIGNIN }> + <p>参加するには<a href={ CONFIG.url }>ログインまたは新規登録</a>してください</p> + </div> + <hr> + <footer> + <small><a href={ CONFIG.url }>Misskey</a> ver { version } (葵 aoi)</small> + </footer> + </main> + <style> + :scope + display block + + > main + > h1 + font-size 1.5em + color #f00 + + > .share + > * + margin-right 4px + + > .body + margin 8px 0 0 0 + + > mk-channel-form + max-width 500px + + </style> + <script> + import Progress from '../../common/scripts/loading'; + import ChannelStream from '../../common/scripts/channel-stream'; + + this.mixin('i'); + this.mixin('api'); + + this.id = this.opts.id; + this.fetching = true; + this.postsFetching = true; + this.channel = null; + this.posts = null; + this.connection = new ChannelStream(this.id); + this.version = VERSION; + this.unreadCount = 0; + + this.on('mount', () => { + document.documentElement.style.background = '#efefef'; + + Progress.start(); + + let fetched = false; + + // チャンネル概要読み込み + this.api('channels/show', { + channel_id: this.id + }).then(channel => { + if (fetched) { + Progress.done(); + } else { + Progress.set(0.5); + fetched = true; + } + + this.update({ + fetching: false, + channel: channel + }); + + document.title = channel.title + ' | Misskey' + }); + + // 投稿読み込み + this.api('channels/posts', { + channel_id: this.id + }).then(posts => { + if (fetched) { + Progress.done(); + } else { + Progress.set(0.5); + fetched = true; + } + + this.update({ + postsFetching: false, + posts: posts + }); + }); + + this.connection.on('post', this.onPost); + document.addEventListener('visibilitychange', this.onVisibilitychange, false); + }); + + this.on('unmount', () => { + this.connection.off('post', this.onPost); + this.connection.close(); + document.removeEventListener('visibilitychange', this.onVisibilitychange); + }); + + this.onPost = post => { + this.posts.unshift(post); + this.update(); + + if (document.hidden && this.SIGNIN && post.user_id !== this.I.id) { + this.unreadCount++; + document.title = `(${this.unreadCount}) ${this.channel.title} | Misskey`; + } + }; + + this.onVisibilitychange = () => { + if (!document.hidden) { + this.unreadCount = 0; + document.title = this.channel.title + ' | Misskey' + } + }; + + this.watch = () => { + this.api('channels/watch', { + channel_id: this.id + }).then(() => { + this.channel.is_watching = true; + this.update(); + }, e => { + alert('error'); + }); + }; + + this.unwatch = () => { + this.api('channels/unwatch', { + channel_id: this.id + }).then(() => { + this.channel.is_watching = false; + this.update(); + }, e => { + alert('error'); + }); + }; + </script> +</mk-channel> + +<mk-channel-post> + <header> + <a class="index" onclick={ reply }>{ post.index }:</a> + <a class="name" href={ CONFIG.url + '/' + post.user.username }><b>{ post.user.name }</b></a> + <mk-time time={ post.created_at }/> + <mk-time time={ post.created_at } mode="detail"/> + <span>ID:<i>{ post.user.username }</i></span> + </header> + <div> + <a if={ post.reply }>>>{ post.reply.index }</a> + { post.text } + <div class="media" if={ post.media }> + <virtual each={ file in post.media }> + <a href={ file.url } target="_blank"> + <img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/> + </a> + </virtual> + </div> + </div> + <style> + :scope + display block + margin 0 + padding 0 + + > header + position -webkit-sticky + position sticky + z-index 1 + top 0 + background rgba(239, 239, 239, 0.9) + + > .index + margin-right 0.25em + color #000 + + > .name + margin-right 0.5em + color #008000 + + > mk-time + margin-right 0.5em + + &:first-of-type + display none + + @media (max-width 600px) + > mk-time + &:first-of-type + display initial + + &:last-of-type + display none + + > div + padding 0 0 1em 2em + + > .media + > a + display inline-block + + > img + max-width 100% + vertical-align bottom + + </style> + <script> + this.post = this.opts.post; + this.form = this.opts.form; + + this.reply = () => { + this.form.update({ + reply: this.post + }); + }; + </script> +</mk-channel-post> + +<mk-channel-form> + <p if={ reply }><b>>>{ reply.index }</b> ({ reply.user.name }): <a onclick={ clearReply }>[x]</a></p> + <textarea ref="text" disabled={ wait } oninput={ update } onkeydown={ onkeydown } onpaste={ onpaste } placeholder="%i18n:ch.tags.mk-channel-form.textarea%"></textarea> + <div class="actions"> + <button onclick={ selectFile }><i class="fa fa-upload"></i>%i18n:ch.tags.mk-channel-form.upload%</button> + <button onclick={ drive }><i class="fa fa-cloud"></i>%i18n:ch.tags.mk-channel-form.drive%</button> + <button class={ wait: wait } ref="submit" disabled={ wait || (refs.text.value.length == 0) } onclick={ post }> + <i class="fa fa-paper-plane" if={ !wait }></i>{ wait ? '%i18n:ch.tags.mk-channel-form.posting%' : '%i18n:ch.tags.mk-channel-form.post%' }<mk-ellipsis if={ wait }/> + </button> + </div> + <mk-uploader ref="uploader"/> + <ol if={ files }> + <li each={ files }>{ name }</li> + </ol> + <input ref="file" type="file" accept="image/*" multiple="multiple" onchange={ changeFile }/> + <style> + :scope + display block + + > textarea + width 100% + max-width 100% + min-width 100% + min-height 5em + + > .actions + display flex + + > button + > i + margin-right 0.25em + + &:last-child + margin-left auto + + &.wait + cursor wait + + > input[type='file'] + display none + + </style> + <script> + import CONFIG from '../../common/scripts/config'; + + this.mixin('api'); + + this.channel = this.opts.channel; + this.files = null; + + this.on('mount', () => { + this.refs.uploader.on('uploaded', file => { + this.update({ + files: [file] + }); + }); + }); + + this.upload = file => { + this.refs.uploader.upload(file); + }; + + this.clearReply = () => { + this.update({ + reply: null + }); + }; + + this.clear = () => { + this.clearReply(); + this.update({ + files: null + }); + this.refs.text.value = ''; + }; + + this.post = () => { + this.update({ + wait: true + }); + + const files = this.files && this.files.length > 0 + ? this.files.map(f => f.id) + : undefined; + + this.api('posts/create', { + text: this.refs.text.value == '' ? undefined : this.refs.text.value, + media_ids: files, + reply_id: this.reply ? this.reply.id : undefined, + channel_id: this.channel.id + }).then(data => { + this.clear(); + }).catch(err => { + alert('失敗した'); + }).then(() => { + this.update({ + wait: false + }); + }); + }; + + this.changeFile = () => { + this.refs.file.files.forEach(this.upload); + }; + + this.selectFile = () => { + this.refs.file.click(); + }; + + this.drive = () => { + window['cb'] = files => { + this.update({ + files: files + }); + }; + + window.open(CONFIG.url + '/selectdrive?multiple=true', + 'drive_window', + 'height=500,width=800'); + }; + + this.onkeydown = e => { + if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post(); + }; + + this.onpaste = e => { + e.clipboardData.items.forEach(item => { + if (item.kind == 'file') { + this.upload(item.getAsFile()); + } + }); + }; + </script> +</mk-channel-form> + +<mk-twitter-button> + <a href="https://twitter.com/share?ref_src=twsrc%5Etfw" class="twitter-share-button" data-show-count="false">Tweet</a> + <script> + this.on('mount', () => { + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', 'https://platform.twitter.com/widgets.js'); + script.setAttribute('async', 'async'); + head.appendChild(script); + }); + </script> +</mk-twitter-button> + +<mk-line-button> + <div class="line-it-button" data-lang="ja" data-type="share-a" data-url={ CONFIG.chUrl } style="display: none;"></div> + <script> + this.on('mount', () => { + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', 'https://d.line-scdn.net/r/web/social-plugin/js/thirdparty/loader.min.js'); + script.setAttribute('async', 'async'); + head.appendChild(script); + }); + </script> +</mk-line-button> diff --git a/src/web/app/ch/tags/header.tag b/src/web/app/ch/tags/header.tag new file mode 100644 index 0000000000..5cdcbd09cc --- /dev/null +++ b/src/web/app/ch/tags/header.tag @@ -0,0 +1,20 @@ +<mk-header> + <div> + <a href={ CONFIG.chUrl }>Index</a> | <a href={ CONFIG.url }>Misskey</a> + </div> + <div> + <a if={ !SIGNIN } href={ CONFIG.url }>ログイン(新規登録)</a> + <a if={ SIGNIN } href={ CONFIG.url + '/' + I.username }>{ I.username }</a> + </div> + <style> + :scope + display flex + + > div:last-child + margin-left auto + + </style> + <script> + this.mixin('i'); + </script> +</mk-header> diff --git a/src/web/app/ch/tags/index.js b/src/web/app/ch/tags/index.js new file mode 100644 index 0000000000..12ffdaeb84 --- /dev/null +++ b/src/web/app/ch/tags/index.js @@ -0,0 +1,3 @@ +require('./index.tag'); +require('./channel.tag'); +require('./header.tag'); diff --git a/src/web/app/ch/tags/index.tag b/src/web/app/ch/tags/index.tag new file mode 100644 index 0000000000..50ccc0d91c --- /dev/null +++ b/src/web/app/ch/tags/index.tag @@ -0,0 +1,35 @@ +<mk-index> + <mk-header/> + <hr> + <button onclick={ n }>%i18n:ch.tags.mk-index.new%</button> + <hr> + <ul if={ channels }> + <li each={ channels }><a href={ '/' + this.id }>{ this.title }</a></li> + </ul> + <style> + :scope + display block + + </style> + <script> + this.mixin('api'); + + this.on('mount', () => { + this.api('channels').then(channels => { + this.update({ + channels: channels + }); + }); + }); + + this.n = () => { + const title = window.prompt('%i18n:ch.tags.mk-index.channel-title%'); + + this.api('channels/create', { + title: title + }).then(channel => { + location.href = '/' + channel.id; + }); + }; + </script> +</mk-index> diff --git a/src/web/app/common/scripts/channel-stream.js b/src/web/app/common/scripts/channel-stream.js new file mode 100644 index 0000000000..17944dbe45 --- /dev/null +++ b/src/web/app/common/scripts/channel-stream.js @@ -0,0 +1,16 @@ +'use strict'; + +import Stream from './stream'; + +/** + * Channel stream connection + */ +class Connection extends Stream { + constructor(channelId) { + super('channel', { + channel: channelId + }); + } +} + +export default Connection; diff --git a/src/web/app/common/scripts/config.js b/src/web/app/common/scripts/config.js index 75a7abba29..c5015622f0 100644 --- a/src/web/app/common/scripts/config.js +++ b/src/web/app/common/scripts/config.js @@ -6,6 +6,7 @@ const host = isRoot ? Url.host : Url.host.substring(Url.host.indexOf('.') + 1, U const scheme = Url.protocol; const url = `${scheme}//${host}`; const apiUrl = `${scheme}//api.${host}`; +const chUrl = `${scheme}//ch.${host}`; const devUrl = `${scheme}//dev.${host}`; const aboutUrl = `${scheme}//about.${host}`; const statsUrl = `${scheme}//stats.${host}`; @@ -16,6 +17,7 @@ export default { scheme, url, apiUrl, + chUrl, devUrl, aboutUrl, statsUrl, diff --git a/src/web/app/common/scripts/home-stream.js b/src/web/app/common/scripts/home-stream.js index 24f13cd291..de9ceb3b51 100644 --- a/src/web/app/common/scripts/home-stream.js +++ b/src/web/app/common/scripts/home-stream.js @@ -1,6 +1,7 @@ 'use strict'; import Stream from './stream'; +import signout from './signout'; /** * Home stream connection @@ -11,7 +12,17 @@ class Connection extends Stream { i: me.token }); + // 最終利用日時を更新するため定期的にaliveメッセージを送信 + setInterval(() => { + this.send({ type: 'alive' }); + }, 1000 * 60); + this.on('i_updated', me.update); + + this.on('my_token_regenerated', () => { + alert('%i18n:common.my-token-regenerated%'); + signout(); + }); } } diff --git a/src/web/app/common/tags/activity-table.tag b/src/web/app/common/tags/activity-table.tag index 6331e7c9c3..1d26d1788a 100644 --- a/src/web/app/common/tags/activity-table.tag +++ b/src/web/app/common/tags/activity-table.tag @@ -17,7 +17,6 @@ display block max-width 600px margin 0 auto - background #fff > svg display block diff --git a/src/web/app/common/tags/api-info.tag b/src/web/app/common/tags/api-info.tag deleted file mode 100644 index 612f20a7a8..0000000000 --- a/src/web/app/common/tags/api-info.tag +++ /dev/null @@ -1,27 +0,0 @@ -<mk-api-info> - <p>Token:<code>{ I.token }</code></p> - <p>APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。</p> - <p>アカウントを乗っ取られてしまう可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。</p> - <p>万が一このトークンが漏れたりその可能性がある場合は - <button class="regenerate" onclick={ regenerateToken }>トークンを再生成</button>できます。(副作用として、ログインしているすべてのデバイスでログアウトが発生します) - </p> - <style> - :scope - display block - color #4a535a - - code - padding 4px - background #eee - - .regenerate - display inline - color $theme-color - - &:hover - text-decoration underline - </style> - <script> - this.mixin('i'); - </script> -</mk-api-info> diff --git a/src/web/app/common/tags/error.tag b/src/web/app/common/tags/error.tag index e4e0272a49..62f4563e5c 100644 --- a/src/web/app/common/tags/error.tag +++ b/src/web/app/common/tags/error.tag @@ -1,7 +1,15 @@ <mk-error> - <img src="/assets/error.jpg" alt=""/> + <img src="data:image/jpeg;base64,%base64:/assets/error.jpg%" alt=""/> <h1>%i18n:common.tags.mk-error.title%</h1> - <p class="text">%i18n:common.tags.mk-error.description%</p> + <p class="text">{ + '%i18n:common.tags.mk-error.description%'.substr(0, '%i18n:common.tags.mk-error.description%'.indexOf('{')) + }<a onclick={ reload }>{ + '%i18n:common.tags.mk-error.description%'.match(/\{(.+?)\}/)[1] + }</a>{ + '%i18n:common.tags.mk-error.description%'.substr('%i18n:common.tags.mk-error.description%'.indexOf('}') + 1) + }</p> + <button if={ !troubleshooting } onclick={ troubleshoot }>%i18n:common.tags.mk-error.troubleshoot%</button> + <mk-troubleshooter if={ troubleshooting }/> <p class="thanks">%i18n:common.tags.mk-error.thanks%</p> <style> :scope @@ -30,6 +38,25 @@ font-size 1em color #666 + > button + display block + margin 1em auto 0 auto + padding 8px 10px + color $theme-color-foreground + background $theme-color + + &:focus + outline solid 3px rgba($theme-color, 0.3) + + &:hover + background lighten($theme-color, 10%) + + &:active + background darken($theme-color, 10%) + + > mk-troubleshooter + margin 1em auto 0 auto + > .thanks display block margin 2em auto 0 auto @@ -49,9 +76,142 @@ </style> <script> + this.troubleshooting = false; + this.on('mount', () => { document.title = 'Oops!'; document.documentElement.style.background = '#f8f8f8'; }); + + this.reload = () => { + location.reload(); + }; + + this.troubleshoot = () => { + this.update({ + troubleshooting: true + }); + }; </script> </mk-error> + +<mk-troubleshooter> + <h1><i class="fa fa-wrench"></i>%i18n:common.tags.mk-error.troubleshooter.title%</h1> + <div> + <p data-wip={ network == null }><i if={ network != null } class="fa fa-{ network ? 'check' : 'times' }"></i>{ network == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-network%' : '%i18n:common.tags.mk-error.troubleshooter.network%' }<mk-ellipsis if={ network == null }/></p> + <p if={ network == true } data-wip={ internet == null }><i if={ internet != null } class="fa fa-{ internet ? 'check' : 'times' }"></i>{ internet == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-internet%' : '%i18n:common.tags.mk-error.troubleshooter.internet%' }<mk-ellipsis if={ internet == null }/></p> + <p if={ internet == true } data-wip={ server == null }><i if={ server != null } class="fa fa-{ server ? 'check' : 'times' }"></i>{ server == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-server%' : '%i18n:common.tags.mk-error.troubleshooter.server%' }<mk-ellipsis if={ server == null }/></p> + </div> + <p if={ !end }>%i18n:common.tags.mk-error.troubleshooter.finding%<mk-ellipsis/></p> + <p if={ network === false }><b><i class="fa fa-exclamation-triangle"></i>%i18n:common.tags.mk-error.troubleshooter.no-network%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-network-desc%</p> + <p if={ internet === false }><b><i class="fa fa-exclamation-triangle"></i>%i18n:common.tags.mk-error.troubleshooter.no-internet%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-internet-desc%</p> + <p if={ server === false }><b><i class="fa fa-exclamation-triangle"></i>%i18n:common.tags.mk-error.troubleshooter.no-server%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-server-desc%</p> + <p if={ server === true } class="success"><b><i class="fa fa-info-circle"></i>%i18n:common.tags.mk-error.troubleshooter.success%</b><br>%i18n:common.tags.mk-error.troubleshooter.success-desc%</p> + + <style> + :scope + display block + width 100% + max-width 500px + text-align left + background #fff + border-radius 8px + border solid 1px #ddd + + > h1 + margin 0 + padding 0.6em 1.2em + font-size 1em + color #444 + border-bottom solid 1px #eee + + > i + margin-right 0.25em + + > div + overflow hidden + padding 0.6em 1.2em + + > p + margin 0.5em 0 + font-size 0.9em + color #444 + + &[data-wip] + color #888 + + > i + margin-right 0.25em + + &.fa-times + color #e03524 + + &.fa-check + color #84c32f + + > p + margin 0 + padding 0.6em 1.2em + font-size 1em + color #444 + border-top solid 1px #eee + + > b + > i + margin-right 0.25em + + &.success + > b + color #39adad + + &:not(.success) + > b + color #ad4339 + + </style> + <script> + import CONFIG from '../../common/scripts/config'; + + this.on('mount', () => { + this.update({ + network: navigator.onLine + }); + + if (!this.network) { + this.update({ + end: true + }); + return; + } + + // Check internet connection + fetch('https://google.com?rand=' + Math.random(), { + mode: 'no-cors' + }).then(() => { + this.update({ + internet: true + }); + + // Check misskey server is available + fetch(`${CONFIG.apiUrl}/meta`).then(() => { + this.update({ + end: true, + server: true + }); + }) + .catch(() => { + this.update({ + end: true, + server: false + }); + }); + }) + .catch(() => { + this.update({ + end: true, + internet: false + }); + }); + }); + </script> +</mk-troubleshooter> diff --git a/src/web/app/common/tags/index.js b/src/web/app/common/tags/index.js index 5dc4ef4546..35a9f4586e 100644 --- a/src/web/app/common/tags/index.js +++ b/src/web/app/common/tags/index.js @@ -14,7 +14,6 @@ require('./forkit.tag'); require('./introduction.tag'); require('./copyright.tag'); require('./signin-history.tag'); -require('./api-info.tag'); require('./twitter-setting.tag'); require('./authorized-apps.tag'); require('./poll.tag'); @@ -28,3 +27,4 @@ require('./activity-table.tag'); require('./reaction-picker.tag'); require('./reactions-viewer.tag'); require('./reaction-icon.tag'); +require('./post-menu.tag'); diff --git a/src/web/app/common/tags/post-menu.tag b/src/web/app/common/tags/post-menu.tag new file mode 100644 index 0000000000..be4468a214 --- /dev/null +++ b/src/web/app/common/tags/post-menu.tag @@ -0,0 +1,157 @@ +<mk-post-menu> + <div class="backdrop" ref="backdrop" onclick={ close }></div> + <div class="popover { compact: opts.compact }" ref="popover"> + <button if={ post.user_id === I.id } onclick={ pin }>%i18n:common.tags.mk-post-menu.pin%</button> + <div if={ I.is_pro && !post.is_category_verified }> + <select ref="categorySelect"> + <option value="">%i18n:common.tags.mk-post-menu.select%</option> + <option value="music">%i18n:common.post_categories.music%</option> + <option value="game">%i18n:common.post_categories.game%</option> + <option value="anime">%i18n:common.post_categories.anime%</option> + <option value="it">%i18n:common.post_categories.it%</option> + <option value="gadgets">%i18n:common.post_categories.gadgets%</option> + <option value="photography">%i18n:common.post_categories.photography%</option> + </select> + <button onclick={ categorize }>%i18n:common.tags.mk-post-menu.categorize%</button> + </div> + </div> + <style> + $border-color = rgba(27, 31, 35, 0.15) + + :scope + display block + position initial + + > .backdrop + position fixed + top 0 + left 0 + z-index 10000 + width 100% + height 100% + background rgba(0, 0, 0, 0.1) + opacity 0 + + > .popover + position absolute + z-index 10001 + background #fff + border 1px solid $border-color + border-radius 4px + box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) + transform scale(0.5) + opacity 0 + + $balloon-size = 16px + + &:not(.compact) + margin-top $balloon-size + transform-origin center -($balloon-size) + + &:before + content "" + display block + position absolute + top -($balloon-size * 2) + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size $border-color + + &:after + content "" + display block + position absolute + top -($balloon-size * 2) + 1.5px + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size #fff + + > button + display block + + </style> + <script> + import anime from 'animejs'; + + this.mixin('i'); + this.mixin('api'); + + this.post = this.opts.post; + this.source = this.opts.source; + + this.on('mount', () => { + const rect = this.source.getBoundingClientRect(); + const width = this.refs.popover.offsetWidth; + const height = this.refs.popover.offsetHeight; + if (this.opts.compact) { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); + this.refs.popover.style.left = (x - (width / 2)) + 'px'; + this.refs.popover.style.top = (y - (height / 2)) + 'px'; + } else { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + this.source.offsetHeight; + this.refs.popover.style.left = (x - (width / 2)) + 'px'; + this.refs.popover.style.top = y + 'px'; + } + + anime({ + targets: this.refs.backdrop, + opacity: 1, + duration: 100, + easing: 'linear' + }); + + anime({ + targets: this.refs.popover, + opacity: 1, + scale: [0.5, 1], + duration: 500 + }); + }); + + this.pin = () => { + this.api('i/pin', { + post_id: this.post.id + }).then(() => { + if (this.opts.cb) this.opts.cb('pinned', '%i18n:common.tags.mk-post-menu.pinned%'); + this.unmount(); + }); + }; + + this.categorize = () => { + const category = this.refs.categorySelect.options[this.refs.categorySelect.selectedIndex].value; + this.api('posts/categorize', { + post_id: this.post.id, + category: category + }).then(() => { + if (this.opts.cb) this.opts.cb('categorized', '%i18n:common.tags.mk-post-menu.categorized%'); + this.unmount(); + }); + }; + + this.close = () => { + this.refs.backdrop.style.pointerEvents = 'none'; + anime({ + targets: this.refs.backdrop, + opacity: 0, + duration: 200, + easing: 'linear' + }); + + this.refs.popover.style.pointerEvents = 'none'; + anime({ + targets: this.refs.popover, + opacity: 0, + scale: 0.5, + duration: 200, + easing: 'easeInBack', + complete: () => this.unmount() + }); + }; + </script> +</mk-post-menu> diff --git a/src/web/app/common/tags/signup.tag b/src/web/app/common/tags/signup.tag index 0359f4fab9..17de0347f5 100644 --- a/src/web/app/common/tags/signup.tag +++ b/src/web/app/common/tags/signup.tag @@ -3,7 +3,7 @@ <label class="username"> <p class="caption"><i class="fa fa-at"></i>%i18n:common.tags.mk-signup.username%</p> <input ref="username" type="text" pattern="^[a-zA-Z0-9-]{3,20}$" placeholder="a~z、A~Z、0~9、-" autocomplete="off" required="required" onkeyup={ onChangeUsername }/> - <p class="profile-page-url-preview" if={ refs.username.value != '' && username-state != 'invalidFormat' && username-state != 'minRange' && username-state != 'maxRange' }>{ '/' + refs.username.value }</p> + <p class="profile-page-url-preview" if={ refs.username.value != '' && username-state != 'invalidFormat' && username-state != 'minRange' && username-state != 'maxRange' }>{ CONFIG.url + '/' + refs.username.value }</p> <p class="info" if={ usernameState == 'wait' } style="color:#999"><i class="fa fa-fw fa-spinner fa-pulse"></i>%i18n:common.tags.mk-signup.checking%</p> <p class="info" if={ usernameState == 'ok' } style="color:#3CB7B5"><i class="fa fa-fw fa-check"></i>%i18n:common.tags.mk-signup.available%</p> <p class="info" if={ usernameState == 'unavailable' } style="color:#FF1161"><i class="fa fa-fw fa-exclamation-triangle"></i>%i18n:common.tags.mk-signup.unavailable%</p> diff --git a/src/web/app/desktop/router.js b/src/web/app/desktop/router.js index afa8a2dce3..977e3fa9a6 100644 --- a/src/web/app/desktop/router.js +++ b/src/web/app/desktop/router.js @@ -7,14 +7,15 @@ const route = require('page'); let page = null; export default me => { - route('/', index); - route('/i>mentions', mentions); - route('/post::post', post); - route('/search::query', search); - route('/:user', user.bind(null, 'home')); - route('/:user/graphs', user.bind(null, 'graphs')); - route('/:user/:post', post); - route('*', notFound); + route('/', index); + route('/selectdrive', selectDrive); + route('/i>mentions', mentions); + route('/post::post', post); + route('/search::query', search); + route('/:user', user.bind(null, 'home')); + route('/:user/graphs', user.bind(null, 'graphs')); + route('/:user/:post', post); + route('*', notFound); function index() { me ? home() : entrance(); @@ -54,6 +55,10 @@ export default me => { mount(el); } + function selectDrive() { + mount(document.createElement('mk-selectdrive-page')); + } + function notFound() { mount(document.createElement('mk-not-found')); } @@ -67,6 +72,7 @@ export default me => { }; function mount(content) { + document.documentElement.style.background = '#313a42'; document.documentElement.removeAttribute('data-page'); if (page) page.unmount(); const body = document.getElementById('app'); diff --git a/src/web/app/desktop/script.js b/src/web/app/desktop/script.js index 2e81147943..46a7fce700 100644 --- a/src/web/app/desktop/script.js +++ b/src/web/app/desktop/script.js @@ -11,7 +11,7 @@ import * as riot from 'riot'; import init from '../init'; import route from './router'; import fuckAdBlock from './scripts/fuck-ad-block'; -import getPostSummary from '../common/scripts/get-post-summary'; +import getPostSummary from '../../../common/get-post-summary.ts'; /** * init diff --git a/src/web/app/desktop/scripts/password-dialog.js b/src/web/app/desktop/scripts/password-dialog.js new file mode 100644 index 0000000000..2bdc93e421 --- /dev/null +++ b/src/web/app/desktop/scripts/password-dialog.js @@ -0,0 +1,11 @@ +import * as riot from 'riot'; + +export default (title, onOk, onCancel) => { + const dialog = document.body.appendChild(document.createElement('mk-input-dialog')); + return riot.mount(dialog, { + title: title, + type: 'password', + onOk: onOk, + onCancel: onCancel + }); +}; diff --git a/src/web/app/desktop/style.styl b/src/web/app/desktop/style.styl index fa50f6ce31..4597dffdb3 100644 --- a/src/web/app/desktop/style.styl +++ b/src/web/app/desktop/style.styl @@ -1,4 +1,5 @@ -@import "../base" +@import "../app" +@import "../reset" @import "../../../../node_modules/cropperjs/dist/cropper.css" *::input-placeholder @@ -39,7 +40,8 @@ background rgba(0, 0, 0, 0.2) html - background #fdfdfd + //background #2f3e42 + background #313a42 // ↓ workaround of https://github.com/riot/riot/issues/2134 &[data-page='entrance'] diff --git a/src/web/app/desktop/tags/detailed-post-window.tag b/src/web/app/desktop/tags/detailed-post-window.tag new file mode 100644 index 0000000000..04f9acf974 --- /dev/null +++ b/src/web/app/desktop/tags/detailed-post-window.tag @@ -0,0 +1,80 @@ +<mk-detailed-post-window> + <div class="bg" ref="bg" onclick={ bgClick }></div> + <div class="main" ref="main" if={ !fetching }> + <mk-post-detail ref="detail" post={ post }/> + </div> + <style> + :scope + display block + opacity 0 + + > .bg + display block + position fixed + z-index 1000 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + + > .main + display block + position fixed + z-index 1000 + top 20% + left 0 + right 0 + margin 0 auto 0 auto + padding 0 + width 638px + text-align center + + > mk-post-detail + margin 0 auto + + </style> + <script> + import anime from 'animejs'; + + this.mixin('api'); + + this.fetching = true; + this.post = null; + + this.on('mount', () => { + anime({ + targets: this.root, + opacity: 1, + duration: 100, + easing: 'linear' + }); + + this.api('posts/show', { + post_id: this.opts.post + }).then(post => { + + this.update({ + fetching: false, + post: post + }); + }); + }); + + this.close = () => { + this.refs.bg.style.pointerEvents = 'none'; + this.refs.main.style.pointerEvents = 'none'; + anime({ + targets: this.root, + opacity: 0, + duration: 300, + easing: 'linear', + complete: () => this.unmount() + }); + }; + + this.bgClick = () => { + this.close(); + }; + </script> +</mk-detailed-post-window> diff --git a/src/web/app/desktop/tags/dialog.tag b/src/web/app/desktop/tags/dialog.tag index 9905123eeb..743fd63942 100644 --- a/src/web/app/desktop/tags/dialog.tag +++ b/src/web/app/desktop/tags/dialog.tag @@ -44,6 +44,9 @@ // color #43A4EC font-weight bold + &:empty + display none + > i margin-right 0.5em diff --git a/src/web/app/desktop/tags/home-widgets/nav.tag b/src/web/app/desktop/tags/home-widgets/nav.tag index 499d66014b..54bfb87a11 100644 --- a/src/web/app/desktop/tags/home-widgets/nav.tag +++ b/src/web/app/desktop/tags/home-widgets/nav.tag @@ -1,4 +1,4 @@ -<mk-nav-home-widget><a href={ CONFIG.aboutUrl }>Misskeyについて</a><i>・</i><a href={ CONFIG.statsUrl }>統計</a><i>・</i><a href={ CONFIG.statusUrl }>ステータス</a><i>・</i><a href="http://zawazawa.jp/misskey/">Wiki</a><i>・</i><a href="https://github.com/syuilo/misskey">リポジトリ</a><i>・</i><a href={ CONFIG.devUrl }>開発者</a><i>・</i><a href="https://twitter.com/misskey_xyz" target="_blank">Follow us on <i class="fa fa-twitter"></i></a> +<mk-nav-home-widget><a href={ CONFIG.aboutUrl }>%i18n:desktop.tags.mk-nav-home-widget.about%</a><i>・</i><a href={ CONFIG.statsUrl }>%i18n:desktop.tags.mk-nav-home-widget.stats%</a><i>・</i><a href={ CONFIG.statusUrl }>%i18n:desktop.tags.mk-nav-home-widget.status%</a><i>・</i><a href="http://zawazawa.jp/misskey/">%i18n:desktop.tags.mk-nav-home-widget.wiki%</a><i>・</i><a href="https://github.com/syuilo/misskey/blob/master/DONORS.md">%i18n:desktop.tags.mk-nav-home-widget.donors%</a><i>・</i><a href="https://github.com/syuilo/misskey">%i18n:desktop.tags.mk-nav-home-widget.repository%</a><i>・</i><a href={ CONFIG.devUrl }>%i18n:desktop.tags.mk-nav-home-widget.develop%</a><i>・</i><a href="https://twitter.com/misskey_xyz" target="_blank">Follow us on <i class="fa fa-twitter"></i></a> <style> :scope display block diff --git a/src/web/app/desktop/tags/home-widgets/rss-reader.tag b/src/web/app/desktop/tags/home-widgets/rss-reader.tag index 550d7e76de..e9b740762e 100644 --- a/src/web/app/desktop/tags/home-widgets/rss-reader.tag +++ b/src/web/app/desktop/tags/home-widgets/rss-reader.tag @@ -4,7 +4,7 @@ <div class="feed" if={ !initializing }> <virtual each={ item in items }><a href={ item.link } target="_blank">{ item.title }</a></virtual> </div> - <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>読み込んでいます<mk-ellipsis/></p> + <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:common.loading%<mk-ellipsis/></p> <style> :scope display block diff --git a/src/web/app/desktop/tags/home-widgets/version.tag b/src/web/app/desktop/tags/home-widgets/version.tag index 079e4e86b8..ea5307061c 100644 --- a/src/web/app/desktop/tags/home-widgets/version.tag +++ b/src/web/app/desktop/tags/home-widgets/version.tag @@ -1,5 +1,5 @@ <mk-version-home-widget> - <p>ver{ version }</p> + <p>ver { version } (葵 aoi)</p> <style> :scope display block diff --git a/src/web/app/desktop/tags/index.js b/src/web/app/desktop/tags/index.js index 177ba41293..37fdfe37e4 100644 --- a/src/web/app/desktop/tags/index.js +++ b/src/web/app/desktop/tags/index.js @@ -16,17 +16,9 @@ require('./crop-window.tag'); require('./settings.tag'); require('./settings-window.tag'); require('./analog-clock.tag'); -require('./ui-header.tag'); -require('./ui-header-account.tag'); -require('./ui-header-notifications.tag'); -require('./ui-header-clock.tag'); -require('./ui-header-nav.tag'); -require('./ui-header-post-button.tag'); -require('./ui-header-search.tag'); require('./notifications.tag'); require('./post-form-window.tag'); require('./post-form.tag'); -require('./timeline-post.tag'); require('./post-preview.tag'); require('./repost-form-window.tag'); require('./home-widgets/user-recommendation.tag'); @@ -69,6 +61,7 @@ require('./pages/user.tag'); require('./pages/post.tag'); require('./pages/search.tag'); require('./pages/not-found.tag'); +require('./pages/selectdrive.tag'); require('./autocomplete-suggestion.tag'); require('./progress-dialog.tag'); require('./user-preview.tag'); @@ -79,7 +72,6 @@ require('./search-posts.tag'); require('./set-avatar-suggestion.tag'); require('./set-banner-suggestion.tag'); require('./repost-form.tag'); -require('./timeline-post-sub.tag'); require('./sub-post-content.tag'); require('./images-viewer.tag'); require('./image-dialog.tag'); @@ -90,4 +82,4 @@ require('./user-followers.tag'); require('./user-following-window.tag'); require('./user-followers-window.tag'); require('./list-user.tag'); -require('./ui-notification.tag'); +require('./detailed-post-window.tag'); diff --git a/src/web/app/desktop/tags/input-dialog.tag b/src/web/app/desktop/tags/input-dialog.tag index f343c4625a..78fd62ee8b 100644 --- a/src/web/app/desktop/tags/input-dialog.tag +++ b/src/web/app/desktop/tags/input-dialog.tag @@ -5,7 +5,7 @@ </yield> <yield to="content"> <div class="body"> - <input ref="text" oninput={ parent.update } onkeydown={ parent.onKeydown } placeholder={ parent.placeholder }/> + <input ref="text" type={ parent.type } oninput={ parent.onInput } onkeydown={ parent.onKeydown } placeholder={ parent.placeholder }/> </div> <div class="action"> <button class="cancel" onclick={ parent.cancel }>キャンセル</button> @@ -126,6 +126,7 @@ this.placeholder = this.opts.placeholder; this.default = this.opts.default; this.allowEmpty = this.opts.allowEmpty != null ? this.opts.allowEmpty : true; + this.type = this.opts.type ? this.opts.type : 'text'; this.on('mount', () => { this.text = this.refs.window.refs.text; @@ -156,6 +157,10 @@ this.refs.window.close(); }; + this.onInput = () => { + this.update(); + }; + this.onKeydown = e => { if (e.which == 13) { // Enter e.preventDefault(); diff --git a/src/web/app/desktop/tags/notifications.tag b/src/web/app/desktop/tags/notifications.tag index 21e4fe7fa5..a4f66105a8 100644 --- a/src/web/app/desktop/tags/notifications.tag +++ b/src/web/app/desktop/tags/notifications.tag @@ -207,7 +207,7 @@ </style> <script> - import getPostSummary from '../../common/scripts/get-post-summary'; + import getPostSummary from '../../../../common/get-post-summary.ts'; this.getPostSummary = getPostSummary; this.mixin('i'); @@ -252,6 +252,12 @@ }); this.onNotification = notification => { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.stream.send({ + type: 'read_notification', + id: notification.id + }); + this.notifications.unshift(notification); this.update(); }; diff --git a/src/web/app/desktop/tags/pages/home.tag b/src/web/app/desktop/tags/pages/home.tag index 124a2eefa3..e8ba4023de 100644 --- a/src/web/app/desktop/tags/pages/home.tag +++ b/src/web/app/desktop/tags/pages/home.tag @@ -8,7 +8,7 @@ </style> <script> import Progress from '../../../common/scripts/loading'; - import getPostSummary from '../../../common/scripts/get-post-summary'; + import getPostSummary from '../../../../../common/get-post-summary.ts'; this.mixin('i'); this.mixin('api'); diff --git a/src/web/app/desktop/tags/pages/post.tag b/src/web/app/desktop/tags/pages/post.tag index c91e98bbd4..f270b43ac2 100644 --- a/src/web/app/desktop/tags/pages/post.tag +++ b/src/web/app/desktop/tags/pages/post.tag @@ -1,7 +1,9 @@ <mk-post-page> <mk-ui ref="ui"> - <main> + <main if={ !parent.fetching }> + <a if={ parent.post.next } href={ parent.post.next }><i class="fa fa-angle-up"></i>%i18n:desktop.tags.mk-post-page.next%</a> <mk-post-detail ref="detail" post={ parent.post }/> + <a if={ parent.post.prev } href={ parent.post.prev }><i class="fa fa-angle-down"></i>%i18n:desktop.tags.mk-post-page.prev%</a> </main> </mk-ui> <style> @@ -10,6 +12,19 @@ main padding 16px + text-align center + + > a + display inline-block + + &:first-child + margin-bottom 4px + + &:last-child + margin-top 4px + + > i + margin-right 4px > mk-post-detail margin 0 auto @@ -18,16 +33,23 @@ <script> import Progress from '../../../common/scripts/loading'; - this.post = this.opts.post; + this.mixin('api'); + + this.fetching = true; + this.post = null; this.on('mount', () => { Progress.start(); - this.refs.ui.refs.detail.on('post-fetched', () => { - Progress.set(0.5); - }); + this.api('posts/show', { + post_id: this.opts.post + }).then(post => { + + this.update({ + fetching: false, + post: post + }); - this.refs.ui.refs.detail.on('loaded', () => { Progress.done(); }); }); diff --git a/src/web/app/desktop/tags/pages/selectdrive.tag b/src/web/app/desktop/tags/pages/selectdrive.tag new file mode 100644 index 0000000000..63fc588fac --- /dev/null +++ b/src/web/app/desktop/tags/pages/selectdrive.tag @@ -0,0 +1,160 @@ +<mk-selectdrive-page> + <mk-drive-browser ref="browser" multiple={ multiple }/> + <div> + <button class="upload" title="PCからドライブにファイルをアップロード" onclick={ upload }><i class="fa fa-upload"></i></button> + <button class="cancel" onclick={ close }>キャンセル</button> + <button class="ok" onclick={ ok }>決定</button> + </div> + + <style> + :scope + display block + position fixed + height 100% + background #fff + + > mk-drive-browser + height calc(100% - 72px) + + > div + position fixed + bottom 0 + left 0 + width 100% + height 72px + background lighten($theme-color, 95%) + + .upload + display inline-block + position absolute + top 8px + left 16px + cursor pointer + padding 0 + margin 8px 4px 0 0 + width 40px + height 40px + font-size 1em + color rgba($theme-color, 0.5) + background transparent + outline none + border solid 1px transparent + border-radius 4px + + &:hover + background transparent + border-color rgba($theme-color, 0.3) + + &:active + color rgba($theme-color, 0.6) + background transparent + border-color rgba($theme-color, 0.5) + box-shadow 0 2px 4px rgba(darken($theme-color, 50%), 0.15) inset + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + .ok + .cancel + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + .ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + .cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + </style> + <script> + const q = (new URL(location)).searchParams; + this.multiple = q.get('multiple') == 'true' ? true : false; + + this.on('mount', () => { + document.documentElement.style.background = '#fff'; + + this.refs.browser.on('selected', file => { + this.files = [file]; + this.ok(); + }); + + this.refs.browser.on('change-selection', files => { + this.update({ + files: files + }); + }); + }); + + this.upload = () => { + this.refs.browser.selectLocalFile(); + }; + + this.close = () => { + window.close(); + }; + + this.ok = () => { + window.opener.cb(this.multiple ? this.files : this.files[0]); + window.close(); + }; + </script> +</mk-selectdrive-page> diff --git a/src/web/app/desktop/tags/pages/user.tag b/src/web/app/desktop/tags/pages/user.tag index 864fe22735..811ca5c0fd 100644 --- a/src/web/app/desktop/tags/pages/user.tag +++ b/src/web/app/desktop/tags/pages/user.tag @@ -16,7 +16,7 @@ this.refs.ui.refs.user.on('user-fetched', user => { Progress.set(0.5); - document.title = user.name + ' | Misskey' + document.title = user.name + ' | Misskey'; }); this.refs.ui.refs.user.on('loaded', () => { diff --git a/src/web/app/desktop/tags/post-detail.tag b/src/web/app/desktop/tags/post-detail.tag index b162a4084a..ce7f81e32c 100644 --- a/src/web/app/desktop/tags/post-detail.tag +++ b/src/web/app/desktop/tags/post-detail.tag @@ -1,9 +1,6 @@ <mk-post-detail title={ title }> - <div class="fetching" if={ fetching }> - <mk-ellipsis-icon/> - </div> - <div class="main" if={ !fetching }> - <button class="read-more" if={ p.reply_to && p.reply_to.reply_to_id && context == null } title="会話をもっと読み込む" onclick={ loadContext } disabled={ contextFetching }> + <div class="main"> + <button class="read-more" if={ p.reply && p.reply.reply_id && context == null } title="会話をもっと読み込む" onclick={ loadContext } disabled={ contextFetching }> <i class="fa fa-ellipsis-v" if={ !contextFetching }></i> <i class="fa fa-spinner fa-pulse" if={ contextFetching }></i> </button> @@ -12,8 +9,8 @@ <mk-post-detail-sub post={ post }/> </virtual> </div> - <div class="reply-to" if={ p.reply_to }> - <mk-post-detail-sub post={ p.reply_to }/> + <div class="reply-to" if={ p.reply }> + <mk-post-detail-sub post={ p.reply }/> </div> <div class="repost" if={ isRepost }> <p> @@ -33,7 +30,7 @@ <header> <a class="name" href={ '/' + p.user.username } data-user-preview={ p.user.id }>{ p.user.name }</a> <span class="username">@{ p.user.username }</span> - <a class="time" href={ url }> + <a class="time" href={ '/' + p.user.username + '/' + p.id }> <mk-time time={ p.created_at }/> </a> </header> @@ -46,16 +43,18 @@ </div> <footer> <mk-reactions-viewer post={ p }/> - <button onclick={ reply } title="返信"><i class="fa fa-reply"></i> - <p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> + <button onclick={ reply } title="返信"> + <i class="fa fa-reply"></i><p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> </button> - <button onclick={ repost } title="Repost"><i class="fa fa-retweet"></i> - <p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> + <button onclick={ repost } title="Repost"> + <i class="fa fa-retweet"></i><p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> </button> - <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="リアクション"><i class="fa fa-plus"></i> - <p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> + <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="リアクション"> + <i class="fa fa-plus"></i><p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> + </button> + <button onclick={ menu } ref="menuButton"> + <i class="fa fa-ellipsis-h"></i> </button> - <button><i class="fa fa-ellipsis-h"></i></button> </footer> </article> <div class="replies"> @@ -71,13 +70,11 @@ padding 0 width 640px overflow hidden + text-align left background #fff border solid 1px rgba(0, 0, 0, 0.1) border-radius 8px - > .fetching - padding 64px 0 - > .main > .read-more @@ -262,56 +259,41 @@ this.mixin('api'); this.mixin('user-preview'); - this.fetching = true; this.contextFetching = false; this.context = null; - this.post = null; + this.post = this.opts.post; + this.isRepost = this.post.repost != null; + this.p = this.isRepost ? this.post.repost : this.post; + this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0; + this.title = dateStringify(this.p.created_at); this.on('mount', () => { - this.api('posts/show', { - post_id: this.opts.post - }).then(post => { - const isRepost = post.repost != null; - const p = isRepost ? post.repost : post; - p.reactions_count = p.reaction_counts ? Object.keys(p.reaction_counts).map(key => p.reaction_counts[key]).reduce((a, b) => a + b) : 0; + if (this.p.text) { + const tokens = this.p.ast; - this.update({ - fetching: false, - post: post, - isRepost: isRepost, - p: p, - title: dateStringify(p.created_at) + this.refs.text.innerHTML = compile(tokens); + + this.refs.text.children.forEach(e => { + if (e.tagName == 'MK-URL') riot.mount(e); }); - this.trigger('loaded'); - - if (this.p.text) { - const tokens = this.p.ast; - - this.refs.text.innerHTML = compile(tokens); - - this.refs.text.children.forEach(e => { - if (e.tagName == 'MK-URL') riot.mount(e); + // URLをプレビュー + tokens + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => { + riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), { + url: t.url }); + }); + } - // URLをプレビュー - tokens - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => { - riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), { - url: t.url - }); - }); - } - - // Get replies - this.api('posts/replies', { - post_id: this.p.id, - limit: 8 - }).then(replies => { - this.update({ - replies: replies - }); + // Get replies + this.api('posts/replies', { + post_id: this.p.id, + limit: 8 + }).then(replies => { + this.update({ + replies: replies }); }); }); @@ -335,12 +317,19 @@ }); }; + this.menu = () => { + riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), { + source: this.refs.menuButton, + post: this.p + }); + }; + this.loadContext = () => { this.contextFetching = true; // Fetch context this.api('posts/context', { - post_id: this.p.reply_to_id + post_id: this.p.reply_id }).then(context => { this.update({ contextFetching: false, diff --git a/src/web/app/desktop/tags/post-form.tag b/src/web/app/desktop/tags/post-form.tag index 6a363d67cd..5041078bee 100644 --- a/src/web/app/desktop/tags/post-form.tag +++ b/src/web/app/desktop/tags/post-form.tag @@ -475,7 +475,7 @@ this.api('posts/create', { text: this.refs.text.value == '' ? undefined : this.refs.text.value, media_ids: files, - reply_to_id: this.inReplyToPost ? this.inReplyToPost.id : undefined, + reply_id: this.inReplyToPost ? this.inReplyToPost.id : undefined, repost_id: this.repost ? this.repost.id : undefined, poll: this.poll ? this.refs.poll.get() : undefined }).then(data => { diff --git a/src/web/app/desktop/tags/settings.tag b/src/web/app/desktop/tags/settings.tag index a89cfda0e4..eabddfb432 100644 --- a/src/web/app/desktop/tags/settings.tag +++ b/src/web/app/desktop/tags/settings.tag @@ -7,7 +7,7 @@ <p class={ active: page == 'apps' } onmousedown={ setPage.bind(null, 'apps') }><i class="fa fa-fw fa-puzzle-piece"></i>アプリ</p> <p class={ active: page == 'twitter' } onmousedown={ setPage.bind(null, 'twitter') }><i class="fa fa-fw fa-twitter"></i>Twitter</p> <p class={ active: page == 'signin' } onmousedown={ setPage.bind(null, 'signin') }><i class="fa fa-fw fa-sign-in"></i>ログイン履歴</p> - <p class={ active: page == 'password' } onmousedown={ setPage.bind(null, 'password') }><i class="fa fa-fw fa-unlock-alt"></i>パスワード</p> + <p class={ active: page == 'password' } onmousedown={ setPage.bind(null, 'password') }><i class="fa fa-fw fa-unlock-alt"></i>%i18n:desktop.tags.mk-settings.password%</p> <p class={ active: page == 'api' } onmousedown={ setPage.bind(null, 'api') }><i class="fa fa-fw fa-key"></i>API</p> </div> <div class="pages"> @@ -58,6 +58,11 @@ <mk-signin-history/> </section> + <section class="password" show={ page == 'password' }> + <h1>%i18n:desktop.tags.mk-settings.password%</h1> + <mk-password-setting/> + </section> + <section class="api" show={ page == 'api' }> <h1>API</h1> <mk-api-info/> @@ -211,3 +216,71 @@ }; </script> </mk-settings> + +<mk-api-info> + <p>Token:<code>{ I.token }</code></p> + <p>APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。</p> + <p>アカウントを乗っ取られてしまう可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。</p> + <p>万が一このトークンが漏れたりその可能性がある場合は<a class="regenerate" onclick={ regenerateToken }>トークンを再生成</a>できます。(副作用として、ログインしているすべてのデバイスでログアウトが発生します)</p> + <style> + :scope + display block + color #4a535a + + code + padding 4px + background #eee + </style> + <script> + import passwordDialog from '../scripts/password-dialog'; + + this.mixin('i'); + this.mixin('api'); + + this.regenerateToken = () => { + passwordDialog('%i18n:desktop.tags.mk-api-info.regenerate-token%', password => { + this.api('i/regenerate_token', { + password: password + }); + }); + }; + </script> +</mk-api-info> + +<mk-password-setting> + <button onclick={ reset }>%i18n:desktop.tags.mk-password-setting.reset%</button> + <style> + :scope + display block + color #4a535a + </style> + <script> + import passwordDialog from '../scripts/password-dialog'; + import dialog from '../scripts/dialog'; + import notify from '../scripts/notify'; + + this.mixin('i'); + this.mixin('api'); + + this.reset = () => { + passwordDialog('%i18n:desktop.tags.mk-password-setting.enter-current-password%', currentPassword => { + passwordDialog('%i18n:desktop.tags.mk-password-setting.enter-new-password%', newPassword => { + passwordDialog('%i18n:desktop.tags.mk-password-setting.enter-new-password-again%', newPassword2 => { + if (newPassword !== newPassword2) { + dialog(null, '%i18n:desktop.tags.mk-password-setting.not-match%', [{ + text: 'OK' + }]); + return; + } + this.api('i/change_password', { + current_password: currentPassword, + new_password: newPassword + }).then(() => { + notify('%i18n:desktop.tags.mk-password-setting.changed%'); + }); + }); + }); + }); + }; + </script> +</mk-password-setting> diff --git a/src/web/app/desktop/tags/sub-post-content.tag b/src/web/app/desktop/tags/sub-post-content.tag index 02cb5251b2..c75ae2911c 100644 --- a/src/web/app/desktop/tags/sub-post-content.tag +++ b/src/web/app/desktop/tags/sub-post-content.tag @@ -1,6 +1,6 @@ <mk-sub-post-content> <div class="body"> - <a class="reply" if={ post.reply_to_id }> + <a class="reply" if={ post.reply_id }> <i class="fa fa-reply"></i> </a> <span ref="text"></span> diff --git a/src/web/app/desktop/tags/timeline-post-sub.tag b/src/web/app/desktop/tags/timeline-post-sub.tag deleted file mode 100644 index ab1e26721b..0000000000 --- a/src/web/app/desktop/tags/timeline-post-sub.tag +++ /dev/null @@ -1,107 +0,0 @@ -<mk-timeline-post-sub title={ title }> - <article> - <a class="avatar-anchor" href={ '/' + post.user.username }> - <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar" data-user-preview={ post.user_id }/> - </a> - <div class="main"> - <header> - <a class="name" href={ '/' + post.user.username } data-user-preview={ post.user_id }>{ post.user.name }</a> - <span class="username">@{ post.user.username }</span> - <a class="created-at" href={ '/' + post.user.username + '/' + post.id }> - <mk-time time={ post.created_at }/> - </a> - </header> - <div class="body"> - <mk-sub-post-content class="text" post={ post }/> - </div> - </div> - </article> - <style> - :scope - display block - margin 0 - padding 0 - font-size 0.9em - - > article - padding 16px - - &:after - content "" - display block - clear both - - &:hover - > .main > footer > button - color #888 - - > .avatar-anchor - display block - float left - margin 0 14px 0 0 - - > .avatar - display block - width 52px - height 52px - margin 0 - border-radius 8px - vertical-align bottom - - > .main - float left - width calc(100% - 66px) - - > header - display flex - margin-bottom 2px - white-space nowrap - line-height 21px - - > .name - display block - margin 0 .5em 0 0 - padding 0 - overflow hidden - color #607073 - font-size 1em - font-weight 700 - text-align left - text-decoration none - text-overflow ellipsis - - &:hover - text-decoration underline - - > .username - text-align left - margin 0 .5em 0 0 - color #d1d8da - - > .created-at - margin-left auto - color #b2b8bb - - > .body - - > .text - cursor default - margin 0 - padding 0 - font-size 1.1em - color #717171 - - pre - max-height 120px - font-size 80% - - </style> - <script> - import dateStringify from '../../common/scripts/date-stringify'; - - this.mixin('user-preview'); - - this.post = this.opts.post; - this.title = dateStringify(this.post.created_at); - </script> -</mk-timeline-post-sub> diff --git a/src/web/app/desktop/tags/timeline-post.tag b/src/web/app/desktop/tags/timeline-post.tag deleted file mode 100644 index 150b928dfd..0000000000 --- a/src/web/app/desktop/tags/timeline-post.tag +++ /dev/null @@ -1,487 +0,0 @@ -<mk-timeline-post tabindex="-1" title={ title } onkeydown={ onKeyDown }> - <div class="reply-to" if={ p.reply_to }> - <mk-timeline-post-sub post={ p.reply_to }/> - </div> - <div class="repost" if={ isRepost }> - <p> - <a class="avatar-anchor" href={ '/' + post.user.username } data-user-preview={ post.user_id }> - <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=32' } alt="avatar"/> - </a> - <i class="fa fa-retweet"></i>{'%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('{'))}<a class="name" href={ '/' + post.user.username } data-user-preview={ post.user_id }>{ post.user.name }</a>{'%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr('%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1)} - </p> - <mk-time time={ post.created_at }/> - </div> - <article> - <a class="avatar-anchor" href={ '/' + p.user.username }> - <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=64' } alt="avatar" data-user-preview={ p.user.id }/> - </a> - <div class="main"> - <header> - <a class="name" href={ '/' + p.user.username } data-user-preview={ p.user.id }>{ p.user.name }</a> - <span class="is-bot" if={ p.user.is_bot }>bot</span> - <span class="username">@{ p.user.username }</span> - <div class="info"> - <span class="app" if={ p.app }>via <b>{ p.app.name }</b></span> - <a class="created-at" href={ url }> - <mk-time time={ p.created_at }/> - </a> - </div> - </header> - <div class="body"> - <div class="text" ref="text"> - <a class="reply" if={ p.reply_to }> - <i class="fa fa-reply"></i> - </a> - <p class="dummy"></p> - <a class="quote" if={ p.repost != null }>RP:</a> - </div> - <div class="media" if={ p.media }> - <mk-images-viewer images={ p.media }/> - </div> - <mk-poll if={ p.poll } post={ p } ref="pollViewer"/> - <div class="repost" if={ p.repost }><i class="fa fa-quote-right fa-flip-horizontal"></i> - <mk-post-preview class="repost" post={ p.repost }/> - </div> - </div> - <footer> - <mk-reactions-viewer post={ p } ref="reactionsViewer"/> - <button onclick={ reply } title="%i18n:desktop.tags.mk-timeline-post.reply%"><i class="fa fa-reply"></i> - <p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> - </button> - <button onclick={ repost } title="%i18n:desktop.tags.mk-timeline-post.repost%"><i class="fa fa-retweet"></i> - <p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> - </button> - <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="%i18n:desktop.tags.mk-timeline-post.add-reaction%"><i class="fa fa-plus"></i> - <p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> - </button> - <button> - <i class="fa fa-ellipsis-h"></i> - </button> - <button onclick={ toggleDetail } title="%i18n:desktop.tags.mk-timeline-post.detail"> - <i class="fa fa-caret-down" if={ !isDetailOpened }></i> - <i class="fa fa-caret-up" if={ isDetailOpened }></i> - </button> - </footer> - </div> - </article> - <div class="detail" if={ isDetailOpened }> - <mk-post-status-graph width="462" height="130" post={ p }/> - </div> - <style> - :scope - display block - margin 0 - padding 0 - background #fff - - &:focus - z-index 1 - - &:after - content "" - pointer-events none - position absolute - top 2px - right 2px - bottom 2px - left 2px - border 2px solid rgba($theme-color, 0.3) - border-radius 4px - - > .repost - color #9dbb00 - background linear-gradient(to bottom, #edfde2 0%, #fff 100%) - - > p - margin 0 - padding 16px 32px - line-height 28px - - .avatar-anchor - display inline-block - - .avatar - vertical-align bottom - width 28px - height 28px - margin 0 8px 0 0 - border-radius 6px - - i - margin-right 4px - - .name - font-weight bold - - > mk-time - position absolute - top 16px - right 32px - font-size 0.9em - line-height 28px - - & + article - padding-top 8px - - > .reply-to - padding 0 16px - background rgba(0, 0, 0, 0.0125) - - > mk-post-preview - background transparent - - > article - padding 28px 32px 18px 32px - - &:after - content "" - display block - clear both - - &:hover - > .main > footer > button - color #888 - - > .avatar-anchor - display block - float left - margin 0 16px 10px 0 - position -webkit-sticky - position sticky - top 74px - - > .avatar - display block - width 58px - height 58px - margin 0 - border-radius 8px - vertical-align bottom - - > .main - float left - width calc(100% - 74px) - - > header - display flex - margin-bottom 4px - white-space nowrap - line-height 1.4 - - > .name - display block - margin 0 .5em 0 0 - padding 0 - overflow hidden - color #777 - font-size 1em - font-weight 700 - text-align left - text-decoration none - text-overflow ellipsis - - &:hover - text-decoration underline - - > .is-bot - text-align left - margin 0 .5em 0 0 - padding 1px 6px - font-size 12px - color #aaa - border solid 1px #ddd - border-radius 3px - - > .username - text-align left - margin 0 .5em 0 0 - color #ccc - - > .info - margin-left auto - text-align right - font-size 0.9em - - > .app - margin-right 8px - padding-right 8px - color #ccc - border-right solid 1px #eaeaea - - > .created-at - color #c0c0c0 - - > .body - - > .text - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - font-size 1.1em - color #717171 - - > .dummy - display none - - mk-url-preview - margin-top 8px - - .link - &:after - content "\f14c" - display inline-block - padding-left 2px - font-family FontAwesome - font-size .9em - font-weight 400 - font-style normal - - > .reply - margin-right 8px - color #717171 - - > .quote - margin-left 4px - font-style oblique - color #a0bf46 - - code - padding 4px 8px - margin 0 0.5em - font-size 80% - color #525252 - background #f8f8f8 - border-radius 2px - - pre > code - padding 16px - margin 0 - - [data-is-me]:after - content "you" - padding 0 4px - margin-left 4px - font-size 80% - color $theme-color-foreground - background $theme-color - border-radius 4px - - > .media - > img - display block - max-width 100% - - > mk-poll - font-size 80% - - > .repost - margin 8px 0 - - > i:first-child - position absolute - top -8px - left -8px - z-index 1 - color #c0dac6 - font-size 28px - background #fff - - > mk-post-preview - padding 16px - border dashed 1px #c0dac6 - border-radius 8px - - > footer - > button - margin 0 28px 0 0 - padding 0 8px - line-height 32px - font-size 1em - color #ddd - background transparent - border none - cursor pointer - - &:hover - color #666 - - > .count - display inline - margin 0 0 0 8px - color #999 - - &.reacted - color $theme-color - - &:last-child - position absolute - right 0 - margin 0 - - > .detail - padding-top 4px - background rgba(0, 0, 0, 0.0125) - - </style> - <script> - import compile from '../../common/scripts/text-compiler'; - import dateStringify from '../../common/scripts/date-stringify'; - - this.mixin('api'); - this.mixin('stream'); - this.mixin('user-preview'); - - this.isDetailOpened = false; - - this.set = post => { - this.post = post; - this.isRepost = this.post.repost && this.post.text == null && this.post.media_ids == null && this.post.poll == null; - this.p = this.isRepost ? this.post.repost : this.post; - this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0; - this.title = dateStringify(this.p.created_at); - this.url = `/${this.p.user.username}/${this.p.id}`; - }; - - this.set(this.opts.post); - - this.refresh = post => { - this.set(post); - this.update(); - if (this.refs.reactionsViewer) this.refs.reactionsViewer.update({ - post - }); - if (this.refs.pollViewer) this.refs.pollViewer.init(post); - }; - - this.onStreamPostUpdated = data => { - const post = data.post; - if (post.id == this.post.id) { - this.refresh(post); - } - }; - - this.onStreamConnected = () => { - this.capture(); - }; - - this.capture = withHandler => { - this.stream.send({ - type: 'capture', - id: this.post.id - }); - if (withHandler) this.stream.on('post-updated', this.onStreamPostUpdated); - }; - - this.decapture = withHandler => { - this.stream.send({ - type: 'decapture', - id: this.post.id - }); - if (withHandler) this.stream.off('post-updated', this.onStreamPostUpdated); - }; - - this.on('mount', () => { - this.capture(true); - this.stream.on('_connected_', this.onStreamConnected); - - if (this.p.text) { - const tokens = this.p.ast; - - this.refs.text.innerHTML = this.refs.text.innerHTML.replace('<p class="dummy"></p>', compile(tokens)); - - this.refs.text.children.forEach(e => { - if (e.tagName == 'MK-URL') riot.mount(e); - }); - - // URLをプレビュー - tokens - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => { - riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), { - url: t.url - }); - }); - } - }); - - this.on('unmount', () => { - this.decapture(true); - this.stream.off('_connected_', this.onStreamConnected); - }); - - this.reply = () => { - riot.mount(document.body.appendChild(document.createElement('mk-post-form-window')), { - reply: this.p - }); - }; - - this.repost = () => { - riot.mount(document.body.appendChild(document.createElement('mk-repost-form-window')), { - post: this.p - }); - }; - - this.react = () => { - riot.mount(document.body.appendChild(document.createElement('mk-reaction-picker')), { - source: this.refs.reactButton, - post: this.p - }); - }; - - this.toggleDetail = () => { - this.update({ - isDetailOpened: !this.isDetailOpened - }); - }; - - this.onKeyDown = e => { - let shouldBeCancel = true; - - switch (true) { - case e.which == 38: // [↑] - case e.which == 74: // [j] - case e.which == 9 && e.shiftKey: // [Shift] + [Tab] - focus(this.root, e => e.previousElementSibling); - break; - - case e.which == 40: // [↓] - case e.which == 75: // [k] - case e.which == 9: // [Tab] - focus(this.root, e => e.nextElementSibling); - break; - - case e.which == 81: // [q] - case e.which == 69: // [e] - this.repost(); - break; - - case e.which == 70: // [f] - case e.which == 76: // [l] - this.like(); - break; - - case e.which == 82: // [r] - this.reply(); - break; - - default: - shouldBeCancel = false; - } - - if (shouldBeCancel) e.preventDefault(); - }; - - function focus(el, fn) { - const target = fn(el); - if (target) { - if (target.hasAttribute('tabindex')) { - target.focus(); - } else { - focus(target, fn); - } - } - } - </script> -</mk-timeline-post> diff --git a/src/web/app/desktop/tags/timeline.tag b/src/web/app/desktop/tags/timeline.tag index d4cd50455c..44f3d5d8ec 100644 --- a/src/web/app/desktop/tags/timeline.tag +++ b/src/web/app/desktop/tags/timeline.tag @@ -10,16 +10,6 @@ :scope display block - > mk-timeline-post - border-bottom solid 1px #eaeaea - - &:first-child - border-top-left-radius 6px - border-top-right-radius 6px - - &:last-of-type - border-bottom none - > .date display block margin 0 @@ -90,3 +80,636 @@ </script> </mk-timeline> + +<mk-timeline-post tabindex="-1" title={ title } onkeydown={ onKeyDown } dblclick={ onDblClick }> + <div class="reply-to" if={ p.reply }> + <mk-timeline-post-sub post={ p.reply }/> + </div> + <div class="repost" if={ isRepost }> + <p> + <a class="avatar-anchor" href={ '/' + post.user.username } data-user-preview={ post.user_id }> + <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=32' } alt="avatar"/> + </a> + <i class="fa fa-retweet"></i>{'%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('{'))}<a class="name" href={ '/' + post.user.username } data-user-preview={ post.user_id }>{ post.user.name }</a>{'%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr('%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1)} + </p> + <mk-time time={ post.created_at }/> + </div> + <article> + <a class="avatar-anchor" href={ '/' + p.user.username }> + <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=64' } alt="avatar" data-user-preview={ p.user.id }/> + </a> + <div class="main"> + <header> + <a class="name" href={ '/' + p.user.username } data-user-preview={ p.user.id }>{ p.user.name }</a> + <span class="is-bot" if={ p.user.is_bot }>bot</span> + <span class="username">@{ p.user.username }</span> + <div class="info"> + <span class="app" if={ p.app }>via <b>{ p.app.name }</b></span> + <a class="created-at" href={ url }> + <mk-time time={ p.created_at }/> + </a> + </div> + </header> + <div class="body"> + <div class="text" ref="text"> + <p class="channel" if={ p.channel != null }><a href={ CONFIG.chUrl + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p> + <a class="reply" if={ p.reply }> + <i class="fa fa-reply"></i> + </a> + <p class="dummy"></p> + <a class="quote" if={ p.repost != null }>RP:</a> + </div> + <div class="media" if={ p.media }> + <mk-images-viewer images={ p.media }/> + </div> + <mk-poll if={ p.poll } post={ p } ref="pollViewer"/> + <div class="repost" if={ p.repost }><i class="fa fa-quote-right fa-flip-horizontal"></i> + <mk-post-preview class="repost" post={ p.repost }/> + </div> + </div> + <footer> + <mk-reactions-viewer post={ p } ref="reactionsViewer"/> + <button onclick={ reply } title="%i18n:desktop.tags.mk-timeline-post.reply%"> + <i class="fa fa-reply"></i><p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> + </button> + <button onclick={ repost } title="%i18n:desktop.tags.mk-timeline-post.repost%"> + <i class="fa fa-retweet"></i><p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> + </button> + <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="%i18n:desktop.tags.mk-timeline-post.add-reaction%"> + <i class="fa fa-plus"></i><p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> + </button> + <button onclick={ menu } ref="menuButton"> + <i class="fa fa-ellipsis-h"></i> + </button> + <button onclick={ toggleDetail } title="%i18n:desktop.tags.mk-timeline-post.detail"> + <i class="fa fa-caret-down" if={ !isDetailOpened }></i> + <i class="fa fa-caret-up" if={ isDetailOpened }></i> + </button> + </footer> + </div> + </article> + <div class="detail" if={ isDetailOpened }> + <mk-post-status-graph width="462" height="130" post={ p }/> + </div> + <style> + :scope + display block + margin 0 + padding 0 + background #fff + border-bottom solid 1px #eaeaea + + &:first-child + border-top-left-radius 6px + border-top-right-radius 6px + + > .repost + border-top-left-radius 6px + border-top-right-radius 6px + + &:last-of-type + border-bottom none + + &:focus + z-index 1 + + &:after + content "" + pointer-events none + position absolute + top 2px + right 2px + bottom 2px + left 2px + border 2px solid rgba($theme-color, 0.3) + border-radius 4px + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 16px 32px + line-height 28px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px + + i + margin-right 4px + + .name + font-weight bold + + > mk-time + position absolute + top 16px + right 32px + font-size 0.9em + line-height 28px + + & + article + padding-top 8px + + > .reply-to + padding 0 16px + background rgba(0, 0, 0, 0.0125) + + > mk-post-preview + background transparent + + > article + padding 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 16px 10px 0 + position -webkit-sticky + position sticky + top 74px + + > .avatar + display block + width 58px + height 58px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 74px) + + > header + display flex + margin-bottom 4px + white-space nowrap + line-height 1.4 + + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + text-overflow ellipsis + + &:hover + text-decoration underline + + > .is-bot + text-align left + margin 0 .5em 0 0 + padding 1px 6px + font-size 12px + color #aaa + border solid 1px #ddd + border-radius 3px + + > .username + text-align left + margin 0 .5em 0 0 + color #ccc + + > .info + margin-left auto + text-align right + font-size 0.9em + + > .app + margin-right 8px + padding-right 8px + color #ccc + border-right solid 1px #eaeaea + + > .created-at + color #c0c0c0 + + > .body + + > .text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.1em + color #717171 + + > .dummy + display none + + mk-url-preview + margin-top 8px + + .link + &:after + content "\f14c" + display inline-block + padding-left 2px + font-family FontAwesome + font-size .9em + font-weight 400 + font-style normal + + > .channel + margin 0 + + > .reply + margin-right 8px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + + code + padding 4px 8px + margin 0 0.5em + font-size 80% + color #525252 + background #f8f8f8 + border-radius 2px + + pre > code + padding 16px + margin 0 + + [data-is-me]:after + content "you" + padding 0 4px + margin-left 4px + font-size 80% + color $theme-color-foreground + background $theme-color + border-radius 4px + + > .media + > img + display block + max-width 100% + + > mk-poll + font-size 80% + + > .repost + margin 8px 0 + + > i:first-child + position absolute + top -8px + left -8px + z-index 1 + color #c0dac6 + font-size 28px + background #fff + + > mk-post-preview + padding 16px + border dashed 1px #c0dac6 + border-radius 8px + + > footer + > button + margin 0 28px 0 0 + padding 0 8px + line-height 32px + font-size 1em + color #ddd + background transparent + border none + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.reacted + color $theme-color + + &:last-child + position absolute + right 0 + margin 0 + + > .detail + padding-top 4px + background rgba(0, 0, 0, 0.0125) + + </style> + <script> + import compile from '../../common/scripts/text-compiler'; + import dateStringify from '../../common/scripts/date-stringify'; + + this.mixin('i'); + this.mixin('api'); + this.mixin('stream'); + this.mixin('user-preview'); + + this.isDetailOpened = false; + + this.set = post => { + this.post = post; + this.isRepost = this.post.repost && this.post.text == null && this.post.media_ids == null && this.post.poll == null; + this.p = this.isRepost ? this.post.repost : this.post; + this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0; + this.title = dateStringify(this.p.created_at); + this.url = `/${this.p.user.username}/${this.p.id}`; + }; + + this.set(this.opts.post); + + this.refresh = post => { + this.set(post); + this.update(); + if (this.refs.reactionsViewer) this.refs.reactionsViewer.update({ + post + }); + if (this.refs.pollViewer) this.refs.pollViewer.init(post); + }; + + this.onStreamPostUpdated = data => { + const post = data.post; + if (post.id == this.post.id) { + this.refresh(post); + } + }; + + this.onStreamConnected = () => { + this.capture(); + }; + + this.capture = withHandler => { + if (this.SIGNIN) { + this.stream.send({ + type: 'capture', + id: this.post.id + }); + if (withHandler) this.stream.on('post-updated', this.onStreamPostUpdated); + } + }; + + this.decapture = withHandler => { + if (this.SIGNIN) { + this.stream.send({ + type: 'decapture', + id: this.post.id + }); + if (withHandler) this.stream.off('post-updated', this.onStreamPostUpdated); + } + }; + + this.on('mount', () => { + this.capture(true); + + if (this.SIGNIN) { + this.stream.on('_connected_', this.onStreamConnected); + } + + if (this.p.text) { + const tokens = this.p.ast; + + this.refs.text.innerHTML = this.refs.text.innerHTML.replace('<p class="dummy"></p>', compile(tokens)); + + this.refs.text.children.forEach(e => { + if (e.tagName == 'MK-URL') riot.mount(e); + }); + + // URLをプレビュー + tokens + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => { + riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), { + url: t.url + }); + }); + } + }); + + this.on('unmount', () => { + this.decapture(true); + this.stream.off('_connected_', this.onStreamConnected); + }); + + this.reply = () => { + riot.mount(document.body.appendChild(document.createElement('mk-post-form-window')), { + reply: this.p + }); + }; + + this.repost = () => { + riot.mount(document.body.appendChild(document.createElement('mk-repost-form-window')), { + post: this.p + }); + }; + + this.react = () => { + riot.mount(document.body.appendChild(document.createElement('mk-reaction-picker')), { + source: this.refs.reactButton, + post: this.p + }); + }; + + this.menu = () => { + riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), { + source: this.refs.menuButton, + post: this.p + }); + }; + + this.toggleDetail = () => { + this.update({ + isDetailOpened: !this.isDetailOpened + }); + }; + + this.onKeyDown = e => { + let shouldBeCancel = true; + + switch (true) { + case e.which == 38: // [↑] + case e.which == 74: // [j] + case e.which == 9 && e.shiftKey: // [Shift] + [Tab] + focus(this.root, e => e.previousElementSibling); + break; + + case e.which == 40: // [↓] + case e.which == 75: // [k] + case e.which == 9: // [Tab] + focus(this.root, e => e.nextElementSibling); + break; + + case e.which == 81: // [q] + case e.which == 69: // [e] + this.repost(); + break; + + case e.which == 70: // [f] + case e.which == 76: // [l] + this.like(); + break; + + case e.which == 82: // [r] + this.reply(); + break; + + default: + shouldBeCancel = false; + } + + if (shouldBeCancel) e.preventDefault(); + }; + + this.onDblClick = () => { + riot.mount(document.body.appendChild(document.createElement('mk-detailed-post-window')), { + post: this.p.id + }); + }; + + function focus(el, fn) { + const target = fn(el); + if (target) { + if (target.hasAttribute('tabindex')) { + target.focus(); + } else { + focus(target, fn); + } + } + } + </script> +</mk-timeline-post> + +<mk-timeline-post-sub title={ title }> + <article> + <a class="avatar-anchor" href={ '/' + post.user.username }> + <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar" data-user-preview={ post.user_id }/> + </a> + <div class="main"> + <header> + <a class="name" href={ '/' + post.user.username } data-user-preview={ post.user_id }>{ post.user.name }</a> + <span class="username">@{ post.user.username }</span> + <a class="created-at" href={ '/' + post.user.username + '/' + post.id }> + <mk-time time={ post.created_at }/> + </a> + </header> + <div class="body"> + <mk-sub-post-content class="text" post={ post }/> + </div> + </div> + </article> + <style> + :scope + display block + margin 0 + padding 0 + font-size 0.9em + + > article + padding 16px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 14px 0 0 + + > .avatar + display block + width 52px + height 52px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 66px) + + > header + display flex + margin-bottom 2px + white-space nowrap + line-height 21px + + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + text-overflow ellipsis + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 .5em 0 0 + color #d1d8da + + > .created-at + margin-left auto + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + + pre + max-height 120px + font-size 80% + + </style> + <script> + import dateStringify from '../../common/scripts/date-stringify'; + + this.mixin('user-preview'); + + this.post = this.opts.post; + this.title = dateStringify(this.post.created_at); + </script> +</mk-timeline-post-sub> diff --git a/src/web/app/desktop/tags/ui-header-account.tag b/src/web/app/desktop/tags/ui-header-account.tag deleted file mode 100644 index 23c4fdbbf9..0000000000 --- a/src/web/app/desktop/tags/ui-header-account.tag +++ /dev/null @@ -1,214 +0,0 @@ -<mk-ui-header-account> - <button class="header" data-active={ isOpen.toString() } onclick={ toggle }> - <span class="username">{ I.username }<i class="fa fa-angle-down" if={ !isOpen }></i><i class="fa fa-angle-up" if={ isOpen }></i></span> - <img class="avatar" src={ I.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </button> - <div class="menu" if={ isOpen }> - <ul> - <li> - <a href={ '/' + I.username }><i class="fa fa-user"></i>%i18n:desktop.tags.mk-ui-header-account.profile%<i class="fa fa-angle-right"></i></a> - </li> - <li onclick={ drive }> - <p><i class="fa fa-cloud"></i>%i18n:desktop.tags.mk-ui-header-account.drive%<i class="fa fa-angle-right"></i></p> - </li> - <li> - <a href="/i>mentions"><i class="fa fa-at"></i>%i18n:desktop.tags.mk-ui-header-account.mentions%<i class="fa fa-angle-right"></i></a> - </li> - </ul> - <ul> - <li onclick={ settings }> - <p><i class="fa fa-cog"></i>%i18n:desktop.tags.mk-ui-header-account.settings%<i class="fa fa-angle-right"></i></p> - </li> - </ul> - <ul> - <li onclick={ signout }> - <p><i class="fa fa-power-off"></i>%i18n:desktop.tags.mk-ui-header-account.signout%<i class="fa fa-angle-right"></i></p> - </li> - </ul> - </div> - <style> - :scope - display block - float left - - > .header - display block - margin 0 - padding 0 - color #9eaba8 - border none - background transparent - cursor pointer - - * - pointer-events none - - &:hover - &[data-active='true'] - color darken(#9eaba8, 20%) - - > .avatar - filter saturate(150%) - - &:active - color darken(#9eaba8, 30%) - - > .username - display block - float left - margin 0 12px 0 16px - max-width 16em - line-height 48px - font-weight bold - font-family Meiryo, sans-serif - text-decoration none - - i - margin-left 8px - - > .avatar - display block - float left - min-width 32px - max-width 32px - min-height 32px - max-height 32px - margin 8px 8px 8px 0 - border-radius 4px - transition filter 100ms ease - - > .menu - display block - position absolute - top 56px - right -2px - width 230px - font-size 0.8em - background #fff - border-radius 4px - box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) - - &:before - content "" - pointer-events none - display block - position absolute - top -28px - right 12px - border-top solid 14px transparent - border-right solid 14px transparent - border-bottom solid 14px rgba(0, 0, 0, 0.1) - border-left solid 14px transparent - - &:after - content "" - pointer-events none - display block - position absolute - top -27px - right 12px - border-top solid 14px transparent - border-right solid 14px transparent - border-bottom solid 14px #fff - border-left solid 14px transparent - - ul - display block - margin 10px 0 - padding 0 - list-style none - - & + ul - padding-top 10px - border-top solid 1px #eee - - > li - display block - margin 0 - padding 0 - - > a - > p - display block - z-index 1 - padding 0 28px - margin 0 - line-height 40px - color #868C8C - cursor pointer - - * - pointer-events none - - > i:first-of-type - margin-right 6px - - > i:last-of-type - display block - position absolute - top 0 - right 8px - z-index 1 - padding 0 20px - font-size 1.2em - line-height 40px - - &:hover, &:active - text-decoration none - background $theme-color - color $theme-color-foreground - - </style> - <script> - import contains from '../../common/scripts/contains'; - import signout from '../../common/scripts/signout'; - this.signout = signout; - - this.mixin('i'); - - this.isOpen = false; - - this.on('before-unmount', () => { - this.close(); - }); - - this.toggle = () => { - this.isOpen ? this.close() : this.open(); - }; - - this.open = () => { - this.update({ - isOpen: true - }); - document.querySelectorAll('body *').forEach(el => { - el.addEventListener('mousedown', this.mousedown); - }); - }; - - this.close = () => { - this.update({ - isOpen: false - }); - document.querySelectorAll('body *').forEach(el => { - el.removeEventListener('mousedown', this.mousedown); - }); - }; - - this.mousedown = e => { - e.preventDefault(); - if (!contains(this.root, e.target) && this.root != e.target) this.close(); - return false; - }; - - this.drive = () => { - this.close(); - riot.mount(document.body.appendChild(document.createElement('mk-drive-browser-window'))); - }; - - this.settings = () => { - this.close(); - riot.mount(document.body.appendChild(document.createElement('mk-settings-window'))); - }; - - </script> -</mk-ui-header-account> diff --git a/src/web/app/desktop/tags/ui-header-clock.tag b/src/web/app/desktop/tags/ui-header-clock.tag deleted file mode 100644 index b8cb078497..0000000000 --- a/src/web/app/desktop/tags/ui-header-clock.tag +++ /dev/null @@ -1,86 +0,0 @@ -<mk-ui-header-clock> - <div class="header"> - <time ref="time"> - <span class="yyyymmdd">{ yyyy }/{ mm }/{ dd }</span> - <br> - <span class="hhnn">{ hh }<span style="visibility:{ now.getSeconds() % 2 == 0 ? 'visible' : 'hidden' }">:</span>{ nn }</span> - </time> - </div> - <div class="content"> - <mk-analog-clock/> - </div> - <style> - :scope - display inline-block - overflow visible - - > .header - padding 0 12px - text-align center - font-size 10px - - &, * - cursor: default - - &:hover - background #899492 - - & + .content - visibility visible - - > time - color #fff !important - - * - color #fff !important - - &:after - content "" - display block - clear both - - > time - display table-cell - vertical-align middle - height 48px - color #9eaba8 - - > .yyyymmdd - opacity 0.7 - - > .content - visibility hidden - display block - position absolute - top auto - right 0 - z-index 3 - margin 0 - padding 0 - width 256px - background #899492 - - </style> - <script> - this.now = new Date(); - - this.draw = () => { - const now = this.now = new Date(); - this.yyyy = now.getFullYear(); - this.mm = ('0' + (now.getMonth() + 1)).slice(-2); - this.dd = ('0' + now.getDate()).slice(-2); - this.hh = ('0' + now.getHours()).slice(-2); - this.nn = ('0' + now.getMinutes()).slice(-2); - this.update(); - }; - - this.on('mount', () => { - this.draw(); - this.clock = setInterval(this.draw, 1000); - }); - - this.on('unmount', () => { - clearInterval(this.clock); - }); - </script> -</mk-ui-header-clock> diff --git a/src/web/app/desktop/tags/ui-header-nav.tag b/src/web/app/desktop/tags/ui-header-nav.tag deleted file mode 100644 index c36ce65798..0000000000 --- a/src/web/app/desktop/tags/ui-header-nav.tag +++ /dev/null @@ -1,133 +0,0 @@ -<mk-ui-header-nav> - <ul if={ SIGNIN }> - <li class="home { active: page == 'home' }"> - <a href={ CONFIG.url }> - <i class="fa fa-home"></i> - <p>%i18n:desktop.tags.mk-ui-header-nav.home%</p> - </a> - </li> - <li class="messaging"> - <a onclick={ messaging }> - <i class="fa fa-comments"></i> - <p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p> - <i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i> - </a> - </li> - <li class="info"> - <a href="https://twitter.com/misskey_xyz" target="_blank"> - <i class="fa fa-info"></i> - <p>%i18n:desktop.tags.mk-ui-header-nav.info%</p> - </a> - </li> - </ul> - <style> - :scope - display inline-block - margin 0 - padding 0 - line-height 3rem - vertical-align top - - > ul - display inline-block - margin 0 - padding 0 - vertical-align top - line-height 3rem - list-style none - - > li - display inline-block - vertical-align top - height 48px - line-height 48px - - &.active - > a - border-bottom solid 3px $theme-color - - > a - display inline-block - z-index 1 - height 100% - padding 0 24px - font-size 13px - font-variant small-caps - color #9eaba8 - text-decoration none - transition none - cursor pointer - - * - pointer-events none - - &:hover - color darken(#9eaba8, 20%) - text-decoration none - - > i:first-child - margin-right 8px - - > i:last-child - margin-left 5px - vertical-align super - font-size 10px - color $theme-color - - @media (max-width 1100px) - margin-left -5px - - > p - display inline - margin 0 - - @media (max-width 1100px) - display none - - @media (max-width 700px) - padding 0 12px - - </style> - <script> - this.mixin('i'); - this.mixin('api'); - this.mixin('stream'); - - this.page = this.opts.page; - - this.on('mount', () => { - this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage); - - // Fetch count of unread messaging messages - this.api('messaging/unread').then(res => { - if (res.count > 0) { - this.update({ - hasUnreadMessagingMessages: true - }); - } - }); - }); - - this.on('unmount', () => { - this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage); - }); - - this.onReadAllMessagingMessages = () => { - this.update({ - hasUnreadMessagingMessages: false - }); - }; - - this.onUnreadMessagingMessage = () => { - this.update({ - hasUnreadMessagingMessages: true - }); - }; - - this.messaging = () => { - riot.mount(document.body.appendChild(document.createElement('mk-messaging-window'))); - }; - </script> -</mk-ui-header-nav> diff --git a/src/web/app/desktop/tags/ui-header-notifications.tag b/src/web/app/desktop/tags/ui-header-notifications.tag deleted file mode 100644 index 3cd8d1e3df..0000000000 --- a/src/web/app/desktop/tags/ui-header-notifications.tag +++ /dev/null @@ -1,108 +0,0 @@ -<mk-ui-header-notifications> - <button class="header" data-active={ isOpen } onclick={ toggle }><i class="fa fa-bell-o"></i></button> - <div class="notifications" if={ isOpen }> - <mk-notifications/> - </div> - <style> - :scope - display block - float left - - > .header - display block - margin 0 - padding 0 - width 32px - color #9eaba8 - border none - background transparent - cursor pointer - - * - pointer-events none - - &:hover - &[data-active='true'] - color darken(#9eaba8, 20%) - - &:active - color darken(#9eaba8, 30%) - - > i - font-size 1.2em - line-height 48px - - > .notifications - display block - position absolute - top 56px - right -72px - width 300px - background #fff - border-radius 4px - box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) - - &:before - content "" - pointer-events none - display block - position absolute - top -28px - right 74px - border-top solid 14px transparent - border-right solid 14px transparent - border-bottom solid 14px rgba(0, 0, 0, 0.1) - border-left solid 14px transparent - - &:after - content "" - pointer-events none - display block - position absolute - top -27px - right 74px - border-top solid 14px transparent - border-right solid 14px transparent - border-bottom solid 14px #fff - border-left solid 14px transparent - - > mk-notifications - max-height 350px - font-size 1rem - overflow auto - - </style> - <script> - import contains from '../../common/scripts/contains'; - - this.isOpen = false; - - this.toggle = () => { - this.isOpen ? this.close() : this.open(); - }; - - this.open = () => { - this.update({ - isOpen: true - }); - document.querySelectorAll('body *').forEach(el => { - el.addEventListener('mousedown', this.mousedown); - }); - }; - - this.close = () => { - this.update({ - isOpen: false - }); - document.querySelectorAll('body *').forEach(el => { - el.removeEventListener('mousedown', this.mousedown); - }); - }; - - this.mousedown = e => { - e.preventDefault(); - if (!contains(this.root, e.target) && this.root != e.target) this.close(); - return false; - }; - </script> -</mk-ui-header-notifications> diff --git a/src/web/app/desktop/tags/ui-header-post-button.tag b/src/web/app/desktop/tags/ui-header-post-button.tag deleted file mode 100644 index ca380b06ea..0000000000 --- a/src/web/app/desktop/tags/ui-header-post-button.tag +++ /dev/null @@ -1,42 +0,0 @@ -<mk-ui-header-post-button> - <button onclick={ post } title="新規投稿"><i class="fa fa-pencil-square-o"></i></button> - <style> - :scope - display inline-block - padding 8px - height 100% - vertical-align top - - > button - display inline-block - margin 0 - padding 0 10px - height 100% - font-size 1.2em - font-weight normal - text-decoration none - color $theme-color-foreground - background $theme-color !important - outline none - border none - border-radius 2px - transition background 0.1s ease - cursor pointer - - * - pointer-events none - - &:hover - background lighten($theme-color, 10%) !important - - &:active - background darken($theme-color, 10%) !important - transition background 0s ease - - </style> - <script> - this.post = e => { - this.parent.parent.openPostForm(); - }; - </script> -</mk-ui-header-post-button> diff --git a/src/web/app/desktop/tags/ui-header-search.tag b/src/web/app/desktop/tags/ui-header-search.tag deleted file mode 100644 index 616476f42c..0000000000 --- a/src/web/app/desktop/tags/ui-header-search.tag +++ /dev/null @@ -1,42 +0,0 @@ -<mk-ui-header-search> - <form class="search" onsubmit={ onsubmit }> - <input ref="q" type="search" placeholder=" %i18n:desktop.tags.mk-ui-header-search.placeholder%"/> - <div class="result"></div> - </form> - <style> - :scope - - > form - display block - float left - - > input - user-select text - cursor auto - margin 0 - padding 6px 18px - width 14em - height 48px - font-size 1em - line-height calc(48px - 12px) - background transparent - outline none - //border solid 1px #ddd - border none - border-radius 0 - transition color 0.5s ease, border 0.5s ease - font-family FontAwesome, sans-serif - - &::-webkit-input-placeholder - color #9eaba8 - - </style> - <script> - this.mixin('page'); - - this.onsubmit = e => { - e.preventDefault(); - this.page('/search:' + this.refs.q.value); - }; - </script> -</mk-ui-header-search> diff --git a/src/web/app/desktop/tags/ui-header.tag b/src/web/app/desktop/tags/ui-header.tag deleted file mode 100644 index fa7f2cb2ac..0000000000 --- a/src/web/app/desktop/tags/ui-header.tag +++ /dev/null @@ -1,86 +0,0 @@ -<mk-ui-header> - <mk-donation if={ SIGNIN && I.data.no_donation != 'true' }/> - <mk-special-message/> - <div class="main"> - <div class="backdrop"></div> - <div class="main"> - <div class="container"> - <div class="left"> - <mk-ui-header-nav page={ opts.page }/> - </div> - <div class="right"> - <mk-ui-header-search/> - <mk-ui-header-account if={ SIGNIN }/> - <mk-ui-header-notifications if={ SIGNIN }/> - <mk-ui-header-post-button if={ SIGNIN }/> - <mk-ui-header-clock/> - </div> - </div> - </div> - </div> - <style> - :scope - display block - position -webkit-sticky - position sticky - top 0 - z-index 1024 - width 100% - box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) - - > .main - - > .backdrop - position absolute - top 0 - z-index 1023 - width 100% - height 48px - backdrop-filter blur(12px) - //background-color rgba(255, 255, 255, 0.75) - background #fff - - &:after - content "" - display block - width 100% - height 48px - background-image url(/assets/desktop/header-logo.svg) - background-size 46px - background-position center - background-repeat no-repeat - opacity 0.3 - - > .main - z-index 1024 - margin 0 - padding 0 - background-clip content-box - font-size 0.9rem - user-select none - - > .container - width 100% - max-width 1300px - margin 0 auto - - &:after - content "" - display block - clear both - - > .left - float left - height 3rem - - > .right - float right - height 48px - - @media (max-width 1100px) - > mk-ui-header-search - display none - - </style> - <script>this.mixin('i');</script> -</mk-ui-header> diff --git a/src/web/app/desktop/tags/ui-notification.tag b/src/web/app/desktop/tags/ui-notification.tag deleted file mode 100644 index f39d766d8c..0000000000 --- a/src/web/app/desktop/tags/ui-notification.tag +++ /dev/null @@ -1,51 +0,0 @@ -<mk-ui-notification> - <p>{ opts.message }</p> - <style> - :scope - display block - position fixed - z-index 10000 - top -128px - left 0 - right 0 - margin 0 auto - padding 128px 0 0 0 - width 500px - color rgba(#000, 0.6) - background rgba(#fff, 0.9) - border-radius 0 0 8px 8px - box-shadow 0 2px 4px rgba(#000, 0.2) - transform translateY(-64px) - opacity 0 - - > p - margin 0 - line-height 64px - text-align center - - </style> - <script> - import anime from 'animejs'; - - this.on('mount', () => { - anime({ - targets: this.root, - opacity: 1, - translateY: [-64, 0], - easing: 'easeOutElastic', - duration: 500 - }); - - setTimeout(() => { - anime({ - targets: this.root, - opacity: 0, - translateY: -64, - duration: 500, - easing: 'easeInElastic', - complete: () => this.unmount() - }); - }, 6000); - }); - </script> -</mk-ui-notification> diff --git a/src/web/app/desktop/tags/ui.tag b/src/web/app/desktop/tags/ui.tag index 788fb56131..3123c34f4f 100644 --- a/src/web/app/desktop/tags/ui.tag +++ b/src/web/app/desktop/tags/ui.tag @@ -5,7 +5,7 @@ <div class="content"> <yield /> </div> - <mk-stream-indicator/> + <mk-stream-indicator if={ SIGNIN }/> <style> :scope display block @@ -35,3 +35,785 @@ }; </script> </mk-ui> + +<mk-ui-header> + <mk-donation if={ SIGNIN && I.data.no_donation != 'true' }/> + <mk-special-message/> + <div class="main"> + <div class="backdrop"></div> + <div class="main"> + <div class="container"> + <div class="left"> + <mk-ui-header-nav page={ opts.page }/> + </div> + <div class="right"> + <mk-ui-header-search/> + <mk-ui-header-account if={ SIGNIN }/> + <mk-ui-header-notifications if={ SIGNIN }/> + <mk-ui-header-post-button if={ SIGNIN }/> + <mk-ui-header-clock/> + </div> + </div> + </div> + </div> + <style> + :scope + display block + position -webkit-sticky + position sticky + top 0 + z-index 1024 + width 100% + box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + + > .main + + > .backdrop + position absolute + top 0 + z-index 1023 + width 100% + height 48px + backdrop-filter blur(12px) + //background-color rgba(255, 255, 255, 0.75) + background #1d2429 + + &:after + content "" + display block + width 100% + height 48px + background-image url(/assets/desktop/header-logo.svg) + background-size 46px + background-position center + background-repeat no-repeat + opacity 0.3 + + > .main + z-index 1024 + margin 0 + padding 0 + background-clip content-box + font-size 0.9rem + user-select none + + > .container + width 100% + max-width 1300px + margin 0 auto + + &:after + content "" + display block + clear both + + > .left + float left + height 3rem + + > .right + float right + height 48px + + @media (max-width 1100px) + > mk-ui-header-search + display none + + </style> + <script>this.mixin('i');</script> +</mk-ui-header> + +<mk-ui-header-search> + <form class="search" onsubmit={ onsubmit }> + <input ref="q" type="search" placeholder=" %i18n:desktop.tags.mk-ui-header-search.placeholder%"/> + <div class="result"></div> + </form> + <style> + :scope + + > form + display block + float left + + > input + user-select text + cursor auto + margin 0 + padding 6px 18px + width 14em + height 48px + font-size 1em + line-height calc(48px - 12px) + background transparent + outline none + //border solid 1px #ddd + border none + border-radius 0 + transition color 0.5s ease, border 0.5s ease + font-family FontAwesome, sans-serif + + &::-webkit-input-placeholder + color #9eaba8 + + </style> + <script> + this.mixin('page'); + + this.onsubmit = e => { + e.preventDefault(); + this.page('/search:' + this.refs.q.value); + }; + </script> +</mk-ui-header-search> + +<mk-ui-header-post-button> + <button onclick={ post } title="新規投稿"><i class="fa fa-pencil-square-o"></i></button> + <style> + :scope + display inline-block + padding 8px + height 100% + vertical-align top + + > button + display inline-block + margin 0 + padding 0 10px + height 100% + font-size 1.2em + font-weight normal + text-decoration none + color $theme-color-foreground + background $theme-color !important + outline none + border none + border-radius 2px + transition background 0.1s ease + cursor pointer + + * + pointer-events none + + &:hover + background lighten($theme-color, 10%) !important + + &:active + background darken($theme-color, 10%) !important + transition background 0s ease + + </style> + <script> + this.post = e => { + this.parent.parent.openPostForm(); + }; + </script> +</mk-ui-header-post-button> + +<mk-ui-header-notifications> + <button class="header" data-active={ isOpen } onclick={ toggle }><i class="fa fa-bell-o"></i></button> + <div class="notifications" if={ isOpen }> + <mk-notifications/> + </div> + <style> + :scope + display block + float left + + > .header + display block + margin 0 + padding 0 + width 32px + color #9eaba8 + border none + background transparent + cursor pointer + + * + pointer-events none + + &:hover + &[data-active='true'] + color darken(#9eaba8, 20%) + + &:active + color darken(#9eaba8, 30%) + + > i + font-size 1.2em + line-height 48px + + > .notifications + display block + position absolute + top 56px + right -72px + width 300px + background #fff + border-radius 4px + box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + + &:before + content "" + pointer-events none + display block + position absolute + top -28px + right 74px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px rgba(0, 0, 0, 0.1) + border-left solid 14px transparent + + &:after + content "" + pointer-events none + display block + position absolute + top -27px + right 74px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px #fff + border-left solid 14px transparent + + > mk-notifications + max-height 350px + font-size 1rem + overflow auto + + </style> + <script> + import contains from '../../common/scripts/contains'; + + this.isOpen = false; + + this.toggle = () => { + this.isOpen ? this.close() : this.open(); + }; + + this.open = () => { + this.update({ + isOpen: true + }); + document.querySelectorAll('body *').forEach(el => { + el.addEventListener('mousedown', this.mousedown); + }); + }; + + this.close = () => { + this.update({ + isOpen: false + }); + document.querySelectorAll('body *').forEach(el => { + el.removeEventListener('mousedown', this.mousedown); + }); + }; + + this.mousedown = e => { + e.preventDefault(); + if (!contains(this.root, e.target) && this.root != e.target) this.close(); + return false; + }; + </script> +</mk-ui-header-notifications> + +<mk-ui-header-nav> + <ul> + <virtual if={ SIGNIN }> + <li class="home { active: page == 'home' }"> + <a href={ CONFIG.url }> + <i class="fa fa-home"></i> + <p>%i18n:desktop.tags.mk-ui-header-nav.home%</p> + </a> + </li> + <li class="messaging"> + <a onclick={ messaging }> + <i class="fa fa-comments"></i> + <p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p> + <i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i> + </a> + </li> + </virtual> + <li class="ch"> + <a href={ CONFIG.chUrl } target="_blank"> + <i class="fa fa-television"></i> + <p>%i18n:desktop.tags.mk-ui-header-nav.ch%</p> + </a> + </li> + <li class="info"> + <a href="https://twitter.com/misskey_xyz" target="_blank"> + <i class="fa fa-info"></i> + <p>%i18n:desktop.tags.mk-ui-header-nav.info%</p> + </a> + </li> + </ul> + <style> + :scope + display inline-block + margin 0 + padding 0 + line-height 3rem + vertical-align top + + > ul + display inline-block + margin 0 + padding 0 + vertical-align top + line-height 3rem + list-style none + + > li + display inline-block + vertical-align top + height 48px + line-height 48px + + &.active + > a + border-bottom solid 3px $theme-color + + > a + display inline-block + z-index 1 + height 100% + padding 0 24px + font-size 13px + font-variant small-caps + color #9eaba8 + text-decoration none + transition none + cursor pointer + + * + pointer-events none + + &:hover + color darken(#9eaba8, 20%) + text-decoration none + + > i:first-child + margin-right 8px + + > i:last-child + margin-left 5px + vertical-align super + font-size 10px + color $theme-color + + @media (max-width 1100px) + margin-left -5px + + > p + display inline + margin 0 + + @media (max-width 1100px) + display none + + @media (max-width 700px) + padding 0 12px + + </style> + <script> + this.mixin('i'); + this.mixin('api'); + this.mixin('stream'); + + this.page = this.opts.page; + + this.on('mount', () => { + if (this.SIGNIN) { + this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage); + + // Fetch count of unread messaging messages + this.api('messaging/unread').then(res => { + if (res.count > 0) { + this.update({ + hasUnreadMessagingMessages: true + }); + } + }); + } + }); + + this.on('unmount', () => { + if (this.SIGNIN) { + this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage); + } + }); + + this.onReadAllMessagingMessages = () => { + this.update({ + hasUnreadMessagingMessages: false + }); + }; + + this.onUnreadMessagingMessage = () => { + this.update({ + hasUnreadMessagingMessages: true + }); + }; + + this.messaging = () => { + riot.mount(document.body.appendChild(document.createElement('mk-messaging-window'))); + }; + </script> +</mk-ui-header-nav> + +<mk-ui-header-clock> + <div class="header"> + <time ref="time"> + <span class="yyyymmdd">{ yyyy }/{ mm }/{ dd }</span> + <br> + <span class="hhnn">{ hh }<span style="visibility:{ now.getSeconds() % 2 == 0 ? 'visible' : 'hidden' }">:</span>{ nn }</span> + </time> + </div> + <div class="content"> + <mk-analog-clock/> + </div> + <style> + :scope + display inline-block + overflow visible + + > .header + padding 0 12px + text-align center + font-size 10px + + &, * + cursor: default + + &:hover + background #899492 + + & + .content + visibility visible + + > time + color #fff !important + + * + color #fff !important + + &:after + content "" + display block + clear both + + > time + display table-cell + vertical-align middle + height 48px + color #9eaba8 + + > .yyyymmdd + opacity 0.7 + + > .content + visibility hidden + display block + position absolute + top auto + right 0 + z-index 3 + margin 0 + padding 0 + width 256px + background #899492 + + </style> + <script> + this.now = new Date(); + + this.draw = () => { + const now = this.now = new Date(); + this.yyyy = now.getFullYear(); + this.mm = ('0' + (now.getMonth() + 1)).slice(-2); + this.dd = ('0' + now.getDate()).slice(-2); + this.hh = ('0' + now.getHours()).slice(-2); + this.nn = ('0' + now.getMinutes()).slice(-2); + this.update(); + }; + + this.on('mount', () => { + this.draw(); + this.clock = setInterval(this.draw, 1000); + }); + + this.on('unmount', () => { + clearInterval(this.clock); + }); + </script> +</mk-ui-header-clock> + +<mk-ui-header-account> + <button class="header" data-active={ isOpen.toString() } onclick={ toggle }> + <span class="username">{ I.username }<i class="fa fa-angle-down" if={ !isOpen }></i><i class="fa fa-angle-up" if={ isOpen }></i></span> + <img class="avatar" src={ I.avatar_url + '?thumbnail&size=64' } alt="avatar"/> + </button> + <div class="menu" if={ isOpen }> + <ul> + <li> + <a href={ '/' + I.username }><i class="fa fa-user"></i>%i18n:desktop.tags.mk-ui-header-account.profile%<i class="fa fa-angle-right"></i></a> + </li> + <li onclick={ drive }> + <p><i class="fa fa-cloud"></i>%i18n:desktop.tags.mk-ui-header-account.drive%<i class="fa fa-angle-right"></i></p> + </li> + <li> + <a href="/i>mentions"><i class="fa fa-at"></i>%i18n:desktop.tags.mk-ui-header-account.mentions%<i class="fa fa-angle-right"></i></a> + </li> + </ul> + <ul> + <li onclick={ settings }> + <p><i class="fa fa-cog"></i>%i18n:desktop.tags.mk-ui-header-account.settings%<i class="fa fa-angle-right"></i></p> + </li> + </ul> + <ul> + <li onclick={ signout }> + <p><i class="fa fa-power-off"></i>%i18n:desktop.tags.mk-ui-header-account.signout%<i class="fa fa-angle-right"></i></p> + </li> + </ul> + </div> + <style> + :scope + display block + float left + + > .header + display block + margin 0 + padding 0 + color #9eaba8 + border none + background transparent + cursor pointer + + * + pointer-events none + + &:hover + &[data-active='true'] + color darken(#9eaba8, 20%) + + > .avatar + filter saturate(150%) + + &:active + color darken(#9eaba8, 30%) + + > .username + display block + float left + margin 0 12px 0 16px + max-width 16em + line-height 48px + font-weight bold + font-family Meiryo, sans-serif + text-decoration none + + i + margin-left 8px + + > .avatar + display block + float left + min-width 32px + max-width 32px + min-height 32px + max-height 32px + margin 8px 8px 8px 0 + border-radius 4px + transition filter 100ms ease + + > .menu + display block + position absolute + top 56px + right -2px + width 230px + font-size 0.8em + background #fff + border-radius 4px + box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + + &:before + content "" + pointer-events none + display block + position absolute + top -28px + right 12px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px rgba(0, 0, 0, 0.1) + border-left solid 14px transparent + + &:after + content "" + pointer-events none + display block + position absolute + top -27px + right 12px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px #fff + border-left solid 14px transparent + + ul + display block + margin 10px 0 + padding 0 + list-style none + + & + ul + padding-top 10px + border-top solid 1px #eee + + > li + display block + margin 0 + padding 0 + + > a + > p + display block + z-index 1 + padding 0 28px + margin 0 + line-height 40px + color #868C8C + cursor pointer + + * + pointer-events none + + > i:first-of-type + margin-right 6px + + > i:last-of-type + display block + position absolute + top 0 + right 8px + z-index 1 + padding 0 20px + font-size 1.2em + line-height 40px + + &:hover, &:active + text-decoration none + background $theme-color + color $theme-color-foreground + + </style> + <script> + import contains from '../../common/scripts/contains'; + import signout from '../../common/scripts/signout'; + this.signout = signout; + + this.mixin('i'); + + this.isOpen = false; + + this.on('before-unmount', () => { + this.close(); + }); + + this.toggle = () => { + this.isOpen ? this.close() : this.open(); + }; + + this.open = () => { + this.update({ + isOpen: true + }); + document.querySelectorAll('body *').forEach(el => { + el.addEventListener('mousedown', this.mousedown); + }); + }; + + this.close = () => { + this.update({ + isOpen: false + }); + document.querySelectorAll('body *').forEach(el => { + el.removeEventListener('mousedown', this.mousedown); + }); + }; + + this.mousedown = e => { + e.preventDefault(); + if (!contains(this.root, e.target) && this.root != e.target) this.close(); + return false; + }; + + this.drive = () => { + this.close(); + riot.mount(document.body.appendChild(document.createElement('mk-drive-browser-window'))); + }; + + this.settings = () => { + this.close(); + riot.mount(document.body.appendChild(document.createElement('mk-settings-window'))); + }; + + </script> +</mk-ui-header-account> + +<mk-ui-notification> + <p>{ opts.message }</p> + <style> + :scope + display block + position fixed + z-index 10000 + top -128px + left 0 + right 0 + margin 0 auto + padding 128px 0 0 0 + width 500px + color rgba(#000, 0.6) + background rgba(#fff, 0.9) + border-radius 0 0 8px 8px + box-shadow 0 2px 4px rgba(#000, 0.2) + transform translateY(-64px) + opacity 0 + + > p + margin 0 + line-height 64px + text-align center + + </style> + <script> + import anime from 'animejs'; + + this.on('mount', () => { + anime({ + targets: this.root, + opacity: 1, + translateY: [-64, 0], + easing: 'easeOutElastic', + duration: 500 + }); + + setTimeout(() => { + anime({ + targets: this.root, + opacity: 0, + translateY: -64, + duration: 500, + easing: 'easeInElastic', + complete: () => this.unmount() + }); + }, 6000); + }); + </script> +</mk-ui-notification> diff --git a/src/web/app/dev/style.styl b/src/web/app/dev/style.styl index 4fd537709d..cdbcb0e261 100644 --- a/src/web/app/dev/style.styl +++ b/src/web/app/dev/style.styl @@ -1,4 +1,5 @@ -@import "../base" +@import "../app" +@import "../reset" html background-color #fff diff --git a/src/web/app/init.js b/src/web/app/init.js index 44391b8fcb..5a6899ed4f 100644 --- a/src/web/app/init.js +++ b/src/web/app/init.js @@ -2,7 +2,7 @@ * App initializer */ -"use strict"; +'use strict'; import * as riot from 'riot'; import api from './common/scripts/api'; @@ -19,7 +19,20 @@ require('./common/tags'); * APP ENTRY POINT! */ -console.info(`Misskey v${VERSION}`); +console.info(`Misskey v${VERSION} (葵 aoi)`); + +{ // Set lang attr + const html = document.documentElement; + html.setAttribute('lang', LANG); +} + +{ // Set description meta tag + const head = document.getElementsByTagName('head')[0]; + const meta = document.createElement('meta'); + meta.setAttribute('name', 'description'); + meta.setAttribute('content', '%i18n:common.misskey%'); + head.appendChild(meta); +} document.domain = CONFIG.host; diff --git a/src/web/app/mobile/router.js b/src/web/app/mobile/router.js index d0b45d9614..01eb3c8145 100644 --- a/src/web/app/mobile/router.js +++ b/src/web/app/mobile/router.js @@ -8,6 +8,7 @@ let page = null; export default me => { route('/', index); + route('/selectdrive', selectDrive); route('/i/notifications', notifications); route('/i/messaging', messaging); route('/i/messaging/:username', messaging); @@ -15,6 +16,7 @@ export default me => { route('/i/drive/folder/:folder', drive); route('/i/drive/file/:file', drive); route('/i/settings', settings); + route('/i/settings/profile', settingsProfile); route('/i/settings/signin-history', settingsSignin); route('/i/settings/api', settingsApi); route('/i/settings/twitter', settingsTwitter); @@ -22,7 +24,7 @@ export default me => { route('/post/new', newPost); route('/post::post', post); route('/search::query', search); - route('/:user', user.bind(null, 'posts')); + route('/:user', user.bind(null, 'overview')); route('/:user/graphs', user.bind(null, 'graphs')); route('/:user/followers', userFollowers); route('/:user/following', userFollowing); @@ -63,6 +65,10 @@ export default me => { mount(document.createElement('mk-settings-page')); } + function settingsProfile() { + mount(document.createElement('mk-profile-setting-page')); + } + function settingsSignin() { mount(document.createElement('mk-signin-history-page')); } @@ -117,6 +123,10 @@ export default me => { mount(el); } + function selectDrive() { + mount(document.createElement('mk-selectdrive-page')); + } + function notFound() { mount(document.createElement('mk-not-found')); } @@ -130,6 +140,7 @@ export default me => { }; function mount(content) { + document.documentElement.style.background = '#fff'; if (page) page.unmount(); const body = document.getElementById('app'); page = riot.mount(body.appendChild(content))[0]; diff --git a/src/web/app/mobile/style.styl b/src/web/app/mobile/style.styl index bd6965e402..63e4f2349f 100644 --- a/src/web/app/mobile/style.styl +++ b/src/web/app/mobile/style.styl @@ -1,4 +1,5 @@ -@import "../base" +@import "../app" +@import "../reset" #wait top auto diff --git a/src/web/app/mobile/tags/drive-selector.tag b/src/web/app/mobile/tags/drive-selector.tag index 32845432f2..2edae67c1b 100644 --- a/src/web/app/mobile/tags/drive-selector.tag +++ b/src/web/app/mobile/tags/drive-selector.tag @@ -3,7 +3,7 @@ <header> <h1>%i18n:mobile.tags.mk-drive-selector.select-file%<span class="count" if={ files.length > 0 }>({ files.length })</span></h1> <button class="close" onclick={ cancel }><i class="fa fa-times"></i></button> - <button class="ok" onclick={ ok }><i class="fa fa-check"></i></button> + <button if={ opts.multiple } class="ok" onclick={ ok }><i class="fa fa-check"></i></button> </header> <mk-drive ref="browser" select-file={ true } multiple={ opts.multiple }/> </div> @@ -68,6 +68,11 @@ files: files }); }); + + this.refs.browser.on('selected', file => { + this.trigger('selected', file); + this.unmount(); + }); }); this.cancel = () => { diff --git a/src/web/app/mobile/tags/drive.tag b/src/web/app/mobile/tags/drive.tag index e19325091d..6929c50ab1 100644 --- a/src/web/app/mobile/tags/drive.tag +++ b/src/web/app/mobile/tags/drive.tag @@ -1,5 +1,5 @@ <mk-drive> - <nav> + <nav ref="nav"> <p onclick={ goRoot }><i class="fa fa-cloud"></i>%i18n:mobile.tags.mk-drive.drive%</p> <virtual each={ folder in hierarchyFolders }> <span><i class="fa fa-angle-right"></i></span> @@ -56,10 +56,6 @@ display block background #fff - &[data-is-naked] - > nav - top 48px - > nav display block position sticky @@ -190,7 +186,7 @@ this.file = null; this.isFileSelectMode = this.opts.selectFile; - this.multiple =this.opts.multiple; + this.multiple = this.opts.multiple; this.on('mount', () => { this.stream.on('drive_file_created', this.onStreamDriveFileCreated); @@ -205,6 +201,10 @@ } else { this.fetch(); } + + if (this.opts.isNaked) { + this.refs.nav.style.top = `${this.opts.top}px`; + } }); this.on('unmount', () => { @@ -435,13 +435,17 @@ this.chooseFile = file => { if (this.isFileSelectMode) { - if (this.selectedFiles.some(f => f.id == file.id)) { - this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id); + if (this.multiple) { + if (this.selectedFiles.some(f => f.id == file.id)) { + this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id); + } else { + this.selectedFiles.push(file); + } + this.update(); + this.trigger('change-selection', this.selectedFiles); } else { - this.selectedFiles.push(file); + this.trigger('selected', file); } - this.update(); - this.trigger('change-selection', this.selectedFiles); } else { this.cf(file); } @@ -479,7 +483,7 @@ if (fn == null || fn == '') return; switch (fn) { case '1': - this.refs.file.click(); + this.selectLocalFile(); break; case '2': this.urlUpload(); @@ -499,6 +503,10 @@ } }; + this.selectLocalFile = () => { + this.refs.file.click(); + }; + this.createFolder = () => { const name = window.prompt('フォルダー名'); if (name == null || name == '') return; diff --git a/src/web/app/mobile/tags/home-timeline.tag b/src/web/app/mobile/tags/home-timeline.tag index 5d5399f322..051158597d 100644 --- a/src/web/app/mobile/tags/home-timeline.tag +++ b/src/web/app/mobile/tags/home-timeline.tag @@ -6,7 +6,7 @@ display block > mk-init-following - border-bottom solid 1px #eee + margin-bottom 8px </style> <script> @@ -23,6 +23,12 @@ }); }); + this.fetch = () => { + this.api('posts/timeline').then(posts => { + this.refs.timeline.setPosts(posts); + }); + }; + this.on('mount', () => { this.stream.on('post', this.onStreamPost); this.stream.on('follow', this.onStreamFollow); diff --git a/src/web/app/mobile/tags/home.tag b/src/web/app/mobile/tags/home.tag index 48b5a67c38..d92e3ae4e5 100644 --- a/src/web/app/mobile/tags/home.tag +++ b/src/web/app/mobile/tags/home.tag @@ -7,6 +7,7 @@ > mk-home-timeline max-width 600px margin 0 auto + padding 8px @media (min-width 500px) padding 16px diff --git a/src/web/app/mobile/tags/index.js b/src/web/app/mobile/tags/index.js index 02d1541fcd..19952c20cd 100644 --- a/src/web/app/mobile/tags/index.js +++ b/src/web/app/mobile/tags/index.js @@ -1,6 +1,4 @@ require('./ui.tag'); -require('./ui-header.tag'); -require('./ui-nav.tag'); require('./page/entrance.tag'); require('./page/entrance/signin.tag'); require('./page/entrance/signup.tag'); @@ -14,17 +12,17 @@ require('./page/post.tag'); require('./page/new-post.tag'); require('./page/search.tag'); require('./page/settings.tag'); +require('./page/settings/profile.tag'); require('./page/settings/signin.tag'); require('./page/settings/api.tag'); require('./page/settings/authorized-apps.tag'); require('./page/settings/twitter.tag'); require('./page/messaging.tag'); require('./page/messaging-room.tag'); +require('./page/selectdrive.tag'); require('./home.tag'); require('./home-timeline.tag'); require('./timeline.tag'); -require('./timeline-post.tag'); -require('./timeline-post-sub.tag'); require('./post-preview.tag'); require('./sub-post-content.tag'); require('./images-viewer.tag'); @@ -50,3 +48,4 @@ require('./users-list.tag'); require('./user-following.tag'); require('./user-followers.tag'); require('./init-following.tag'); +require('./user-card.tag'); diff --git a/src/web/app/mobile/tags/init-following.tag b/src/web/app/mobile/tags/init-following.tag index 0c54d3a6a1..6357f86a29 100644 --- a/src/web/app/mobile/tags/init-following.tag +++ b/src/web/app/mobile/tags/init-following.tag @@ -1,12 +1,9 @@ <mk-init-following> <p class="title">気になるユーザーをフォロー:</p> <div class="users" if={ !fetching && users.length > 0 }> - <div class="user" each={ users }><a class="avatar-anchor" href={ '/' + username }><img class="avatar" src={ avatar_url + '?thumbnail&size=42' } alt=""/></a> - <div class="body"><a class="name" href={ '/' + username } target="_blank">{ name }</a> - <p class="username">@{ username }</p> - </div> - <mk-follow-button user={ this }/> - </div> + <virtual each={ users }> + <mk-user-card user={ this } /> + </virtual> </div> <p class="empty" if={ !fetching && users.length == 0 }>おすすめのユーザーは見つかりませんでした。</p> <p class="fetching" if={ fetching }><i class="fa fa-spinner fa-pulse fa-fw"></i>読み込んでいます<mk-ellipsis/></p> @@ -15,63 +12,27 @@ <style> :scope display block - padding 16px + background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) > .title - margin 0 0 12px 0 + margin 0 + padding 8px 16px font-size 1em font-weight bold color #888 > .users - &:after - content "" - display block - clear both + overflow-x scroll + -webkit-overflow-scrolling touch + white-space nowrap + padding 16px + background #eee - > .user - padding 16px - width 238px - float left - - &:after - content "" - display block - clear both - - > .avatar-anchor - display block - float left - margin 0 12px 0 0 - - > .avatar - display block - width 42px - height 42px - margin 0 - border-radius 8px - vertical-align bottom - - > .body - float left - width calc(100% - 54px) - - > .name - margin 0 - font-size 16px - line-height 24px - color #555 - - > .username - margin 0 - font-size 15px - line-height 16px - color #ccc - - > mk-follow-button - position absolute - top 16px - right 16px + > mk-user-card + &:not(:last-child) + margin-right 16px > .empty margin 0 @@ -90,7 +51,8 @@ > .refresh display block - margin 0 8px 0 0 + margin 0 + padding 8px 16px text-align right font-size 0.9em color #999 @@ -117,7 +79,7 @@ color #222 > i - padding 14px + padding 10px </style> <script> diff --git a/src/web/app/mobile/tags/notification-preview.tag b/src/web/app/mobile/tags/notification-preview.tag index 077ae78463..1fdcc57641 100644 --- a/src/web/app/mobile/tags/notification-preview.tag +++ b/src/web/app/mobile/tags/notification-preview.tag @@ -110,7 +110,7 @@ </style> <script> - import getPostSummary from '../../common/scripts/get-post-summary'; + import getPostSummary from '../../../../common/get-post-summary.ts'; this.getPostSummary = getPostSummary; this.notification = this.opts.notification; </script> diff --git a/src/web/app/mobile/tags/notification.tag b/src/web/app/mobile/tags/notification.tag index 3663709525..53222b9dbe 100644 --- a/src/web/app/mobile/tags/notification.tag +++ b/src/web/app/mobile/tags/notification.tag @@ -163,7 +163,7 @@ </style> <script> - import getPostSummary from '../../common/scripts/get-post-summary'; + import getPostSummary from '../../../../common/get-post-summary.ts'; this.getPostSummary = getPostSummary; this.notification = this.opts.notification; </script> diff --git a/src/web/app/mobile/tags/notifications.tag b/src/web/app/mobile/tags/notifications.tag index 21a941e630..2e95990314 100644 --- a/src/web/app/mobile/tags/notifications.tag +++ b/src/web/app/mobile/tags/notifications.tag @@ -1,9 +1,7 @@ <mk-notifications> <div class="notifications" if={ notifications.length != 0 }> <virtual each={ notification, i in notifications }> - <div> - <mk-notification notification={ notification }/> - </div> + <mk-notification notification={ notification }/> <p class="date" if={ i != notifications.length - 1 && notification._date != notifications[i + 1]._date }><span><i class="fa fa-angle-up"></i>{ notification._datetext }</span><span><i class="fa fa-angle-down"></i>{ notifications[i + 1]._datetext }</span></p> </virtual> </div> @@ -15,20 +13,28 @@ <style> :scope display block + margin 8px auto + padding 0 + max-width 500px + width calc(100% - 16px) background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + @media (min-width 500px) + margin 16px auto + width calc(100% - 32px) > .notifications - > div + > mk-notification + margin 0 auto + max-width 500px border-bottom solid 1px rgba(0, 0, 0, 0.05) &:last-child border-bottom none - > mk-notification - margin 0 auto - max-width 500px - > .date display block margin 0 @@ -72,7 +78,7 @@ </style> <script> - import getPostSummary from '../../common/scripts/get-post-summary'; + import getPostSummary from '../../../../common/get-post-summary.ts'; this.getPostSummary = getPostSummary; this.mixin('api'); @@ -117,6 +123,12 @@ }); this.onNotification = notification => { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.stream.send({ + type: 'read_notification', + id: notification.id + }); + this.notifications.unshift(notification); this.update(); }; diff --git a/src/web/app/mobile/tags/page/drive.tag b/src/web/app/mobile/tags/page/drive.tag index 1169e3b9eb..218960c702 100644 --- a/src/web/app/mobile/tags/page/drive.tag +++ b/src/web/app/mobile/tags/page/drive.tag @@ -1,6 +1,6 @@ <mk-drive-page> <mk-ui ref="ui"> - <mk-drive ref="browser" folder={ parent.opts.folder } file={ parent.opts.file } data-is-naked="true"/> + <mk-drive ref="browser" folder={ parent.opts.folder } file={ parent.opts.file } is-naked={ true } top={ 48 }/> </mk-ui> <style> :scope diff --git a/src/web/app/mobile/tags/page/home.tag b/src/web/app/mobile/tags/page/home.tag index 32c80fd20e..3b0255b293 100644 --- a/src/web/app/mobile/tags/page/home.tag +++ b/src/web/app/mobile/tags/page/home.tag @@ -9,7 +9,7 @@ <script> import ui from '../../scripts/ui-event'; import Progress from '../../../common/scripts/loading'; - import getPostSummary from '../../../common/scripts/get-post-summary'; + import getPostSummary from '../../../../../common/get-post-summary.ts'; import openPostForm from '../../scripts/open-post-form'; this.mixin('i'); @@ -20,6 +20,7 @@ this.on('mount', () => { document.title = 'Misskey' ui.trigger('title', '<i class="fa fa-home"></i>%i18n:mobile.tags.mk-home.home%'); + document.documentElement.style.background = '#313a42'; ui.trigger('func', () => { openPostForm(); diff --git a/src/web/app/mobile/tags/page/notifications.tag b/src/web/app/mobile/tags/page/notifications.tag index f90cd1628d..743de04393 100644 --- a/src/web/app/mobile/tags/page/notifications.tag +++ b/src/web/app/mobile/tags/page/notifications.tag @@ -10,9 +10,16 @@ import ui from '../../scripts/ui-event'; import Progress from '../../../common/scripts/loading'; + this.mixin('api'); + this.on('mount', () => { document.title = 'Misskey | %i18n:mobile.tags.mk-notifications-page.notifications%'; ui.trigger('title', '<i class="fa fa-bell-o"></i>%i18n:mobile.tags.mk-notifications-page.notifications%'); + document.documentElement.style.background = '#313a42'; + + ui.trigger('func', () => { + this.readAll(); + }, 'check'); Progress.start(); @@ -20,5 +27,13 @@ Progress.done(); }); }); + + this.readAll = () => { + const ok = window.confirm('%i18n:mobile.tags.mk-notifications-page.read-all%'); + + if (!ok) return; + + this.api('notifications/mark_as_read_all'); + }; </script> </mk-notifications-page> diff --git a/src/web/app/mobile/tags/page/post.tag b/src/web/app/mobile/tags/page/post.tag index 7ab4ea2714..6888229f89 100644 --- a/src/web/app/mobile/tags/page/post.tag +++ b/src/web/app/mobile/tags/page/post.tag @@ -1,7 +1,11 @@ <mk-post-page> <mk-ui ref="ui"> - <main> - <mk-post-detail ref="post" post={ parent.post }/> + <main if={ !parent.fetching }> + <a if={ parent.post.next } href={ parent.post.next }><i class="fa fa-angle-up"></i>%i18n:mobile.tags.mk-post-page.next%</a> + <div> + <mk-post-detail ref="post" post={ parent.post }/> + </div> + <a if={ parent.post.prev } href={ parent.post.prev }><i class="fa fa-angle-down"></i>%i18n:mobile.tags.mk-post-page.prev%</a> </main> </mk-ui> <style> @@ -9,31 +13,61 @@ display block main - background #fff + text-align center - > mk-post-detail - width 100% + > div + margin 8px auto + padding 0 max-width 500px - margin 0 auto + width calc(100% - 16px) + @media (min-width 500px) + margin 16px auto + width calc(100% - 32px) + + > a + display inline-block + + &:first-child + margin-top 8px + + @media (min-width 500px) + margin-top 16px + + &:last-child + margin-bottom 8px + + @media (min-width 500px) + margin-bottom 16px + + > i + margin-right 4px </style> <script> import ui from '../../scripts/ui-event'; import Progress from '../../../common/scripts/loading'; - this.post = this.opts.post; + this.mixin('api'); + + this.fetching = true; + this.post = null; this.on('mount', () => { document.title = 'Misskey'; - ui.trigger('title', '<i class="fa fa-sticky-note-o"></i>%i18n:mobile.tags.mk-post-page.submit%'); + ui.trigger('title', '<i class="fa fa-sticky-note-o"></i>%i18n:mobile.tags.mk-post-page.title%'); + document.documentElement.style.background = '#313a42'; Progress.start(); - this.refs.ui.refs.post.on('post-fetched', () => { - Progress.set(0.5); - }); + this.api('posts/show', { + post_id: this.opts.post + }).then(post => { + + this.update({ + fetching: false, + post: post + }); - this.refs.ui.refs.post.on('loaded', () => { Progress.done(); }); }); diff --git a/src/web/app/mobile/tags/page/search.tag b/src/web/app/mobile/tags/page/search.tag index 869d5c8533..a66f07971a 100644 --- a/src/web/app/mobile/tags/page/search.tag +++ b/src/web/app/mobile/tags/page/search.tag @@ -14,6 +14,7 @@ document.title = `%i18n:mobile.tags.mk-search-page.search%: ${this.opts.query} | Misskey` // TODO: クエリをHTMLエスケープ ui.trigger('title', '<i class="fa fa-search"></i>' + this.opts.query); + document.documentElement.style.background = '#313a42'; Progress.start(); diff --git a/src/web/app/mobile/tags/page/selectdrive.tag b/src/web/app/mobile/tags/page/selectdrive.tag new file mode 100644 index 0000000000..79ea3548f8 --- /dev/null +++ b/src/web/app/mobile/tags/page/selectdrive.tag @@ -0,0 +1,87 @@ +<mk-selectdrive-page> + <header> + <h1>%i18n:mobile.tags.mk-selectdrive-page.select-file%<span class="count" if={ files.length > 0 }>({ files.length })</span></h1> + <button class="upload" onclick={ upload }><i class="fa fa-upload"></i></button> + <button if={ multiple } class="ok" onclick={ ok }><i class="fa fa-check"></i></button> + </header> + <mk-drive ref="browser" select-file={ true } multiple={ multiple } is-naked={ true } top={ 42 }/> + + <style> + :scope + display block + width 100% + height 100% + background #fff + + > header + position fixed + top 0 + left 0 + width 100% + z-index 1000 + background #fff + box-shadow 0 1px rgba(0, 0, 0, 0.1) + + > h1 + margin 0 + padding 0 + text-align center + line-height 42px + font-size 1em + font-weight normal + + > .count + margin-left 4px + opacity 0.5 + + > .upload + position absolute + top 0 + left 0 + line-height 42px + width 42px + + > .ok + position absolute + top 0 + right 0 + line-height 42px + width 42px + + > mk-drive + top 42px + + </style> + <script> + const q = (new URL(location)).searchParams; + this.multiple = q.get('multiple') == 'true' ? true : false; + + this.on('mount', () => { + document.documentElement.style.background = '#fff'; + + this.refs.browser.on('selected', file => { + this.files = [file]; + this.ok(); + }); + + this.refs.browser.on('change-selection', files => { + this.update({ + files: files + }); + }); + }); + + this.upload = () => { + this.refs.browser.selectLocalFile(); + }; + + this.close = () => { + window.close(); + }; + + this.ok = () => { + window.opener.cb(this.multiple ? this.files : this.files[0]); + window.close(); + }; + </script> +</mk-selectdrive-page> diff --git a/src/web/app/mobile/tags/page/settings.tag b/src/web/app/mobile/tags/page/settings.tag index 58094a876a..b6501142ee 100644 --- a/src/web/app/mobile/tags/page/settings.tag +++ b/src/web/app/mobile/tags/page/settings.tag @@ -1,12 +1,6 @@ <mk-settings-page> <mk-ui ref="ui"> - <ul> - <li><a><i class="fa fa-user"></i>%i18n:mobile.tags.mk-settings-page.profile%</a></li> - <li><a href="./settings/authorized-apps"><i class="fa fa-puzzle-piece"></i>%i18n:mobile.tags.mk-settings-page.applications%</a></li> - <li><a href="./settings/twitter"><i class="fa fa-twitter"></i>%i18n:mobile.tags.mk-settings-page.twitter-integration%</a></li> - <li><a href="./settings/signin-history"><i class="fa fa-sign-in"></i>%i18n:mobile.tags.mk-settings-page.signin-history%</a></li> - <li><a href="./settings/api"><i class="fa fa-key"></i>API</a></li> - </ul> + <mk-settings /> </mk-ui> <style> :scope @@ -18,6 +12,92 @@ this.on('mount', () => { document.title = 'Misskey | %i18n:mobile.tags.mk-settings-page.settings%'; ui.trigger('title', '<i class="fa fa-cog"></i>%i18n:mobile.tags.mk-settings-page.settings%'); + document.documentElement.style.background = '#313a42'; }); </script> </mk-settings-page> + +<mk-settings> + <p><mk-raw content={ '%i18n:mobile.tags.mk-settings.signed-in-as%'.replace('{}', '<b>' + I.name + '</b>') }/></p> + <ul> + <li><a href="./settings/profile"><i class="fa fa-user"></i>%i18n:mobile.tags.mk-settings-page.profile%<i class="fa fa-angle-right"></i></a></li> + <li><a href="./settings/authorized-apps"><i class="fa fa-puzzle-piece"></i>%i18n:mobile.tags.mk-settings-page.applications%<i class="fa fa-angle-right"></i></a></li> + <li><a href="./settings/twitter"><i class="fa fa-twitter"></i>%i18n:mobile.tags.mk-settings-page.twitter-integration%<i class="fa fa-angle-right"></i></a></li> + <li><a href="./settings/signin-history"><i class="fa fa-sign-in"></i>%i18n:mobile.tags.mk-settings-page.signin-history%<i class="fa fa-angle-right"></i></a></li> + <li><a href="./settings/api"><i class="fa fa-key"></i>%i18n:mobile.tags.mk-settings-page.api%<i class="fa fa-angle-right"></i></a></li> + </ul> + <ul> + <li><a onclick={ signout }><i class="fa fa-power-off"></i>%i18n:mobile.tags.mk-settings-page.signout%</a></li> + </ul> + <p><small>ver { version } (葵 aoi)</small></p> + <style> + :scope + display block + + > p + display block + margin 24px + text-align center + color #cad2da + + > ul + $radius = 8px + + display block + margin 16px auto + padding 0 + max-width 500px + width calc(100% - 32px) + list-style none + background #fff + border solid 1px rgba(0, 0, 0, 0.2) + border-radius $radius + + > li + display block + border-bottom solid 1px #ddd + + &:hover + background rgba(0, 0, 0, 0.1) + + &:first-child + border-top-left-radius $radius + border-top-right-radius $radius + + &:last-child + border-bottom-left-radius $radius + border-bottom-right-radius $radius + border-bottom none + + > a + $height = 48px + + display block + position relative + padding 0 16px + line-height $height + color #4d635e + + > i:nth-of-type(1) + margin-right 4px + + > i:nth-of-type(2) + display block + position absolute + top 0 + right 8px + z-index 1 + padding 0 20px + font-size 1.2em + line-height $height + + </style> + <script> + import signout from '../../../common/scripts/signout'; + this.signout = signout; + + this.mixin('i'); + + this.version = VERSION; + </script> +</mk-settings> diff --git a/src/web/app/mobile/tags/page/settings/api.tag b/src/web/app/mobile/tags/page/settings/api.tag index cfffeacb5a..25413e2d80 100644 --- a/src/web/app/mobile/tags/page/settings/api.tag +++ b/src/web/app/mobile/tags/page/settings/api.tag @@ -7,7 +7,7 @@ display block </style> <script> - const ui = require('../../../scripts/ui-event'); + import ui from '../../../scripts/ui-event'; this.on('mount', () => { document.title = 'Misskey | API'; @@ -15,3 +15,22 @@ }); </script> </mk-api-info-page> + +<mk-api-info> + <p>Token:<code>{ I.token }</code></p> + <p>APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。</p> + <p>アカウントを乗っ取られてしまう可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。</p> + <p>万が一このトークンが漏れたりその可能性がある場合はデスクトップ版Misskeyから再生成できます。</p> + <style> + :scope + display block + color #4a535a + + code + padding 4px + background #eee + </style> + <script> + this.mixin('i'); + </script> +</mk-api-info> diff --git a/src/web/app/mobile/tags/page/settings/authorized-apps.tag b/src/web/app/mobile/tags/page/settings/authorized-apps.tag index e962871ec7..78efd13e47 100644 --- a/src/web/app/mobile/tags/page/settings/authorized-apps.tag +++ b/src/web/app/mobile/tags/page/settings/authorized-apps.tag @@ -7,7 +7,7 @@ display block </style> <script> - const ui = require('../../../scripts/ui-event'); + import ui from '../../../scripts/ui-event'; this.on('mount', () => { document.title = 'Misskey | %i18n:mobile.tags.mk-authorized-apps-page.application%'; diff --git a/src/web/app/mobile/tags/page/settings/profile.tag b/src/web/app/mobile/tags/page/settings/profile.tag new file mode 100644 index 0000000000..305f16fec5 --- /dev/null +++ b/src/web/app/mobile/tags/page/settings/profile.tag @@ -0,0 +1,247 @@ +<mk-profile-setting-page> + <mk-ui ref="ui"> + <mk-profile-setting/> + </mk-ui> + <style> + :scope + display block + </style> + <script> + import ui from '../../../scripts/ui-event'; + + this.on('mount', () => { + document.title = 'Misskey | %i18n:mobile.tags.mk-profile-setting-page.title%'; + ui.trigger('title', '<i class="fa fa-user"></i>%i18n:mobile.tags.mk-profile-setting-page.title%'); + document.documentElement.style.background = '#313a42'; + }); + </script> +</mk-profile-setting-page> + +<mk-profile-setting> + <div> + <p><i class="fa fa-info-circle"></i>%i18n:mobile.tags.mk-profile-setting.will-be-published%</p> + <div class="form"> + <div style={ I.banner_url ? 'background-image: url(' + I.banner_url + '?thumbnail&size=1024)' : '' } onclick={ clickBanner }> + <img src={ I.avatar_url + '?thumbnail&size=200' } alt="avatar" onclick={ clickAvatar }/> + </div> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.name%</p> + <input ref="name" type="text" value={ I.name }/> + </label> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.location%</p> + <input ref="location" type="text" value={ I.profile.location }/> + </label> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.description%</p> + <textarea ref="description">{ I.description }</textarea> + </label> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.birthday%</p> + <input ref="birthday" type="date" value={ I.profile.birthday }/> + </label> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.avatar%</p> + <button onclick={ setAvatar } disabled={ avatarSaving }>%i18n:mobile.tags.mk-profile-setting.set-avatar%</button> + </label> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.banner%</p> + <button onclick={ setBanner } disabled={ bannerSaving }>%i18n:mobile.tags.mk-profile-setting.set-banner%</button> + </label> + </div> + <button class="save" onclick={ save } disabled={ saving }><i class="fa fa-check"></i>%i18n:mobile.tags.mk-profile-setting.save%</button> + </div> + <style> + :scope + display block + + > div + margin 8px auto + max-width 500px + width calc(100% - 16px) + + @media (min-width 500px) + margin 16px auto + width calc(100% - 32px) + + > p + display block + margin 0 0 8px 0 + padding 12px 16px + font-size 14px + color #79d4e6 + border solid 1px #71afbb + //color #276f86 + //background #f8ffff + //border solid 1px #a9d5de + border-radius 8px + + > i + margin-right 6px + + > .form + position relative + background #fff + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + border-radius 8px + + &:before + content "" + display block + position absolute + bottom -20px + left calc(50% - 10px) + border-top solid 10px rgba(0, 0, 0, 0.2) + border-right solid 10px transparent + border-bottom solid 10px transparent + border-left solid 10px transparent + + &:after + content "" + display block + position absolute + bottom -16px + left calc(50% - 8px) + border-top solid 8px #fff + border-right solid 8px transparent + border-bottom solid 8px transparent + border-left solid 8px transparent + + > div + height 128px + background-color #e4e4e4 + background-size cover + background-position center + border-radius 8px 8px 0 0 + + > img + position absolute + top 25px + left calc(50% - 40px) + width 80px + height 80px + border solid 2px #fff + border-radius 8px + + > label + display block + margin 0 + padding 16px + border-bottom solid 1px #eee + + &:last-of-type + border none + + > p:first-child + display block + margin 0 + padding 0 0 4px 0 + font-weight bold + color #2f3c42 + + > input[type="text"] + > textarea + display block + width 100% + padding 12px + font-size 16px + color #192427 + border solid 2px #ddd + border-radius 4px + + > textarea + min-height 80px + + > .save + display block + margin 8px 0 0 0 + padding 16px + width 100% + font-size 16px + color $theme-color-foreground + background $theme-color + border-radius 8px + + &:disabled + opacity 0.7 + + > i + margin-right 4px + + </style> + <script> + this.mixin('i'); + this.mixin('api'); + + this.setAvatar = () => { + const i = riot.mount(document.body.appendChild(document.createElement('mk-drive-selector')), { + multiple: false + })[0]; + i.one('selected', file => { + this.update({ + avatarSaving: true + }); + + this.api('i/update', { + avatar_id: file.id + }).then(() => { + this.update({ + avatarSaving: false + }); + + alert('%i18n:mobile.tags.mk-profile-setting.avatar-saved%'); + }); + }); + }; + + this.setBanner = () => { + const i = riot.mount(document.body.appendChild(document.createElement('mk-drive-selector')), { + multiple: false + })[0]; + i.one('selected', file => { + this.update({ + bannerSaving: true + }); + + this.api('i/update', { + banner_id: file.id + }).then(() => { + this.update({ + bannerSaving: false + }); + + alert('%i18n:mobile.tags.mk-profile-setting.banner-saved%'); + }); + }); + }; + + this.clickAvatar = e => { + this.setAvatar(); + return false; + }; + + this.clickBanner = e => { + this.setBanner(); + return false; + }; + + this.save = () => { + this.update({ + saving: true + }); + + this.api('i/update', { + name: this.refs.name.value, + location: this.refs.location.value || null, + description: this.refs.description.value || null, + birthday: this.refs.birthday.value || null + }).then(() => { + this.update({ + saving: false + }); + + alert('%i18n:mobile.tags.mk-profile-setting.saved%'); + }); + }; + </script> +</mk-profile-setting> diff --git a/src/web/app/mobile/tags/page/settings/signin.tag b/src/web/app/mobile/tags/page/settings/signin.tag index 2305ea9fb4..a91ebfb140 100644 --- a/src/web/app/mobile/tags/page/settings/signin.tag +++ b/src/web/app/mobile/tags/page/settings/signin.tag @@ -7,7 +7,7 @@ display block </style> <script> - const ui = require('../../../scripts/ui-event'); + import ui from '../../../scripts/ui-event'; this.on('mount', () => { document.title = 'Misskey | %i18n:mobile.tags.mk-signin-history-page.signin-history%'; diff --git a/src/web/app/mobile/tags/page/settings/twitter.tag b/src/web/app/mobile/tags/page/settings/twitter.tag index f4e9f7628b..870eeeb5bc 100644 --- a/src/web/app/mobile/tags/page/settings/twitter.tag +++ b/src/web/app/mobile/tags/page/settings/twitter.tag @@ -7,7 +7,7 @@ display block </style> <script> - const ui = require('../../../scripts/ui-event'); + import ui from '../../../scripts/ui-event'; this.on('mount', () => { document.title = 'Misskey | %i18n:mobile.tags.mk-twitter-setting-page.twitter-integration%'; diff --git a/src/web/app/mobile/tags/page/user-followers.tag b/src/web/app/mobile/tags/page/user-followers.tag index f6fcffebe2..cffb2b58c4 100644 --- a/src/web/app/mobile/tags/page/user-followers.tag +++ b/src/web/app/mobile/tags/page/user-followers.tag @@ -29,6 +29,7 @@ document.title = '%i18n:mobile.tags.mk-user-followers-page.followers-of%'.replace('{}', user.name) + ' | Misskey'; // TODO: ユーザー名をエスケープ ui.trigger('title', '<img src="' + user.avatar_url + '?thumbnail&size=64">' + '%i18n:mobile.tags.mk-user-followers-page.followers-of%'.replace('{}', user.name)); + document.documentElement.style.background = '#313a42'; this.refs.ui.refs.list.on('loaded', () => { Progress.done(); diff --git a/src/web/app/mobile/tags/page/user-following.tag b/src/web/app/mobile/tags/page/user-following.tag index 4b289b6aa3..369cb46422 100644 --- a/src/web/app/mobile/tags/page/user-following.tag +++ b/src/web/app/mobile/tags/page/user-following.tag @@ -29,6 +29,7 @@ document.title = '%i18n:mobile.tags.mk-user-following-page.following-of%'.replace('{}', user.name) + ' | Misskey'; // TODO: ユーザー名をエスケープ ui.trigger('title', '<img src="' + user.avatar_url + '?thumbnail&size=64">' + '%i18n:mobile.tags.mk-user-following-page.following-of%'.replace('{}', user.name)); + document.documentElement.style.background = '#313a42'; this.refs.ui.refs.list.on('loaded', () => { Progress.done(); diff --git a/src/web/app/mobile/tags/page/user.tag b/src/web/app/mobile/tags/page/user.tag index 05ccef3113..1abeab492a 100644 --- a/src/web/app/mobile/tags/page/user.tag +++ b/src/web/app/mobile/tags/page/user.tag @@ -13,6 +13,7 @@ this.user = this.opts.user; this.on('mount', () => { + document.documentElement.style.background = '#313a42'; Progress.start(); this.refs.ui.refs.user.on('loaded', user => { diff --git a/src/web/app/mobile/tags/post-detail.tag b/src/web/app/mobile/tags/post-detail.tag index 9d62a2b591..8a32101036 100644 --- a/src/web/app/mobile/tags/post-detail.tag +++ b/src/web/app/mobile/tags/post-detail.tag @@ -1,313 +1,306 @@ <mk-post-detail> - <div class="fetching" if={ fetching }> - <mk-ellipsis-icon/> + <button class="read-more" if={ p.reply && p.reply.reply_id && context == null } onclick={ loadContext } disabled={ loadingContext }> + <i class="fa fa-ellipsis-v" if={ !contextFetching }></i> + <i class="fa fa-spinner fa-pulse" if={ contextFetching }></i> + </button> + <div class="context"> + <virtual each={ post in context }> + <mk-post-detail-sub post={ post }/> + </virtual> </div> - <div class="main" if={ !fetching }> - <button class="read-more" if={ p.reply_to && p.reply_to.reply_to_id && context == null } onclick={ loadContext } disabled={ loadingContext }> - <i class="fa fa-ellipsis-v" if={ !contextFetching }></i> - <i class="fa fa-spinner fa-pulse" if={ contextFetching }></i> - </button> - <div class="context"> - <virtual each={ post in context }> - <mk-post-preview post={ post }/> - </virtual> - </div> - <div class="reply-to" if={ p.reply_to }> - <mk-post-preview post={ p.reply_to }/> - </div> - <div class="repost" if={ isRepost }> - <p> - <a class="avatar-anchor" href={ '/' + post.user.username }> - <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=32' } alt="avatar"/></a> - <i class="fa fa-retweet"></i><a class="name" href={ '/' + post.user.username }> - { post.user.name } - </a> - がRepost - </p> - </div> - <article> - <header> - <a class="avatar-anchor" href={ '/' + p.user.username }> - <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </a> - <div> - <a class="name" href={ '/' + p.user.username }>{ p.user.name }</a> - <span class="username">@{ p.user.username }</span> - </div> - </header> - <div class="body"> - <div class="text" ref="text"></div> - <div class="media" if={ p.media }> - <virtual each={ file in p.media }><img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/></virtual> - </div> - <mk-poll if={ p.poll } post={ p }/> - </div> - <a class="time" href={ url }> - <mk-time time={ p.created_at } mode="detail"/> + <div class="reply-to" if={ p.reply }> + <mk-post-detail-sub post={ p.reply }/> + </div> + <div class="repost" if={ isRepost }> + <p> + <a class="avatar-anchor" href={ '/' + post.user.username }> + <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=32' } alt="avatar"/></a> + <i class="fa fa-retweet"></i><a class="name" href={ '/' + post.user.username }> + { post.user.name } </a> - <footer> - <mk-reactions-viewer post={ p }/> - <button onclick={ reply } title="%i18n:mobile.tags.mk-post-detail.reply%"><i class="fa fa-reply"></i> - <p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> - </button> - <button onclick={ repost } title="Repost"><i class="fa fa-retweet"></i> - <p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> - </button> - <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="%i18n:mobile.tags.mk-post-detail.reaction%"><i class="fa fa-plus"></i> - <p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> - </button> - <button><i class="fa fa-ellipsis-h"></i></button> - </footer> - </article> - <div class="replies"> - <virtual each={ post in replies }> - <mk-post-preview post={ post }/> - </virtual> + がRepost + </p> + </div> + <article> + <header> + <a class="avatar-anchor" href={ '/' + p.user.username }> + <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> + </a> + <div> + <a class="name" href={ '/' + p.user.username }>{ p.user.name }</a> + <span class="username">@{ p.user.username }</span> + </div> + </header> + <div class="body"> + <div class="text" ref="text"></div> + <div class="media" if={ p.media }> + <virtual each={ file in p.media }><img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/></virtual> + </div> + <mk-poll if={ p.poll } post={ p }/> </div> + <a class="time" href={ '/' + p.user.username + '/' + p.id }> + <mk-time time={ p.created_at } mode="detail"/> + </a> + <footer> + <mk-reactions-viewer post={ p }/> + <button onclick={ reply } title="%i18n:mobile.tags.mk-post-detail.reply%"> + <i class="fa fa-reply"></i><p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> + </button> + <button onclick={ repost } title="Repost"> + <i class="fa fa-retweet"></i><p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> + </button> + <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="%i18n:mobile.tags.mk-post-detail.reaction%"> + <i class="fa fa-plus"></i><p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> + </button> + <button onclick={ menu } ref="menuButton"> + <i class="fa fa-ellipsis-h"></i> + </button> + </footer> + </article> + <div class="replies" if={ !compact }> + <virtual each={ post in replies }> + <mk-post-detail-sub post={ post }/> + </virtual> </div> <style> :scope display block - margin 0 + overflow hidden + margin 0 auto padding 0 + width 100% + text-align left + background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) > .fetching padding 64px 0 - > .main + > .read-more + display block + margin 0 + padding 10px 0 + width 100% + font-size 1em + text-align center + color #999 + cursor pointer + background #fafafa + outline none + border none + border-bottom solid 1px #eef0f2 + border-radius 6px 6px 0 0 + box-shadow none - > .read-more - display block - margin 0 - padding 10px 0 - width 100% - font-size 1em - text-align center - color #999 - cursor pointer - background #fafafa - outline none - border none - border-bottom solid 1px #eef0f2 - border-radius 6px 6px 0 0 - box-shadow none + &:hover + background #f6f6f6 - &:hover - background #f6f6f6 + &:active + background #f0f0f0 - &:active - background #f0f0f0 + &:disabled + color #ccc - &:disabled - color #ccc - - > .context - > * - border-bottom 1px solid #eef0f2 - - > .repost - color #9dbb00 - background linear-gradient(to bottom, #edfde2 0%, #fff 100%) - - > p - margin 0 - padding 16px 32px - - .avatar-anchor - display inline-block - - .avatar - vertical-align bottom - min-width 28px - min-height 28px - max-width 28px - max-height 28px - margin 0 8px 0 0 - border-radius 6px - - i - margin-right 4px - - .name - font-weight bold - - & + article - padding-top 8px - - > .reply-to + > .context + > * border-bottom 1px solid #eef0f2 - > article - padding 14px 16px 9px 16px + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) - @media (min-width 500px) - padding 28px 32px 18px 32px + > p + margin 0 + padding 16px 32px - &:after - content "" + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + min-width 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 6px + + i + margin-right 4px + + .name + font-weight bold + + & + article + padding-top 8px + + > .reply-to + border-bottom 1px solid #eef0f2 + + > article + padding 14px 16px 9px 16px + + @media (min-width 500px) + padding 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > header + display flex + line-height 1.1 + + > .avatar-anchor display block - clear both + padding 0 .5em 0 0 - &:hover - > .main > footer > button - color #888 - - > header - display flex - line-height 1.1 - - > .avatar-anchor - display block - padding 0 .5em 0 0 - - > .avatar - display block - width 54px - height 54px - margin 0 - border-radius 8px - vertical-align bottom - - @media (min-width 500px) - width 60px - height 60px - - > div - - > .name - display inline-block - margin .4em 0 - color #777 - font-size 16px - font-weight bold - text-align left - text-decoration none - - &:hover - text-decoration underline - - > .username - display block - text-align left - margin 0 - color #ccc - - > .body - padding 8px 0 - - > .text - cursor default + > .avatar display block + width 54px + height 54px margin 0 - padding 0 - overflow-wrap break-word - font-size 16px - color #717171 + border-radius 8px + vertical-align bottom @media (min-width 500px) - font-size 24px + width 60px + height 60px - .link - &:after - content "\f14c" - display inline-block - padding-left 2px - font-family FontAwesome - font-size .9em - font-weight 400 - font-style normal + > div - > mk-url-preview - margin-top 8px - - > .media - > img - display block - max-width 100% - - > .time - font-size 16px - color #c0c0c0 - - > footer - font-size 1.2em - - > button - margin 0 28px 0 0 - padding 8px - background transparent - border none - box-shadow none - font-size 1em - color #ddd - cursor pointer + > .name + display inline-block + margin .4em 0 + color #777 + font-size 16px + font-weight bold + text-align left + text-decoration none &:hover - color #666 + text-decoration underline - > .count - display inline - margin 0 0 0 8px - color #999 + > .username + display block + text-align left + margin 0 + color #ccc - &.reacted - color $theme-color + > .body + padding 8px 0 - > .replies - > * - border-top 1px solid #eef0f2 + > .text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 16px + color #717171 + + @media (min-width 500px) + font-size 24px + + .link + &:after + content "\f14c" + display inline-block + padding-left 2px + font-family FontAwesome + font-size .9em + font-weight 400 + font-style normal + + > mk-url-preview + margin-top 8px + + > .media + > img + display block + max-width 100% + + > .time + font-size 16px + color #c0c0c0 + + > footer + font-size 1.2em + + > button + margin 0 + padding 8px + background transparent + border none + box-shadow none + font-size 1em + color #ddd + cursor pointer + + &:not(:last-child) + margin-right 28px + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.reacted + color $theme-color + + > .replies + > * + border-top 1px solid #eef0f2 </style> <script> import compile from '../../common/scripts/text-compiler'; - import getPostSummary from '../../common/scripts/get-post-summary'; + import getPostSummary from '../../../../common/get-post-summary.ts'; import openPostForm from '../scripts/open-post-form'; this.mixin('api'); - this.fetching = true; + this.compact = this.opts.compact; + this.post = this.opts.post; + this.isRepost = this.post.repost != null; + this.p = this.isRepost ? this.post.repost : this.post; + this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0; + this.summary = getPostSummary(this.p); + this.loadingContext = false; this.context = null; - this.post = null; this.on('mount', () => { - this.api('posts/show', { - post_id: this.opts.post - }).then(post => { - const isRepost = post.repost != null; - const p = isRepost ? post.repost : post; - p.reactions_count = p.reaction_counts ? Object.keys(p.reaction_counts).map(key => p.reaction_counts[key]).reduce((a, b) => a + b) : 0; + if (this.p.text) { + const tokens = this.p.ast; - this.update({ - fetching: false, - post: post, - isRepost: isRepost, - p: p, - summary: getPostSummary(p) + this.refs.text.innerHTML = compile(tokens); + + this.refs.text.children.forEach(e => { + if (e.tagName == 'MK-URL') riot.mount(e); }); - this.trigger('loaded'); - - if (this.p.text) { - const tokens = this.p.ast; - - this.refs.text.innerHTML = compile(tokens); - - this.refs.text.children.forEach(e => { - if (e.tagName == 'MK-URL') riot.mount(e); + // URLをプレビュー + tokens + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => { + riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), { + url: t.url }); + }); + } - // URLをプレビュー - tokens - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => { - riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), { - url: t.url - }); - }); - } - - // Get replies + // Get replies + if (!this.compact) { this.api('posts/replies', { post_id: this.p.id, limit: 8 @@ -316,7 +309,7 @@ replies: replies }); }); - }); + } }); this.reply = () => { @@ -342,12 +335,20 @@ }); }; + this.menu = () => { + riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), { + source: this.refs.menuButton, + post: this.p, + compact: true + }); + }; + this.loadContext = () => { this.contextFetching = true; // Fetch context this.api('posts/context', { - post_id: this.p.reply_to_id + post_id: this.p.reply_id }).then(context => { this.update({ contextFetching: false, @@ -357,3 +358,101 @@ }; </script> </mk-post-detail> + +<mk-post-detail-sub> + <article> + <a class="avatar-anchor" href={ '/' + post.user.username }> + <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> + </a> + <div class="main"> + <header> + <a class="name" href={ '/' + post.user.username }>{ post.user.name }</a> + <span class="username">@{ post.user.username }</span> + <a class="time" href={ '/' + post.user.username + '/' + post.id }> + <mk-time time={ post.created_at }/> + </a> + </header> + <div class="body"> + <mk-sub-post-content class="text" post={ post }/> + </div> + </div> + </article> + <style> + :scope + display block + margin 0 + padding 8px + font-size 0.9em + background #fdfdfd + + @media (min-width 500px) + padding 12px + + > article + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 48px + height 48px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 60px) + + > header + display flex + margin-bottom 4px + white-space nowrap + + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + text-overflow ellipsis + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 .5em 0 0 + color #d1d8da + + > .time + margin-left auto + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + + </style> + <script>this.post = this.opts.post</script> +</mk-post-detail-sub> diff --git a/src/web/app/mobile/tags/post-form.tag b/src/web/app/mobile/tags/post-form.tag index 28c7796840..d7d382c9e2 100644 --- a/src/web/app/mobile/tags/post-form.tag +++ b/src/web/app/mobile/tags/post-form.tag @@ -1,11 +1,9 @@ <mk-post-form> <header> + <button class="cancel" onclick={ cancel }><i class="fa fa-times"></i></button> <div> - <button class="cancel" onclick={ cancel }><i class="fa fa-times"></i></button> - <div> - <span if={ refs.text } class="text-count { over: refs.text.value.length > 1000 }">{ 1000 - refs.text.value.length }</span> - <button class="submit" onclick={ post }>%i18n:mobile.tags.mk-post-form.submit%</button> - </div> + <span if={ refs.text } class="text-count { over: refs.text.value.length > 1000 }">{ 1000 - refs.text.value.length }</span> + <button class="submit" onclick={ post }>%i18n:mobile.tags.mk-post-form.submit%</button> </div> </header> <div class="form"> @@ -30,46 +28,47 @@ <style> :scope display block - padding-top 50px + max-width 500px + width calc(100% - 16px) + margin 8px auto + background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + @media (min-width 500px) + margin 16px auto + width calc(100% - 32px) > header - position fixed - z-index 1000 - top 0 - left 0 - width 100% + z-index 1 height 50px - background #fff + box-shadow 0 1px 0 0 rgba(0, 0, 0, 0.1) + + > .cancel + width 50px + line-height 50px + font-size 24px + color #555 > div - max-width 500px - margin 0 auto + position absolute + top 0 + right 0 - > .cancel - width 50px + > .text-count line-height 50px - font-size 24px - color #555 + color #657786 - > div - position absolute - top 0 - right 0 + > .submit + margin 8px + padding 0 16px + line-height 34px + color $theme-color-foreground + background $theme-color + border-radius 4px - > .text-count - line-height 50px - color #657786 - - > .submit - margin 8px - padding 0 16px - line-height 34px - color $theme-color-foreground - background $theme-color - border-radius 4px - - &:disabled - opacity 0.7 + &:disabled + opacity 0.7 > .form max-width 500px @@ -268,7 +267,7 @@ this.api('posts/create', { text: this.refs.text.value == '' ? undefined : this.refs.text.value, media_ids: files, - reply_to_id: opts.reply ? opts.reply.id : undefined, + reply_id: opts.reply ? opts.reply.id : undefined, poll: this.poll ? this.refs.poll.get() : undefined }).then(data => { this.trigger('post'); diff --git a/src/web/app/mobile/tags/search-posts.tag b/src/web/app/mobile/tags/search-posts.tag index 3e6caa1df2..967764bc2c 100644 --- a/src/web/app/mobile/tags/search-posts.tag +++ b/src/web/app/mobile/tags/search-posts.tag @@ -3,8 +3,16 @@ <style> :scope display block + margin 8px auto + max-width 500px + width calc(100% - 16px) background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + @media (min-width 500px) + margin 16px auto + width calc(100% - 32px) </style> <script> this.mixin('api'); diff --git a/src/web/app/mobile/tags/sub-post-content.tag b/src/web/app/mobile/tags/sub-post-content.tag index 97e0ecec03..e32e245185 100644 --- a/src/web/app/mobile/tags/sub-post-content.tag +++ b/src/web/app/mobile/tags/sub-post-content.tag @@ -1,5 +1,5 @@ <mk-sub-post-content> - <div class="body"><a class="reply" if={ post.reply_to_id }><i class="fa fa-reply"></i></a><span ref="text"></span><a class="quote" if={ post.repost_id } href={ '/post:' + post.repost_id }>RP: ...</a></div> + <div class="body"><a class="reply" if={ post.reply_id }><i class="fa fa-reply"></i></a><span ref="text"></span><a class="quote" if={ post.repost_id } href={ '/post:' + post.repost_id }>RP: ...</a></div> <details if={ post.media }> <summary>({ post.media.length }個のメディア)</summary> <mk-images-viewer images={ post.media }/> diff --git a/src/web/app/mobile/tags/timeline-post-sub.tag b/src/web/app/mobile/tags/timeline-post-sub.tag deleted file mode 100644 index 3fff552e8f..0000000000 --- a/src/web/app/mobile/tags/timeline-post-sub.tag +++ /dev/null @@ -1,101 +0,0 @@ -<mk-timeline-post-sub> - <article><a class="avatar-anchor" href={ '/' + post.user.username }><img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=96' } alt="avatar"/></a> - <div class="main"> - <header><a class="name" href={ '/' + post.user.username }>{ post.user.name }</a><span class="username">@{ post.user.username }</span><a class="created-at" href={ '/' + post.user.username + '/' + post.id }> - <mk-time time={ post.created_at }/></a></header> - <div class="body"> - <mk-sub-post-content class="text" post={ post }/> - </div> - </div> - </article> - <style> - :scope - display block - margin 0 - padding 0 - font-size 0.9em - - > article - padding 16px - - &:after - content "" - display block - clear both - - &:hover - > .main > footer > button - color #888 - - > .avatar-anchor - display block - float left - margin 0 10px 0 0 - - @media (min-width 500px) - margin-right 16px - - > .avatar - display block - width 44px - height 44px - margin 0 - border-radius 8px - vertical-align bottom - - @media (min-width 500px) - width 52px - height 52px - - > .main - float left - width calc(100% - 54px) - - @media (min-width 500px) - width calc(100% - 68px) - - > header - display flex - margin-bottom 2px - white-space nowrap - - > .name - display block - margin 0 0.5em 0 0 - padding 0 - overflow hidden - color #607073 - font-size 1em - font-weight 700 - text-align left - text-decoration none - text-overflow ellipsis - - &:hover - text-decoration underline - - > .username - text-align left - margin 0 - color #d1d8da - - > .created-at - margin-left auto - color #b2b8bb - - > .body - - > .text - cursor default - margin 0 - padding 0 - font-size 1.1em - color #717171 - - pre - max-height 120px - font-size 80% - - </style> - <script>this.post = this.opts.post</script> -</mk-timeline-post-sub> diff --git a/src/web/app/mobile/tags/timeline-post.tag b/src/web/app/mobile/tags/timeline-post.tag deleted file mode 100644 index 2395e9fb79..0000000000 --- a/src/web/app/mobile/tags/timeline-post.tag +++ /dev/null @@ -1,414 +0,0 @@ -<mk-timeline-post class={ repost: isRepost }> - <div class="reply-to" if={ p.reply_to }> - <mk-timeline-post-sub post={ p.reply_to }/> - </div> - <div class="repost" if={ isRepost }> - <p> - <a class="avatar-anchor" href={ '/' + post.user.username }> - <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - </a> - <i class="fa fa-retweet"></i>{'%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('{'))}<a class="name" href={ '/' + post.user.username }>{ post.user.name }</a>{'%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr('%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1)} - </p> - <mk-time time={ post.created_at }/> - </div> - <article> - <a class="avatar-anchor" href={ '/' + p.user.username }> - <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=96' } alt="avatar"/> - </a> - <div class="main"> - <header> - <a class="name" href={ '/' + p.user.username }>{ p.user.name }</a> - <span class="is-bot" if={ p.user.is_bot }>bot</span> - <span class="username">@{ p.user.username }</span> - <a class="created-at" href={ url }> - <mk-time time={ p.created_at }/> - </a> - </header> - <div class="body"> - <div class="text" ref="text"> - <a class="reply" if={ p.reply_to }> - <i class="fa fa-reply"></i> - </a> - <p class="dummy"></p> - <a class="quote" if={ p.repost != null }>RP:</a> - </div> - <div class="media" if={ p.media }> - <mk-images-viewer images={ p.media }/> - </div> - <mk-poll if={ p.poll } post={ p } ref="pollViewer"/> - <span class="app" if={ p.app }>via <b>{ p.app.name }</b></span> - <div class="repost" if={ p.repost }><i class="fa fa-quote-right fa-flip-horizontal"></i> - <mk-post-preview class="repost" post={ p.repost }/> - </div> - </div> - <footer> - <mk-reactions-viewer post={ p } ref="reactionsViewer"/> - <button onclick={ reply }><i class="fa fa-reply"></i> - <p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> - </button> - <button onclick={ repost } title="Repost"><i class="fa fa-retweet"></i> - <p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> - </button> - <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton"><i class="fa fa-plus"></i> - <p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> - </button> - </footer> - </div> - </article> - <style> - :scope - display block - margin 0 - padding 0 - font-size 12px - - @media (min-width 350px) - font-size 14px - - @media (min-width 500px) - font-size 16px - - > .repost - color #9dbb00 - background linear-gradient(to bottom, #edfde2 0%, #fff 100%) - - > p - margin 0 - padding 8px 16px - line-height 28px - - @media (min-width 500px) - padding 16px - - .avatar-anchor - display inline-block - - .avatar - vertical-align bottom - width 28px - height 28px - margin 0 8px 0 0 - border-radius 6px - - i - margin-right 4px - - .name - font-weight bold - - > mk-time - position absolute - top 8px - right 16px - font-size 0.9em - line-height 28px - - @media (min-width 500px) - top 16px - - & + article - padding-top 8px - - > .reply-to - background rgba(0, 0, 0, 0.0125) - - > mk-post-preview - background transparent - - > article - padding 14px 16px 9px 16px - - &:after - content "" - display block - clear both - - > .avatar-anchor - display block - float left - margin 0 10px 8px 0 - position -webkit-sticky - position sticky - top 62px - - @media (min-width 500px) - margin-right 16px - - > .avatar - display block - width 48px - height 48px - margin 0 - border-radius 6px - vertical-align bottom - - @media (min-width 500px) - width 58px - height 58px - border-radius 8px - - > .main - float left - width calc(100% - 58px) - - @media (min-width 500px) - width calc(100% - 74px) - - > header - display flex - white-space nowrap - - @media (min-width 500px) - margin-bottom 2px - - > .name - display block - margin 0 0.5em 0 0 - padding 0 - overflow hidden - color #777 - font-size 1em - font-weight 700 - text-align left - text-decoration none - text-overflow ellipsis - - &:hover - text-decoration underline - - > .is-bot - text-align left - margin 0 0.5em 0 0 - padding 1px 6px - font-size 12px - color #aaa - border solid 1px #ddd - border-radius 3px - - > .username - text-align left - margin 0 0.5em 0 0 - color #ccc - - > .created-at - margin-left auto - font-size 0.9em - color #c0c0c0 - - > .body - - > .text - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - font-size 1.1em - color #717171 - - > .dummy - display none - - .link - &:after - content "\f14c" - display inline-block - padding-left 2px - font-family FontAwesome - font-size .9em - font-weight 400 - font-style normal - - mk-url-preview - margin-top 8px - - > .reply - margin-right 8px - color #717171 - - > .quote - margin-left 4px - font-style oblique - color #a0bf46 - - code - padding 4px 8px - margin 0 0.5em - font-size 80% - color #525252 - background #f8f8f8 - border-radius 2px - - pre > code - padding 16px - margin 0 - - [data-is-me]:after - content "you" - padding 0 4px - margin-left 4px - font-size 80% - color $theme-color-foreground - background $theme-color - border-radius 4px - - > .media - > img - display block - max-width 100% - - > .app - font-size 12px - color #ccc - - > mk-poll - font-size 80% - - > .repost - margin 8px 0 - - > i:first-child - position absolute - top -8px - left -8px - z-index 1 - color #c0dac6 - font-size 28px - background #fff - - > mk-post-preview - padding 16px - border dashed 1px #c0dac6 - border-radius 8px - - > footer - > button - margin 0 28px 0 0 - padding 8px - background transparent - border none - box-shadow none - font-size 1em - color #ddd - cursor pointer - - &:hover - color #666 - - > .count - display inline - margin 0 0 0 8px - color #999 - - &.reacted - color $theme-color - - </style> - <script> - import compile from '../../common/scripts/text-compiler'; - import getPostSummary from '../../common/scripts/get-post-summary'; - import openPostForm from '../scripts/open-post-form'; - - this.mixin('api'); - this.mixin('stream'); - - this.set = post => { - this.post = post; - this.isRepost = this.post.repost != null && this.post.text == null; - this.p = this.isRepost ? this.post.repost : this.post; - this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0; - this.summary = getPostSummary(this.p); - this.url = `/${this.p.user.username}/${this.p.id}`; - }; - - this.set(this.opts.post); - - this.refresh = post => { - this.set(post); - this.update(); - if (this.refs.reactionsViewer) this.refs.reactionsViewer.update({ - post - }); - if (this.refs.pollViewer) this.refs.pollViewer.init(post); - }; - - this.onStreamPostUpdated = data => { - const post = data.post; - if (post.id == this.post.id) { - this.refresh(post); - } - }; - - this.onStreamConnected = () => { - this.capture(); - }; - - this.capture = withHandler => { - this.stream.send({ - type: 'capture', - id: this.post.id - }); - if (withHandler) this.stream.on('post-updated', this.onStreamPostUpdated); - }; - - this.decapture = withHandler => { - this.stream.send({ - type: 'decapture', - id: this.post.id - }); - if (withHandler) this.stream.off('post-updated', this.onStreamPostUpdated); - }; - - this.on('mount', () => { - this.capture(true); - this.stream.on('_connected_', this.onStreamConnected); - - if (this.p.text) { - const tokens = this.p.ast; - - this.refs.text.innerHTML = this.refs.text.innerHTML.replace('<p class="dummy"></p>', compile(tokens)); - - this.refs.text.children.forEach(e => { - if (e.tagName == 'MK-URL') riot.mount(e); - }); - - // URLをプレビュー - tokens - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => { - riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), { - url: t.url - }); - }); - } - }); - - this.on('unmount', () => { - this.decapture(true); - this.stream.off('_connected_', this.onStreamConnected); - }); - - this.reply = () => { - openPostForm({ - reply: this.p - }); - }; - - this.repost = () => { - const text = window.prompt(`「${this.summary}」をRepost`); - if (text == null) return; - this.api('posts/create', { - repost_id: this.p.id, - text: text == '' ? undefined : text - }); - }; - - this.react = () => { - riot.mount(document.body.appendChild(document.createElement('mk-reaction-picker')), { - source: this.refs.reactButton, - post: this.p, - compact: true - }); - }; - </script> -</mk-timeline-post> diff --git a/src/web/app/mobile/tags/timeline.tag b/src/web/app/mobile/tags/timeline.tag index 11f4e0740b..f9ec2cca60 100644 --- a/src/web/app/mobile/tags/timeline.tag +++ b/src/web/app/mobile/tags/timeline.tag @@ -22,6 +22,8 @@ :scope display block background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) > .init padding 64px 0 @@ -44,12 +46,6 @@ font-size 3em color #ccc - > mk-timeline-post - border-bottom solid 1px #eaeaea - - &:last-of-type - border-bottom none - > .date display block margin 0 @@ -77,6 +73,7 @@ padding 16px width 100% color $theme-color + border-radius 0 0 8px 8px &:disabled opacity 0.7 @@ -138,3 +135,560 @@ }; </script> </mk-timeline> + +<mk-timeline-post class={ repost: isRepost }> + <div class="reply-to" if={ p.reply }> + <mk-timeline-post-sub post={ p.reply }/> + </div> + <div class="repost" if={ isRepost }> + <p> + <a class="avatar-anchor" href={ '/' + post.user.username }> + <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/> + </a> + <i class="fa fa-retweet"></i>{'%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('{'))}<a class="name" href={ '/' + post.user.username }>{ post.user.name }</a>{'%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr('%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1)} + </p> + <mk-time time={ post.created_at }/> + </div> + <article> + <a class="avatar-anchor" href={ '/' + p.user.username }> + <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=96' } alt="avatar"/> + </a> + <div class="main"> + <header> + <a class="name" href={ '/' + p.user.username }>{ p.user.name }</a> + <span class="is-bot" if={ p.user.is_bot }>bot</span> + <span class="username">@{ p.user.username }</span> + <a class="created-at" href={ url }> + <mk-time time={ p.created_at }/> + </a> + </header> + <div class="body"> + <div class="text" ref="text"> + <p class="channel" if={ p.channel != null }><a href={ CONFIG.chUrl + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p> + <a class="reply" if={ p.reply }> + <i class="fa fa-reply"></i> + </a> + <p class="dummy"></p> + <a class="quote" if={ p.repost != null }>RP:</a> + </div> + <div class="media" if={ p.media }> + <mk-images-viewer images={ p.media }/> + </div> + <mk-poll if={ p.poll } post={ p } ref="pollViewer"/> + <span class="app" if={ p.app }>via <b>{ p.app.name }</b></span> + <div class="repost" if={ p.repost }><i class="fa fa-quote-right fa-flip-horizontal"></i> + <mk-post-preview class="repost" post={ p.repost }/> + </div> + </div> + <footer> + <mk-reactions-viewer post={ p } ref="reactionsViewer"/> + <button onclick={ reply }> + <i class="fa fa-reply"></i><p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> + </button> + <button onclick={ repost } title="Repost"> + <i class="fa fa-retweet"></i><p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> + </button> + <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton"> + <i class="fa fa-plus"></i><p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> + </button> + <button class="menu" onclick={ menu } ref="menuButton"> + <i class="fa fa-ellipsis-h"></i> + </button> + </footer> + </div> + </article> + <style> + :scope + display block + margin 0 + padding 0 + font-size 12px + border-bottom solid 1px #eaeaea + + &:first-child + border-radius 8px 8px 0 0 + + > .repost + border-radius 8px 8px 0 0 + + &:last-of-type + border-bottom none + + @media (min-width 350px) + font-size 14px + + @media (min-width 500px) + font-size 16px + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 8px 16px + line-height 28px + + @media (min-width 500px) + padding 16px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px + + i + margin-right 4px + + .name + font-weight bold + + > mk-time + position absolute + top 8px + right 16px + font-size 0.9em + line-height 28px + + @media (min-width 500px) + top 16px + + & + article + padding-top 8px + + > .reply-to + background rgba(0, 0, 0, 0.0125) + + > mk-post-preview + background transparent + + > article + padding 14px 16px 9px 16px + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 10px 8px 0 + position -webkit-sticky + position sticky + top 62px + + @media (min-width 500px) + margin-right 16px + + > .avatar + display block + width 48px + height 48px + margin 0 + border-radius 6px + vertical-align bottom + + @media (min-width 500px) + width 58px + height 58px + border-radius 8px + + > .main + float left + width calc(100% - 58px) + + @media (min-width 500px) + width calc(100% - 74px) + + > header + display flex + white-space nowrap + + @media (min-width 500px) + margin-bottom 2px + + > .name + display block + margin 0 0.5em 0 0 + padding 0 + overflow hidden + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + text-overflow ellipsis + + &:hover + text-decoration underline + + > .is-bot + text-align left + margin 0 0.5em 0 0 + padding 1px 6px + font-size 12px + color #aaa + border solid 1px #ddd + border-radius 3px + + > .username + text-align left + margin 0 0.5em 0 0 + color #ccc + + > .created-at + margin-left auto + font-size 0.9em + color #c0c0c0 + + > .body + + > .text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.1em + color #717171 + + > .dummy + display none + + .link + &:after + content "\f14c" + display inline-block + padding-left 2px + font-family FontAwesome + font-size .9em + font-weight 400 + font-style normal + + mk-url-preview + margin-top 8px + + > .channel + margin 0 + + > .reply + margin-right 8px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + + code + padding 4px 8px + margin 0 0.5em + font-size 80% + color #525252 + background #f8f8f8 + border-radius 2px + + pre > code + padding 16px + margin 0 + + [data-is-me]:after + content "you" + padding 0 4px + margin-left 4px + font-size 80% + color $theme-color-foreground + background $theme-color + border-radius 4px + + > .media + > img + display block + max-width 100% + + > .app + font-size 12px + color #ccc + + > mk-poll + font-size 80% + + > .repost + margin 8px 0 + + > i:first-child + position absolute + top -8px + left -8px + z-index 1 + color #c0dac6 + font-size 28px + background #fff + + > mk-post-preview + padding 16px + border dashed 1px #c0dac6 + border-radius 8px + + > footer + > button + margin 0 + padding 8px + background transparent + border none + box-shadow none + font-size 1em + color #ddd + cursor pointer + + &:not(:last-child) + margin-right 28px + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.reacted + color $theme-color + + &.menu + @media (max-width 350px) + display none + + </style> + <script> + import compile from '../../common/scripts/text-compiler'; + import getPostSummary from '../../../../common/get-post-summary.ts'; + import openPostForm from '../scripts/open-post-form'; + + this.mixin('i'); + this.mixin('api'); + this.mixin('stream'); + + this.set = post => { + this.post = post; + this.isRepost = this.post.repost != null && this.post.text == null; + this.p = this.isRepost ? this.post.repost : this.post; + this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0; + this.summary = getPostSummary(this.p); + this.url = `/${this.p.user.username}/${this.p.id}`; + }; + + this.set(this.opts.post); + + this.refresh = post => { + this.set(post); + this.update(); + if (this.refs.reactionsViewer) this.refs.reactionsViewer.update({ + post + }); + if (this.refs.pollViewer) this.refs.pollViewer.init(post); + }; + + this.onStreamPostUpdated = data => { + const post = data.post; + if (post.id == this.post.id) { + this.refresh(post); + } + }; + + this.onStreamConnected = () => { + this.capture(); + }; + + this.capture = withHandler => { + if (this.SIGNIN) { + this.stream.send({ + type: 'capture', + id: this.post.id + }); + if (withHandler) this.stream.on('post-updated', this.onStreamPostUpdated); + } + }; + + this.decapture = withHandler => { + if (this.SIGNIN) { + this.stream.send({ + type: 'decapture', + id: this.post.id + }); + if (withHandler) this.stream.off('post-updated', this.onStreamPostUpdated); + } + }; + + this.on('mount', () => { + this.capture(true); + + if (this.SIGNIN) { + this.stream.on('_connected_', this.onStreamConnected); + } + + if (this.p.text) { + const tokens = this.p.ast; + + this.refs.text.innerHTML = this.refs.text.innerHTML.replace('<p class="dummy"></p>', compile(tokens)); + + this.refs.text.children.forEach(e => { + if (e.tagName == 'MK-URL') riot.mount(e); + }); + + // URLをプレビュー + tokens + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => { + riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), { + url: t.url + }); + }); + } + }); + + this.on('unmount', () => { + this.decapture(true); + this.stream.off('_connected_', this.onStreamConnected); + }); + + this.reply = () => { + openPostForm({ + reply: this.p + }); + }; + + this.repost = () => { + const text = window.prompt(`「${this.summary}」をRepost`); + if (text == null) return; + this.api('posts/create', { + repost_id: this.p.id, + text: text == '' ? undefined : text + }); + }; + + this.react = () => { + riot.mount(document.body.appendChild(document.createElement('mk-reaction-picker')), { + source: this.refs.reactButton, + post: this.p, + compact: true + }); + }; + + this.menu = () => { + riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), { + source: this.refs.menuButton, + post: this.p, + compact: true + }); + }; + </script> +</mk-timeline-post> + +<mk-timeline-post-sub> + <article><a class="avatar-anchor" href={ '/' + post.user.username }><img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=96' } alt="avatar"/></a> + <div class="main"> + <header><a class="name" href={ '/' + post.user.username }>{ post.user.name }</a><span class="username">@{ post.user.username }</span><a class="created-at" href={ '/' + post.user.username + '/' + post.id }> + <mk-time time={ post.created_at }/></a></header> + <div class="body"> + <mk-sub-post-content class="text" post={ post }/> + </div> + </div> + </article> + <style> + :scope + display block + margin 0 + padding 0 + font-size 0.9em + + > article + padding 16px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 10px 0 0 + + @media (min-width 500px) + margin-right 16px + + > .avatar + display block + width 44px + height 44px + margin 0 + border-radius 8px + vertical-align bottom + + @media (min-width 500px) + width 52px + height 52px + + > .main + float left + width calc(100% - 54px) + + @media (min-width 500px) + width calc(100% - 68px) + + > header + display flex + margin-bottom 2px + white-space nowrap + + > .name + display block + margin 0 0.5em 0 0 + padding 0 + overflow hidden + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + text-overflow ellipsis + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 + color #d1d8da + + > .created-at + margin-left auto + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + + pre + max-height 120px + font-size 80% + + </style> + <script>this.post = this.opts.post</script> +</mk-timeline-post-sub> diff --git a/src/web/app/mobile/tags/ui-header.tag b/src/web/app/mobile/tags/ui-header.tag deleted file mode 100644 index 10b44b2153..0000000000 --- a/src/web/app/mobile/tags/ui-header.tag +++ /dev/null @@ -1,156 +0,0 @@ -<mk-ui-header> - <mk-special-message/> - <div class="main"> - <div class="backdrop"></div> - <div class="content"> - <button class="nav" onclick={ parent.toggleDrawer }><i class="fa fa-bars"></i></button> - <i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i> - <h1 ref="title">Misskey</h1> - <button if={ func } onclick={ func }><i class="fa fa-{ funcIcon }"></i></button> - </div> - </div> - <style> - :scope - $height = 48px - - display block - position fixed - top 0 - z-index 1024 - width 100% - box-shadow 0 1px 0 rgba(#000, 0.075) - - > .main - color rgba(#fff, 0.9) - - > .backdrop - position absolute - top 0 - z-index 1023 - width 100% - height $height - -webkit-backdrop-filter blur(12px) - backdrop-filter blur(12px) - background-color rgba(#1b2023, 0.75) - - > .content - z-index 1024 - - > h1 - display block - margin 0 auto - padding 0 - width 100% - max-width calc(100% - 112px) - text-align center - font-size 1.1em - font-weight normal - line-height $height - white-space nowrap - overflow hidden - text-overflow ellipsis - - > i - > .icon - margin-right 8px - - > img - display inline-block - vertical-align bottom - width ($height - 16px) - height ($height - 16px) - margin 8px - border-radius 6px - - > .nav - display block - position absolute - top 0 - left 0 - width $height - font-size 1.4em - line-height $height - border-right solid 1px rgba(#000, 0.1) - - > i - transition all 0.2s ease - - > i - position absolute - top 8px - left 8px - pointer-events none - font-size 10px - color $theme-color - - > button:last-child - display block - position absolute - top 0 - right 0 - width $height - text-align center - font-size 1.4em - color inherit - line-height $height - border-left solid 1px rgba(#000, 0.1) - - </style> - <script> - import ui from '../scripts/ui-event'; - - this.mixin('api'); - this.mixin('stream'); - - this.func = null; - this.funcIcon = null; - - this.on('mount', () => { - this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage); - - // Fetch count of unread messaging messages - this.api('messaging/unread').then(res => { - if (res.count > 0) { - this.update({ - hasUnreadMessagingMessages: true - }); - } - }); - }); - - this.on('unmount', () => { - this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage); - - ui.off('title', this.setTitle); - ui.off('func', this.setFunc); - }); - - this.onReadAllMessagingMessages = () => { - this.update({ - hasUnreadMessagingMessages: false - }); - }; - - this.onUnreadMessagingMessage = () => { - this.update({ - hasUnreadMessagingMessages: true - }); - }; - - this.setTitle = title => { - this.refs.title.innerHTML = title; - }; - - this.setFunc = (fn, icon) => { - this.update({ - func: fn, - funcIcon: icon - }); - }; - - ui.on('title', this.setTitle); - ui.on('func', this.setFunc); - </script> -</mk-ui-header> diff --git a/src/web/app/mobile/tags/ui-nav.tag b/src/web/app/mobile/tags/ui-nav.tag deleted file mode 100644 index 76c43ade66..0000000000 --- a/src/web/app/mobile/tags/ui-nav.tag +++ /dev/null @@ -1,169 +0,0 @@ -<mk-ui-nav> - <div class="backdrop" onclick={ parent.toggleDrawer }></div> - <div class="body"> - <a class="me" if={ SIGNIN } href={ '/' + I.username }> - <img class="avatar" src={ I.avatar_url + '?thumbnail&size=128' } alt="avatar"/> - <p class="name">{ I.name }</p> - </a> - <div class="links"> - <ul> - <li><a href="/"><i class="fa fa-home"></i>%i18n:mobile.tags.mk-ui-nav.home%<i class="fa fa-angle-right"></i></a></li> - <li><a href="/i/notifications"><i class="fa fa-bell-o"></i>%i18n:mobile.tags.mk-ui-nav.notifications%<i class="fa fa-angle-right"></i></a></li> - <li><a href="/i/messaging"><i class="fa fa-comments-o"></i>%i18n:mobile.tags.mk-ui-nav.messaging%<i class="i fa fa-circle" if={ hasUnreadMessagingMessages }></i><i class="fa fa-angle-right"></i></a></li> - </ul> - <ul> - <li><a onclick={ search }><i class="fa fa-search"></i>%i18n:mobile.tags.mk-ui-nav.search%<i class="fa fa-angle-right"></i></a></li> - </ul> - <ul> - <li><a href="/i/drive"><i class="fa fa-cloud"></i>%i18n:mobile.tags.mk-ui-nav.drive%<i class="fa fa-angle-right"></i></a></li> - </ul> - <ul> - <li><a href="/i/settings"><i class="fa fa-cog"></i>%i18n:mobile.tags.mk-ui-nav.settings%<i class="fa fa-angle-right"></i></a></li> - </ul> - </div> - <a href={ CONFIG.aboutUrl }><p class="about">%i18n:mobile.tags.mk-ui-nav.about%</p></a> - </div> - <style> - :scope - display none - - .backdrop - position fixed - top 0 - left 0 - z-index 1025 - width 100% - height 100% - background rgba(0, 0, 0, 0.2) - - .body - position fixed - top 0 - left 0 - z-index 1026 - width 240px - height 100% - overflow auto - color #777 - background #fff - - .me - display block - margin 0 - padding 16px - - .avatar - display inline - max-width 64px - border-radius 32px - vertical-align middle - - .name - display block - margin 0 16px - position absolute - top 0 - left 80px - padding 0 - width calc(100% - 112px) - color #777 - line-height 96px - overflow hidden - text-overflow ellipsis - white-space nowrap - - ul - display block - margin 16px 0 - padding 0 - list-style none - - &:first-child - margin-top 0 - - li - display block - font-size 1em - line-height 1em - - a - display block - padding 0 20px - line-height 3rem - line-height calc(1rem + 30px) - color #777 - text-decoration none - - > i:first-child - margin-right 0.5em - - > .i - margin-left 6px - vertical-align super - font-size 10px - color $theme-color - - > i:last-child - position absolute - top 0 - right 0 - padding 0 20px - font-size 1.2em - line-height calc(1rem + 30px) - color #ccc - - .about - margin 0 - padding 1em 0 - text-align center - font-size 0.8em - opacity 0.5 - - a - color #777 - - </style> - <script> - this.mixin('i'); - this.mixin('page'); - this.mixin('api'); - this.mixin('stream'); - - this.on('mount', () => { - this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage); - - // Fetch count of unread messaging messages - this.api('messaging/unread').then(res => { - if (res.count > 0) { - this.update({ - hasUnreadMessagingMessages: true - }); - } - }); - }); - - this.on('unmount', () => { - this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage); - }); - - this.onReadAllMessagingMessages = () => { - this.update({ - hasUnreadMessagingMessages: false - }); - }; - - this.onUnreadMessagingMessage = () => { - this.update({ - hasUnreadMessagingMessages: true - }); - }; - - this.search = () => { - const query = window.prompt('%i18n:mobile.tags.mk-ui-nav.search%'); - if (query == null || query == '') return; - this.page('/search:' + query); - }; - </script> -</mk-ui-nav> diff --git a/src/web/app/mobile/tags/ui.tag b/src/web/app/mobile/tags/ui.tag index b2f738dc2e..b2d96f6b8b 100644 --- a/src/web/app/mobile/tags/ui.tag +++ b/src/web/app/mobile/tags/ui.tag @@ -4,7 +4,7 @@ <div class="content"> <yield /> </div> - <mk-stream-indicator/> + <mk-stream-indicator if={ SIGNIN }/> <style> :scope display block @@ -30,9 +30,378 @@ }; this.onStreamNotification = notification => { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.stream.send({ + type: 'read_notification', + id: notification.id + }); + riot.mount(document.body.appendChild(document.createElement('mk-notify')), { notification: notification }); }; </script> </mk-ui> + +<mk-ui-header> + <mk-special-message/> + <div class="main"> + <div class="backdrop"></div> + <div class="content"> + <button class="nav" onclick={ parent.toggleDrawer }><i class="fa fa-bars"></i></button> + <i class="fa fa-circle" if={ hasUnreadNotifications || hasUnreadMessagingMessages }></i> + <h1 ref="title">Misskey</h1> + <button if={ func } onclick={ func }><i class="fa fa-{ funcIcon }"></i></button> + </div> + </div> + <style> + :scope + $height = 48px + + display block + position fixed + top 0 + z-index 1024 + width 100% + box-shadow 0 1px 0 rgba(#000, 0.075) + + > .main + color rgba(#fff, 0.9) + + > .backdrop + position absolute + top 0 + z-index 1023 + width 100% + height $height + -webkit-backdrop-filter blur(12px) + backdrop-filter blur(12px) + background-color rgba(#1b2023, 0.75) + + > .content + z-index 1024 + + > h1 + display block + margin 0 auto + padding 0 + width 100% + max-width calc(100% - 112px) + text-align center + font-size 1.1em + font-weight normal + line-height $height + white-space nowrap + overflow hidden + text-overflow ellipsis + + > i + > .icon + margin-right 8px + + > img + display inline-block + vertical-align bottom + width ($height - 16px) + height ($height - 16px) + margin 8px + border-radius 6px + + > .nav + display block + position absolute + top 0 + left 0 + width $height + font-size 1.4em + line-height $height + border-right solid 1px rgba(#000, 0.1) + + > i + transition all 0.2s ease + + > i + position absolute + top 8px + left 8px + pointer-events none + font-size 10px + color $theme-color + + > button:last-child + display block + position absolute + top 0 + right 0 + width $height + text-align center + font-size 1.4em + color inherit + line-height $height + border-left solid 1px rgba(#000, 0.1) + + </style> + <script> + import ui from '../scripts/ui-event'; + + this.mixin('api'); + this.mixin('stream'); + + this.func = null; + this.funcIcon = null; + + this.on('mount', () => { + this.stream.on('read_all_notifications', this.onReadAllNotifications); + this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage); + + // Fetch count of unread notifications + this.api('notifications/get_unread_count').then(res => { + if (res.count > 0) { + this.update({ + hasUnreadNotifications: true + }); + } + }); + + // Fetch count of unread messaging messages + this.api('messaging/unread').then(res => { + if (res.count > 0) { + this.update({ + hasUnreadMessagingMessages: true + }); + } + }); + }); + + this.on('unmount', () => { + this.stream.off('read_all_notifications', this.onReadAllNotifications); + this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage); + + ui.off('title', this.setTitle); + ui.off('func', this.setFunc); + }); + + this.onReadAllNotifications = () => { + this.update({ + hasUnreadNotifications: false + }); + }; + + this.onReadAllMessagingMessages = () => { + this.update({ + hasUnreadMessagingMessages: false + }); + }; + + this.onUnreadMessagingMessage = () => { + this.update({ + hasUnreadMessagingMessages: true + }); + }; + + this.setTitle = title => { + this.refs.title.innerHTML = title; + }; + + this.setFunc = (fn, icon) => { + this.update({ + func: fn, + funcIcon: icon + }); + }; + + ui.on('title', this.setTitle); + ui.on('func', this.setFunc); + </script> +</mk-ui-header> + +<mk-ui-nav> + <div class="backdrop" onclick={ parent.toggleDrawer }></div> + <div class="body"> + <a class="me" if={ SIGNIN } href={ '/' + I.username }> + <img class="avatar" src={ I.avatar_url + '?thumbnail&size=128' } alt="avatar"/> + <p class="name">{ I.name }</p> + </a> + <div class="links"> + <ul> + <li><a href="/"><i class="fa fa-home"></i>%i18n:mobile.tags.mk-ui-nav.home%<i class="fa fa-angle-right"></i></a></li> + <li><a href="/i/notifications"><i class="fa fa-bell-o"></i>%i18n:mobile.tags.mk-ui-nav.notifications%<i class="i fa fa-circle" if={ hasUnreadNotifications }></i><i class="fa fa-angle-right"></i></a></li> + <li><a href="/i/messaging"><i class="fa fa-comments-o"></i>%i18n:mobile.tags.mk-ui-nav.messaging%<i class="i fa fa-circle" if={ hasUnreadMessagingMessages }></i><i class="fa fa-angle-right"></i></a></li> + </ul> + <ul> + <li><a href={ CONFIG.chUrl } target="_blank"><i class="fa fa-television"></i>%i18n:mobile.tags.mk-ui-nav.ch%<i class="fa fa-angle-right"></i></a></li> + <li><a href="/i/drive"><i class="fa fa-cloud"></i>%i18n:mobile.tags.mk-ui-nav.drive%<i class="fa fa-angle-right"></i></a></li> + </ul> + <ul> + <li><a onclick={ search }><i class="fa fa-search"></i>%i18n:mobile.tags.mk-ui-nav.search%<i class="fa fa-angle-right"></i></a></li> + </ul> + <ul> + <li><a href="/i/settings"><i class="fa fa-cog"></i>%i18n:mobile.tags.mk-ui-nav.settings%<i class="fa fa-angle-right"></i></a></li> + </ul> + </div> + <a href={ CONFIG.aboutUrl }><p class="about">%i18n:mobile.tags.mk-ui-nav.about%</p></a> + </div> + <style> + :scope + display none + + .backdrop + position fixed + top 0 + left 0 + z-index 1025 + width 100% + height 100% + background rgba(0, 0, 0, 0.2) + + .body + position fixed + top 0 + left 0 + z-index 1026 + width 240px + height 100% + overflow auto + -webkit-overflow-scrolling touch + color #777 + background #fff + + .me + display block + margin 0 + padding 16px + + .avatar + display inline + max-width 64px + border-radius 32px + vertical-align middle + + .name + display block + margin 0 16px + position absolute + top 0 + left 80px + padding 0 + width calc(100% - 112px) + color #777 + line-height 96px + overflow hidden + text-overflow ellipsis + white-space nowrap + + ul + display block + margin 16px 0 + padding 0 + list-style none + + &:first-child + margin-top 0 + + li + display block + font-size 1em + line-height 1em + + a + display block + padding 0 20px + line-height 3rem + line-height calc(1rem + 30px) + color #777 + text-decoration none + + > i:first-child + margin-right 0.5em + + > .i + margin-left 6px + vertical-align super + font-size 10px + color $theme-color + + > i:last-child + position absolute + top 0 + right 0 + padding 0 20px + font-size 1.2em + line-height calc(1rem + 30px) + color #ccc + + .about + margin 0 + padding 1em 0 + text-align center + font-size 0.8em + opacity 0.5 + + a + color #777 + + </style> + <script> + this.mixin('i'); + this.mixin('page'); + this.mixin('api'); + this.mixin('stream'); + + this.on('mount', () => { + this.stream.on('read_all_notifications', this.onReadAllNotifications); + this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage); + + // Fetch count of unread notifications + this.api('notifications/get_unread_count').then(res => { + if (res.count > 0) { + this.update({ + hasUnreadNotifications: true + }); + } + }); + + // Fetch count of unread messaging messages + this.api('messaging/unread').then(res => { + if (res.count > 0) { + this.update({ + hasUnreadMessagingMessages: true + }); + } + }); + }); + + this.on('unmount', () => { + this.stream.off('read_all_notifications', this.onReadAllNotifications); + this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage); + }); + + this.onReadAllNotifications = () => { + this.update({ + hasUnreadNotifications: false + }); + }; + + this.onReadAllMessagingMessages = () => { + this.update({ + hasUnreadMessagingMessages: false + }); + }; + + this.onUnreadMessagingMessage = () => { + this.update({ + hasUnreadMessagingMessages: true + }); + }; + + this.search = () => { + const query = window.prompt('%i18n:mobile.tags.mk-ui-nav.search%'); + if (query == null || query == '') return; + this.page('/search:' + query); + }; + </script> +</mk-ui-nav> diff --git a/src/web/app/mobile/tags/user-card.tag b/src/web/app/mobile/tags/user-card.tag new file mode 100644 index 0000000000..d0c79698c5 --- /dev/null +++ b/src/web/app/mobile/tags/user-card.tag @@ -0,0 +1,55 @@ +<mk-user-card> + <header style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=1024)' : '' }> + <a href={ '/' + user.username }> + <img src={ user.avatar_url + '?thumbnail&size=200' } alt="avatar"/> + </a> + </header> + <a class="name" href={ '/' + user.username } target="_blank">{ user.name }</a> + <p class="username">@{ user.username }</p> + <mk-follow-button user={ user }/> + <style> + :scope + display inline-block + width 200px + text-align center + border-radius 8px + background #fff + + > header + display block + height 80px + background-color #ddd + background-size cover + background-position center + border-radius 8px 8px 0 0 + + > a + > img + position absolute + top 20px + left calc(50% - 40px) + width 80px + height 80px + border solid 2px #fff + border-radius 8px + + > .name + display block + margin 24px 0 0 0 + font-size 16px + color #555 + + > .username + margin 0 + font-size 15px + color #ccc + + > mk-follow-button + display inline-block + margin 8px 0 16px 0 + + </style> + <script> + this.user = this.opts.user; + </script> +</mk-user-card> diff --git a/src/web/app/mobile/tags/user-timeline.tag b/src/web/app/mobile/tags/user-timeline.tag index f7b2b36da0..4dbe719f5a 100644 --- a/src/web/app/mobile/tags/user-timeline.tag +++ b/src/web/app/mobile/tags/user-timeline.tag @@ -5,8 +5,6 @@ display block max-width 600px margin 0 auto - background #fff - </style> <script> this.mixin('api'); diff --git a/src/web/app/mobile/tags/user.tag b/src/web/app/mobile/tags/user.tag index 81eb6ba2e4..a332e930e2 100644 --- a/src/web/app/mobile/tags/user.tag +++ b/src/web/app/mobile/tags/user.tag @@ -12,7 +12,7 @@ <div class="title"> <h1>{ user.name }</h1> <span class="username">@{ user.username }</span> - <span class="followed" if={ user.is_followed }>%i18n:mobile.tags.mk-user.is-followed%</span> + <span class="followed" if={ user.is_followed }>%i18n:mobile.tags.mk-user.follows-you%</span> </div> <div class="description">{ user.description }</div> <div class="info"> @@ -26,7 +26,7 @@ <div class="status"> <a> <b>{ user.posts_count }</b> - <i>%i18n:mobile.tags.mk-user.posts-count%</i> + <i>%i18n:mobile.tags.mk-user.posts%</i> </a> <a href="{ user.username }/following"> <b>{ user.following_count }</b> @@ -37,14 +37,15 @@ <i>%i18n:mobile.tags.mk-user.followers%</i> </a> </div> - <mk-activity-table user={ user }/> </div> <nav> - <a data-is-active={ page == 'posts' } onclick={ go.bind(null, 'posts') }>%i18n:mobile.tags.mk-user.posts%</a> + <a data-is-active={ page == 'overview' } onclick={ go.bind(null, 'overview') }>%i18n:mobile.tags.mk-user.overview%</a> + <a data-is-active={ page == 'posts' } onclick={ go.bind(null, 'posts') }>%i18n:mobile.tags.mk-user.timeline%</a> <a data-is-active={ page == 'media' } onclick={ go.bind(null, 'media') }>%i18n:mobile.tags.mk-user.media%</a> </nav> </header> <div class="body"> + <mk-user-overview if={ page == 'overview' } user={ user }/> <mk-user-timeline if={ page == 'posts' } user={ user }/> <mk-user-timeline if={ page == 'media' } user={ user } with-media={ true }/> </div> @@ -55,9 +56,11 @@ > .user > header + box-shadow 0 4px 4px rgba(0, 0, 0, 0.3) + > .banner padding-bottom 33.3% - background-color #f5f5f5 + background-color #1b1b1b background-size cover background-position center @@ -84,13 +87,13 @@ left -2px bottom -2px width 100% - border 2px solid #fff + border 2px solid #313a42 border-radius 6px @media (min-width 500px) left -4px bottom -4px - border 4px solid #fff + border 4px solid #313a42 border-radius 12px > mk-follow-button @@ -104,7 +107,7 @@ margin 0 line-height 22px font-size 20px - color #222 + color #fff > .username display inline-block @@ -123,7 +126,7 @@ > .description margin 8px 0 - color #333 + color #fff > .info margin 8px 0 @@ -131,7 +134,7 @@ > p display inline margin 0 16px 0 0 - color #555 + color #a9b9c1 > i margin-right 4px @@ -140,13 +143,13 @@ > a color #657786 - &:first-child + &:not(:last-child) margin-right 16px > b margin-right 4px font-size 16px - color #14171a + color #fff > i font-size 14px @@ -159,7 +162,6 @@ justify-content center margin 0 auto max-width 600px - border-bottom solid 1px #ddd > a display block @@ -177,8 +179,10 @@ border-color $theme-color > .body + padding 8px + @media (min-width 500px) - padding 16px 0 0 0 + padding 16px </style> <script> @@ -188,7 +192,7 @@ this.mixin('api'); this.username = this.opts.user; - this.page = this.opts.page ? this.opts.page : 'posts'; + this.page = this.opts.page ? this.opts.page : 'overview'; this.fetching = true; this.on('mount', () => { @@ -209,3 +213,523 @@ }; </script> </mk-user> + +<mk-user-overview> + <mk-post-detail if={ user.pinned_post } post={ user.pinned_post } compact={ true }/> + <section class="recent-posts"> + <h2><i class="fa fa-comments-o"></i>%i18n:mobile.tags.mk-user-overview.recent-posts%</h2> + <div> + <mk-user-overview-posts user={ user }/> + </div> + </section> + <section class="images"> + <h2><i class="fa fa-picture-o"></i>%i18n:mobile.tags.mk-user-overview.images%</h2> + <div> + <mk-user-overview-photos user={ user }/> + </div> + </section> + <section class="activity"> + <h2><i class="fa fa-bar-chart"></i>%i18n:mobile.tags.mk-user-overview.activity%</h2> + <div> + <mk-user-overview-activity-chart user={ user }/> + </div> + </section> + <section class="keywords"> + <h2><i class="fa fa-comment-o"></i>%i18n:mobile.tags.mk-user-overview.keywords%</h2> + <div> + <mk-user-overview-keywords user={ user }/> + </div> + </section> + <section class="domains"> + <h2><i class="fa fa-globe"></i>%i18n:mobile.tags.mk-user-overview.domains%</h2> + <div> + <mk-user-overview-domains user={ user }/> + </div> + </section> + <section class="frequently-replied-users"> + <h2><i class="fa fa-users"></i>%i18n:mobile.tags.mk-user-overview.frequently-replied-users%</h2> + <div> + <mk-user-overview-frequently-replied-users user={ user }/> + </div> + </section> + <section class="followers-you-know" if={ SIGNIN && I.id !== user.id }> + <h2><i class="fa fa-users"></i>%i18n:mobile.tags.mk-user-overview.followers-you-know%</h2> + <div> + <mk-user-overview-followers-you-know user={ user }/> + </div> + </section> + <p>%i18n:mobile.tags.mk-user-overview.last-used-at%: <b><mk-time time={ user.last_used_at }/></b></p> + <style> + :scope + display block + max-width 600px + margin 0 auto + + > mk-post-detail + margin 0 0 8px 0 + + > section + background #eee + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + &:not(:last-child) + margin-bottom 8px + + > h2 + margin 0 + padding 8px 10px + font-size 15px + font-weight normal + color #465258 + background #fff + border-radius 8px 8px 0 0 + + > i + margin-right 6px + + > .activity + > div + padding 8px + + > p + display block + margin 16px + text-align center + color #cad2da + + </style> + <script> + this.mixin('i'); + + this.user = this.opts.user; + </script> +</mk-user-overview> + +<mk-user-overview-posts> + <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:mobile.tags.mk-user-overview-posts.loading%<mk-ellipsis/></p> + <div if={ !initializing && posts.length > 0 }> + <virtual each={ posts }> + <mk-user-overview-posts-post-card post={ this }/> + </virtual> + </div> + <p class="empty" if={ !initializing && posts.length == 0 }>%i18n:mobile.tags.mk-user-overview-posts.no-posts%</p> + <style> + :scope + display block + + > div + overflow-x scroll + -webkit-overflow-scrolling touch + white-space nowrap + padding 8px + + > * + vertical-align top + + &:not(:last-child) + margin-right 8px + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + + </style> + <script> + this.mixin('api'); + + this.user = this.opts.user; + this.initializing = true; + + this.on('mount', () => { + this.api('users/posts', { + user_id: this.user.id + }).then(posts => { + this.update({ + posts: posts, + initializing: false + }); + }); + }); + </script> +</mk-user-overview-posts> + +<mk-user-overview-posts-post-card> + <a href={ '/' + post.user.username + '/' + post.id }> + <header> + <img src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/><h3>{ post.user.name }</h3> + </header> + <div> + { text } + </div> + <mk-time time={ post.created_at }/> + </a> + <style> + :scope + display inline-block + width 150px + //height 120px + font-size 12px + background #fff + border-radius 4px + + > a + display block + color #2c3940 + + &:hover + text-decoration none + + > header + > img + position absolute + top 8px + left 8px + width 28px + height 28px + border-radius 6px + + > h3 + display inline-block + overflow hidden + width calc(100% - 45px) + margin 8px 0 0 42px + line-height 28px + white-space nowrap + text-overflow ellipsis + font-size 12px + + > div + padding 2px 8px 8px 8px + height 60px + overflow hidden + white-space normal + + &:after + content "" + display block + position absolute + top 40px + left 0 + width 100% + height 20px + background linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, #fff 100%) + + > mk-time + display inline-block + padding 8px + color #aaa + + </style> + <script> + import summary from '../../../../common/get-post-summary.ts'; + + this.post = this.opts.post; + this.text = summary(this.post); + </script> +</mk-user-overview-posts-post-card> + +<mk-user-overview-photos> + <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:mobile.tags.mk-user-overview-photos.loading%<mk-ellipsis/></p> + <div class="stream" if={ !initializing && images.length > 0 }> + <virtual each={ image in images }> + <a class="img" style={ 'background-image: url(' + image.media.url + '?thumbnail&size=256)' } href={ '/' + image.post.user.username + '/' + image.post.id }></a> + </virtual> + </div> + <p class="empty" if={ !initializing && images.length == 0 }>%i18n:mobile.tags.mk-user-overview-photos.no-photos%</p> + <style> + :scope + display block + + > .stream + display -webkit-flex + display -moz-flex + display -ms-flex + display flex + justify-content center + flex-wrap wrap + padding 8px + + > .img + flex 1 1 33% + width 33% + height 80px + background-position center center + background-size cover + background-clip content-box + border solid 2px transparent + border-radius 4px + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + + </style> + <script> + this.mixin('api'); + + this.images = []; + this.initializing = true; + this.user = this.opts.user; + + this.on('mount', () => { + this.api('users/posts', { + user_id: this.user.id, + with_media: true, + limit: 6 + }).then(posts => { + this.initializing = false; + posts.forEach(post => { + post.media.forEach(media => { + if (this.images.length < 9) this.images.push({ + post, + media + }); + }); + }); + this.update(); + }); + }); + </script> +</mk-user-overview-photos> + +<mk-user-overview-activity-chart> + <svg if={ data } ref="canvas" viewBox="0 0 30 1" preserveAspectRatio="none"> + <g each={ d, i in data.reverse() }> + <rect width="0.8" riot-height={ d.postsH } + riot-x={ i + 0.1 } riot-y={ 1 - d.postsH - d.repliesH - d.repostsH } + fill="#41ddde"/> + <rect width="0.8" riot-height={ d.repliesH } + riot-x={ i + 0.1 } riot-y={ 1 - d.repliesH - d.repostsH } + fill="#f7796c"/> + <rect width="0.8" riot-height={ d.repostsH } + riot-x={ i + 0.1 } riot-y={ 1 - d.repostsH } + fill="#a1de41"/> + </g> + </svg> + <style> + :scope + display block + max-width 600px + margin 0 auto + + > svg + display block + width 100% + height 80px + + > rect + transform-origin center + + </style> + <script> + this.mixin('api'); + + this.user = this.opts.user; + + this.on('mount', () => { + this.api('aggregation/users/activity', { + user_id: this.user.id, + limit: 30 + }).then(data => { + data.forEach(d => d.total = d.posts + d.replies + d.reposts); + this.peak = Math.max.apply(null, data.map(d => d.total)); + data.forEach(d => { + d.postsH = d.posts / this.peak; + d.repliesH = d.replies / this.peak; + d.repostsH = d.reposts / this.peak; + }); + this.update({ data }); + }); + }); + </script> +</mk-user-overview-activity-chart> + +<mk-user-overview-keywords> + <div if={ user.keywords != null && user.keywords.length > 1 }> + <virtual each={ keyword in user.keywords }> + <a>{ keyword }</a> + </virtual> + </div> + <p class="empty" if={ user.keywords == null || user.keywords.length == 0 }>%i18n:mobile.tags.mk-user-overview-keywords.no-keywords%</p> + <style> + :scope + display block + + > div + padding 4px + + > a + display inline-block + margin 4px + color #555 + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + + </style> + <script> + this.user = this.opts.user; + </script> +</mk-user-overview-keywords> + +<mk-user-overview-domains> + <div if={ user.domains != null && user.domains.length > 1 }> + <virtual each={ domain in user.domains }> + <a style="opacity: { 0.5 + (domain.weight / 2) }">{ domain.domain }</a> + </virtual> + </div> + <p class="empty" if={ user.domains == null || user.domains.length == 0 }>%i18n:mobile.tags.mk-user-overview-domains.no-domains%</p> + <style> + :scope + display block + + > div + padding 4px + + > a + display inline-block + margin 4px + color #555 + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + + </style> + <script> + this.user = this.opts.user; + </script> +</mk-user-overview-domains> + +<mk-user-overview-frequently-replied-users> + <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:mobile.tags.mk-user-overview-frequently-replied-users.loading%<mk-ellipsis/></p> + <div if={ !initializing && users.length > 0 }> + <virtual each={ users }> + <mk-user-card user={ this.user }/> + </virtual> + </div> + <p class="empty" if={ !initializing && users.length == 0 }>%i18n:mobile.tags.mk-user-overview-frequently-replied-users.no-users%</p> + <style> + :scope + display block + + > div + overflow-x scroll + -webkit-overflow-scrolling touch + white-space nowrap + padding 8px + + > mk-user-card + &:not(:last-child) + margin-right 8px + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + + </style> + <script> + this.mixin('api'); + + this.user = this.opts.user; + this.initializing = true; + + this.on('mount', () => { + this.api('users/get_frequently_replied_users', { + user_id: this.user.id + }).then(x => { + this.update({ + users: x, + initializing: false + }); + }); + }); + </script> +</mk-user-overview-frequently-replied-users> + +<mk-user-overview-followers-you-know> + <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:mobile.tags.mk-user-overview-followers-you-know.loading%<mk-ellipsis/></p> + <div if={ !initializing && users.length > 0 }> + <virtual each={ user in users }> + <a href={ '/' + user.username }><img src={ user.avatar_url + '?thumbnail&size=64' } alt={ user.name }/></a> + </virtual> + </div> + <p class="empty" if={ !initializing && users.length == 0 }>%i18n:mobile.tags.mk-user-overview-followers-you-know.no-users%</p> + <style> + :scope + display block + + > div + padding 4px + + > a + display inline-block + margin 4px + + > img + width 48px + height 48px + vertical-align bottom + border-radius 100% + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + + </style> + <script> + this.mixin('api'); + + this.user = this.opts.user; + this.initializing = true; + + this.on('mount', () => { + this.api('users/followers', { + user_id: this.user.id, + iknow: true, + limit: 30 + }).then(x => { + this.update({ + users: x.users, + initializing: false + }); + }); + }); + </script> +</mk-user-overview-followers-you-know> diff --git a/src/web/app/mobile/tags/users-list.tag b/src/web/app/mobile/tags/users-list.tag index fb70f184d5..295ae06694 100644 --- a/src/web/app/mobile/tags/users-list.tag +++ b/src/web/app/mobile/tags/users-list.tag @@ -14,14 +14,13 @@ <style> :scope display block - background #fff > nav display flex justify-content center margin 0 auto max-width 600px - border-bottom solid 1px #ddd + border-bottom solid 1px rgba(0, 0, 0, 0.2) > span display block @@ -43,14 +42,23 @@ padding 2px 5px font-size 12px line-height 1 - color #888 - background #eee + color #fff + background rgba(0, 0, 0, 0.3) border-radius 20px > .users + margin 8px auto + max-width 500px + width calc(100% - 16px) + background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + @media (min-width 500px) + margin 16px auto + width calc(100% - 32px) + > * - max-width 600px - margin 0 auto border-bottom solid 1px rgba(0, 0, 0, 0.05) > .no diff --git a/src/web/app/reset.styl b/src/web/app/reset.styl index 940a9ed18e..3d4b06dbdf 100644 --- a/src/web/app/reset.styl +++ b/src/web/app/reset.styl @@ -1,19 +1,7 @@ -* - position relative - box-sizing border-box - background-clip padding-box !important - -html -body - margin 0 - padding 0 - -body - overflow-wrap break-word - input:not([type]) input[type='text'] input[type='password'] +input[type='search'] input[type='email'] textarea button diff --git a/src/web/app/safe.js b/src/web/app/safe.js index c5fbb83a92..77293be81d 100644 --- a/src/web/app/safe.js +++ b/src/web/app/safe.js @@ -7,5 +7,8 @@ if (!('fetch' in window)) { alert( 'お使いのブラウザが古いためMisskeyを動作させることができません。' + - 'バージョンを最新のものに更新するか、別のブラウザをお試しください。'); + 'バージョンを最新のものに更新するか、別のブラウザをお試しください。' + + '\n\n' + + 'Your browser seems outdated.' + + 'To run Misskey, please update your browser to latest version or try other browsers.'); } diff --git a/src/web/app/stats/style.styl b/src/web/app/stats/style.styl index b48d7aeb9e..5ae230ea56 100644 --- a/src/web/app/stats/style.styl +++ b/src/web/app/stats/style.styl @@ -1,4 +1,5 @@ -@import "../base" +@import "../app" +@import "../reset" html color #456267 diff --git a/src/web/app/status/style.styl b/src/web/app/status/style.styl index b48d7aeb9e..5ae230ea56 100644 --- a/src/web/app/status/style.styl +++ b/src/web/app/status/style.styl @@ -1,4 +1,5 @@ -@import "../base" +@import "../app" +@import "../reset" html color #456267 diff --git a/test/api.js b/test/api.js index 9e1d4ff61b..c0da9d6c5b 100644 --- a/test/api.js +++ b/test/api.js @@ -53,8 +53,6 @@ describe('API', () => { db.get('auth_sessions').drop() ])); - afterEach(cb => setTimeout(cb, 100)); - it('greet server', done => { _chai.request(server) .get('/') @@ -279,15 +277,15 @@ describe('API', () => { const me = await insertSakurako(); const post = { text: 'さく', - reply_to_id: himaPost._id.toString() + reply_id: himaPost._id.toString() }; const res = await request('/posts/create', post, me); res.should.have.status(200); res.body.should.be.a('object'); res.body.should.have.property('text').eql(post.text); - res.body.should.have.property('reply_to_id').eql(post.reply_to_id); - res.body.should.have.property('reply_to'); - res.body.reply_to.should.have.property('text').eql(himaPost.text); + res.body.should.have.property('reply_id').eql(post.reply_id); + res.body.should.have.property('reply'); + res.body.reply.should.have.property('text').eql(himaPost.text); })); it('repostできる', async(async () => { @@ -352,7 +350,7 @@ describe('API', () => { const me = await insertSakurako(); const post = { text: 'さく', - reply_to_id: '000000000000000000000000' + reply_id: '000000000000000000000000' }; const res = await request('/posts/create', post, me); res.should.have.status(400); @@ -371,7 +369,7 @@ describe('API', () => { const me = await insertSakurako(); const post = { text: 'さく', - reply_to_id: 'kyoppie' + reply_id: 'kyoppie' }; const res = await request('/posts/create', post, me); res.should.have.status(400); @@ -1154,9 +1152,12 @@ async function insertHimawari(opts) { } async function insertDriveFile(opts) { - return await db.get('drive_files').insert(Object.assign({ - name: 'strawberry-pasta.png' - }, opts)); + return await db.get('drive_files.files').insert({ + length: opts.datasize, + metadata: Object.assign({ + name: 'strawberry-pasta.png' + }, opts) + }); } async function insertDriveFolder(opts) { diff --git a/test/mocha.opts b/test/mocha.opts index cf80ee74bc..907011807d 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -1 +1 @@ ---timeout 5000 +--timeout 10000 diff --git a/tools/migration/reply_to-to-reply.js b/tools/migration/reply_to-to-reply.js new file mode 100644 index 0000000000..ceb272ebc9 --- /dev/null +++ b/tools/migration/reply_to-to-reply.js @@ -0,0 +1,5 @@ +db.posts.update({}, { + $rename: { + reply_to_id: 'reply_id' + } +}, false, true); diff --git a/tools/migration/use-gridfs.js b/tools/migration/use-gridfs.js new file mode 100644 index 0000000000..d41514416c --- /dev/null +++ b/tools/migration/use-gridfs.js @@ -0,0 +1,49 @@ +// for Node.js interpret + +const { default: db } = require('../../built/db/mongodb') +const { default: DriveFile, getGridFSBucket } = require('../../built/api/models/drive-file') +const { Duplex } = require('stream') + +const writeToGridFS = (bucket, buffer, ...rest) => new Promise((resolve, reject) => { + const writeStream = bucket.openUploadStreamWithId(...rest) + + const dataStream = new Duplex() + dataStream.push(buffer) + dataStream.push(null) + + writeStream.once('finish', resolve) + writeStream.on('error', reject) + + dataStream.pipe(writeStream) +}) + +const migrateToGridFS = async (doc) => { + const id = doc._id + const buffer = doc.data.buffer + const created_at = doc.created_at + + delete doc._id + delete doc.created_at + delete doc.datasize + delete doc.hash + delete doc.data + + const bucket = await getGridFSBucket() + const added = await writeToGridFS(bucket, buffer, id, `${id}/${doc.name}`, { metadata: doc }) + + const result = await DriveFile.update(id, { + $set: { + uploadDate: created_at + } + }) + + return added && result.ok === 1 +} + +const main = async () => { + const docs = await db.get('drive_files').find() + const all = await Promise.all(docs.map(migrateToGridFS)) + return all +} + +main().then(console.dir).catch(console.error) diff --git a/tsconfig.json b/tsconfig.json index 064a04e4d2..a38ff220b2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "allowJs": true, "noEmitOnError": false, "noImplicitAny": false, "noImplicitReturns": true, diff --git a/tslint.json b/tslint.json index dfd8309675..1c44579512 100644 --- a/tslint.json +++ b/tslint.json @@ -16,6 +16,7 @@ "ordered-imports": [false], "arrow-parens": false, "object-literal-shorthand": false, + "object-literal-key-quotes": false, "triple-equals": [false], "no-shadowed-variable": false, "no-string-literal": false, @@ -23,6 +24,7 @@ "comment-format": [false], "interface-over-type-literal": false, "max-line-length": [false], + "max-classes-per-file": false, "member-ordering": [false], "ban-types": [ "Object" diff --git a/webpack/module/rules/base64.ts b/webpack/module/rules/base64.ts new file mode 100644 index 0000000000..529816bd20 --- /dev/null +++ b/webpack/module/rules/base64.ts @@ -0,0 +1,19 @@ +/** + * Replace base64 symbols + */ + +import * as fs from 'fs'; +const StringReplacePlugin = require('string-replace-webpack-plugin'); + +export default () => ({ + enforce: 'pre', + test: /\.(tag|js)$/, + exclude: /node_modules/, + loader: StringReplacePlugin.replace({ + replacements: [{ + pattern: /%base64:(.+?)%/g, replacement: (_, key) => { + return fs.readFileSync(__dirname + '/../../../src/web/' + key, 'base64'); + } + }] + }) +}); diff --git a/webpack/module/rules/i18n.ts b/webpack/module/rules/i18n.ts index 3023253cab..9a4acde686 100644 --- a/webpack/module/rules/i18n.ts +++ b/webpack/module/rules/i18n.ts @@ -4,34 +4,46 @@ const StringReplacePlugin = require('string-replace-webpack-plugin'); -export default (lang, locale) => ({ - enforce: 'pre', - test: /\.(tag|js)$/, - exclude: /node_modules/, - loader: StringReplacePlugin.replace({ - replacements: [ - { - pattern: /%i18n:(.+?)%/g, replacement: (_, key) => { - let text = locale; - - // Check the key existance - const error = key.split('.').some(k => { - if (text.hasOwnProperty(k)) { - text = text[k]; - return false; - } else { - return true; - } - }); - - if (error) { - console.warn(`key '${key}' not found in '${lang}'`); - return key; // Fallback - } else { - return text.replace(/'/g, '\\\'').replace(/"/g, '\\"'); - } - } +export default (lang, locale) => { + function get(key: string) { + let text = locale; + + // Check the key existance + const error = key.split('.').some(k => { + if (text.hasOwnProperty(k)) { + text = text[k]; + return false; + } else { + return true; } - ] - }) -}); + }); + + if (error) { + console.warn(`key '${key}' not found in '${lang}'`); + return key; // Fallback + } else { + return text; + } + } + + return { + enforce: 'pre', + test: /\.(tag|js)$/, + exclude: /node_modules/, + loader: StringReplacePlugin.replace({ + replacements: [{ + pattern: /"%i18n:(.+?)%"/g, replacement: (_, key) => { + return '"' + get(key).replace(/"/g, '\\"') + '"'; + } + }, { + pattern: /'%i18n:(.+?)%'/g, replacement: (_, key) => { + return '\'' + get(key).replace(/'/g, '\\\'') + '\''; + } + }, { + pattern: /%i18n:(.+?)%/g, replacement: (_, key) => { + return get(key); + } + }] + }) + }; +}; diff --git a/webpack/module/rules/index.ts b/webpack/module/rules/index.ts index 2308f4e535..9c1262b3d6 100644 --- a/webpack/module/rules/index.ts +++ b/webpack/module/rules/index.ts @@ -1,11 +1,15 @@ import i18n from './i18n'; +import base64 from './base64'; import themeColor from './theme-color'; import tag from './tag'; import stylus from './stylus'; +import typescript from './typescript'; export default (lang, locale) => [ i18n(lang, locale), + base64(), themeColor(), tag(), - stylus() + stylus(), + typescript() ]; diff --git a/webpack/module/rules/typescript.ts b/webpack/module/rules/typescript.ts new file mode 100644 index 0000000000..eb2b279a55 --- /dev/null +++ b/webpack/module/rules/typescript.ts @@ -0,0 +1,8 @@ +/** + * TypeScript + */ + +export default () => ({ + test: /\.ts$/, + use: 'awesome-typescript-loader' +}); diff --git a/webpack/plugins/const.ts b/webpack/plugins/const.ts index ccfcb45260..f64160b01a 100644 --- a/webpack/plugins/const.ts +++ b/webpack/plugins/const.ts @@ -7,7 +7,8 @@ import * as webpack from 'webpack'; import version from '../../src/version'; const constants = require('../../src/const.json'); -export default () => new webpack.DefinePlugin({ +export default lang => new webpack.DefinePlugin({ VERSION: JSON.stringify(version), + LANG: JSON.stringify(lang), THEME_COLOR: JSON.stringify(constants.themeColor) }); diff --git a/webpack/plugins/index.ts b/webpack/plugins/index.ts index 99b16c2b05..345af7df9e 100644 --- a/webpack/plugins/index.ts +++ b/webpack/plugins/index.ts @@ -2,25 +2,23 @@ const StringReplacePlugin = require('string-replace-webpack-plugin'); import constant from './const'; import hoist from './hoist'; -//import minify from './minify'; +import minify from './minify'; import banner from './banner'; -/* const env = process.env.NODE_ENV; const isProduction = env === 'production'; -*/ -export default version => { +export default (version, lang) => { const plugins = [ - constant(), + constant(lang), new StringReplacePlugin(), hoist() ]; -/* + if (isProduction) { plugins.push(minify()); } -*/ + plugins.push(banner(version)); return plugins; diff --git a/webpack/plugins/minify.ts b/webpack/plugins/minify.ts index ec4c9b3405..e46d4c5a10 100644 --- a/webpack/plugins/minify.ts +++ b/webpack/plugins/minify.ts @@ -1,3 +1,3 @@ -const UglifyEsPlugin = require('uglify-es-webpack-plugin'); +const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); -export default () => new UglifyEsPlugin(); +export default () => new UglifyJsPlugin(); diff --git a/webpack/webpack.config.ts b/webpack/webpack.config.ts index 5199285d55..97782a4102 100644 --- a/webpack/webpack.config.ts +++ b/webpack/webpack.config.ts @@ -16,6 +16,7 @@ module.exports = langs.map(([lang, locale]) => { const entry = { desktop: './src/web/app/desktop/script.js', mobile: './src/web/app/mobile/script.js', + ch: './src/web/app/ch/script.js', stats: './src/web/app/stats/script.js', status: './src/web/app/status/script.js', dev: './src/web/app/dev/script.js', @@ -31,7 +32,7 @@ module.exports = langs.map(([lang, locale]) => { name, entry, module: module_(lang, locale), - plugins: plugins(version), + plugins: plugins(version, lang), output }; });