summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorsyuilo <syuilotan@yahoo.co.jp>2016-12-29 07:49:51 +0900
committersyuilo <syuilotan@yahoo.co.jp>2016-12-29 07:49:51 +0900
commitb3f42e62af698a67c2250533c437569559f1fdf9 (patch)
treecdf6937576e99cccf85e6fa3aa8860a1173c7cfb /src
downloadsharkey-b3f42e62af698a67c2250533c437569559f1fdf9.tar.gz
sharkey-b3f42e62af698a67c2250533c437569559f1fdf9.tar.bz2
sharkey-b3f42e62af698a67c2250533c437569559f1fdf9.zip
Initial commit :four_leaf_clover:
Diffstat (limited to 'src')
-rw-r--r--src/api/api-handler.ts55
-rw-r--r--src/api/authenticate.ts61
-rw-r--r--src/api/common/add-file-to-drive.ts149
-rw-r--r--src/api/common/get-friends.ts25
-rw-r--r--src/api/common/notify.ts32
-rw-r--r--src/api/endpoints.ts101
-rw-r--r--src/api/endpoints/aggregation/posts/like.js83
-rw-r--r--src/api/endpoints/aggregation/posts/likes.js76
-rw-r--r--src/api/endpoints/aggregation/posts/reply.js82
-rw-r--r--src/api/endpoints/aggregation/posts/repost.js82
-rw-r--r--src/api/endpoints/aggregation/users/followers.js77
-rw-r--r--src/api/endpoints/aggregation/users/following.js76
-rw-r--r--src/api/endpoints/aggregation/users/like.js83
-rw-r--r--src/api/endpoints/aggregation/users/post.js113
-rw-r--r--src/api/endpoints/app/create.js75
-rw-r--r--src/api/endpoints/app/name_id/available.js40
-rw-r--r--src/api/endpoints/app/show.js51
-rw-r--r--src/api/endpoints/auth/accept.js64
-rw-r--r--src/api/endpoints/auth/session/generate.js51
-rw-r--r--src/api/endpoints/auth/session/show.js36
-rw-r--r--src/api/endpoints/auth/session/userkey.js74
-rw-r--r--src/api/endpoints/drive.js33
-rw-r--r--src/api/endpoints/drive/files.js82
-rw-r--r--src/api/endpoints/drive/files/create.js59
-rw-r--r--src/api/endpoints/drive/files/find.js48
-rw-r--r--src/api/endpoints/drive/files/show.js40
-rw-r--r--src/api/endpoints/drive/files/update.js89
-rw-r--r--src/api/endpoints/drive/folders.js82
-rw-r--r--src/api/endpoints/drive/folders/create.js79
-rw-r--r--src/api/endpoints/drive/folders/find.js46
-rw-r--r--src/api/endpoints/drive/folders/show.js41
-rw-r--r--src/api/endpoints/drive/folders/update.js114
-rw-r--r--src/api/endpoints/drive/stream.js85
-rw-r--r--src/api/endpoints/following/create.js86
-rw-r--r--src/api/endpoints/following/delete.js83
-rw-r--r--src/api/endpoints/i.js25
-rw-r--r--src/api/endpoints/i/appdata/get.js53
-rw-r--r--src/api/endpoints/i/appdata/set.js55
-rw-r--r--src/api/endpoints/i/favorites.js60
-rw-r--r--src/api/endpoints/i/notifications.js120
-rw-r--r--src/api/endpoints/i/signin_history.js71
-rw-r--r--src/api/endpoints/i/update.js95
-rw-r--r--src/api/endpoints/messaging/history.js48
-rw-r--r--src/api/endpoints/messaging/messages.js139
-rw-r--r--src/api/endpoints/messaging/messages/create.js152
-rw-r--r--src/api/endpoints/messaging/unread.js27
-rw-r--r--src/api/endpoints/meta.js24
-rw-r--r--src/api/endpoints/my/apps.js59
-rw-r--r--src/api/endpoints/notifications/mark_as_read.js54
-rw-r--r--src/api/endpoints/posts.js65
-rw-r--r--src/api/endpoints/posts/context.js83
-rw-r--r--src/api/endpoints/posts/create.js345
-rw-r--r--src/api/endpoints/posts/favorites/create.js56
-rw-r--r--src/api/endpoints/posts/favorites/delete.js52
-rw-r--r--src/api/endpoints/posts/likes.js77
-rw-r--r--src/api/endpoints/posts/likes/create.js93
-rw-r--r--src/api/endpoints/posts/likes/delete.js80
-rw-r--r--src/api/endpoints/posts/mentions.js85
-rw-r--r--src/api/endpoints/posts/replies.js73
-rw-r--r--src/api/endpoints/posts/reposts.js85
-rw-r--r--src/api/endpoints/posts/search.js138
-rw-r--r--src/api/endpoints/posts/show.js40
-rw-r--r--src/api/endpoints/posts/timeline.js78
-rw-r--r--src/api/endpoints/username/available.js41
-rw-r--r--src/api/endpoints/users.js67
-rw-r--r--src/api/endpoints/users/followers.js102
-rw-r--r--src/api/endpoints/users/following.js102
-rw-r--r--src/api/endpoints/users/posts.js114
-rw-r--r--src/api/endpoints/users/recommendation.js61
-rw-r--r--src/api/endpoints/users/search.js116
-rw-r--r--src/api/endpoints/users/search_by_username.js65
-rw-r--r--src/api/endpoints/users/show.js49
-rw-r--r--src/api/event.ts36
-rw-r--r--src/api/limitter.ts69
-rw-r--r--src/api/models/app.ts7
-rw-r--r--src/api/models/appdata.ts1
-rw-r--r--src/api/models/auth-session.ts1
-rw-r--r--src/api/models/drive-file.ts11
-rw-r--r--src/api/models/drive-folder.ts8
-rw-r--r--src/api/models/drive-tag.ts1
-rw-r--r--src/api/models/favorite.ts1
-rw-r--r--src/api/models/following.ts1
-rw-r--r--src/api/models/like.ts1
-rw-r--r--src/api/models/messaging-history.ts1
-rw-r--r--src/api/models/messaging-message.ts1
-rw-r--r--src/api/models/notification.ts1
-rw-r--r--src/api/models/post.ts1
-rw-r--r--src/api/models/signin.ts1
-rw-r--r--src/api/models/user.ts10
-rw-r--r--src/api/models/userkey.ts5
-rw-r--r--src/api/private/signin.ts57
-rw-r--r--src/api/private/signup.ts94
-rw-r--r--src/api/reply.ts13
-rw-r--r--src/api/serializers/app.ts85
-rw-r--r--src/api/serializers/auth-session.ts42
-rw-r--r--src/api/serializers/drive-file.ts63
-rw-r--r--src/api/serializers/drive-folder.ts52
-rw-r--r--src/api/serializers/drive-tag.ts37
-rw-r--r--src/api/serializers/messaging-message.ts64
-rw-r--r--src/api/serializers/notification.ts66
-rw-r--r--src/api/serializers/post.ts103
-rw-r--r--src/api/serializers/signin.ts25
-rw-r--r--src/api/serializers/user.ts138
-rw-r--r--src/api/server.ts52
-rw-r--r--src/api/stream/home.ts10
-rw-r--r--src/api/stream/messaging.ts60
-rw-r--r--src/api/streaming.ts69
-rw-r--r--src/common/text/elements/bold.js17
-rw-r--r--src/common/text/elements/hashtag.js23
-rw-r--r--src/common/text/elements/mention.js17
-rw-r--r--src/common/text/elements/url.js16
-rw-r--r--src/common/text/index.js67
-rw-r--r--src/config.ts95
-rw-r--r--src/db/elasticsearch.ts21
-rw-r--r--src/db/mongodb.ts8
-rw-r--r--src/db/redis.ts9
-rw-r--r--src/file/resources/avatar.jpgbin0 -> 1322 bytes
-rw-r--r--src/file/resources/bad-egg.pngbin0 -> 4783 bytes
-rw-r--r--src/file/resources/dummy.pngbin0 -> 6285 bytes
-rw-r--r--src/file/server.ts115
-rw-r--r--src/himasaku/resources/himasaku.pngbin0 -> 144018 bytes
-rw-r--r--src/himasaku/resources/index.html35
-rw-r--r--src/himasaku/server.ts23
-rw-r--r--src/index.d.ts11
-rw-r--r--src/index.ts223
-rw-r--r--src/server.ts49
-rw-r--r--src/utils/check-dependencies.ts23
-rw-r--r--src/utils/cli/indicator.ts35
-rw-r--r--src/utils/cli/progressbar.ts87
-rw-r--r--src/web/about/base.pug39
-rw-r--r--src/web/about/pages/api/entities/post.pug149
-rw-r--r--src/web/about/pages/api/entities/user.pug118
-rw-r--r--src/web/about/pages/api/getting-started.pug74
-rw-r--r--src/web/about/pages/api/library.pug14
-rw-r--r--src/web/about/pages/license.pug9
-rw-r--r--src/web/about/resources/style.css199
-rw-r--r--src/web/app/auth/resources/logo.svg7
-rw-r--r--src/web/app/auth/script.js19
-rw-r--r--src/web/app/auth/style.styl14
-rw-r--r--src/web/app/auth/tags.ls2
-rw-r--r--src/web/app/auth/tags/form.tag126
-rw-r--r--src/web/app/auth/tags/index.tag129
-rw-r--r--src/web/app/auth/view.pug6
-rw-r--r--src/web/app/base.pug23
-rw-r--r--src/web/app/base.styl118
-rw-r--r--src/web/app/boot.ls154
-rw-r--r--src/web/app/client/script.js40
-rw-r--r--src/web/app/client/view.pug5
-rw-r--r--src/web/app/common/mixins.ls40
-rw-r--r--src/web/app/common/pages/about/base.pug13
-rw-r--r--src/web/app/common/pages/about/pages/staff.pug13
-rw-r--r--src/web/app/common/scripts/api.ls67
-rw-r--r--src/web/app/common/scripts/bytes-to-size.js6
-rw-r--r--src/web/app/common/scripts/check-for-update.ls9
-rw-r--r--src/web/app/common/scripts/date-stringify.ls14
-rw-r--r--src/web/app/common/scripts/generate-default-userdata.ls27
-rw-r--r--src/web/app/common/scripts/get-post-summary.ls26
-rw-r--r--src/web/app/common/scripts/i.ls16
-rw-r--r--src/web/app/common/scripts/is-promise.ls1
-rw-r--r--src/web/app/common/scripts/loading.ls16
-rw-r--r--src/web/app/common/scripts/log.ls18
-rw-r--r--src/web/app/common/scripts/messaging-stream.ls34
-rw-r--r--src/web/app/common/scripts/signout.ls4
-rw-r--r--src/web/app/common/scripts/stream.ls42
-rw-r--r--src/web/app/common/scripts/text-compiler.js30
-rw-r--r--src/web/app/common/scripts/uuid.js12
-rw-r--r--src/web/app/common/tags.ls16
-rw-r--r--src/web/app/common/tags/copyright.tag5
-rw-r--r--src/web/app/common/tags/core-error.tag63
-rw-r--r--src/web/app/common/tags/ellipsis.tag25
-rw-r--r--src/web/app/common/tags/file-type-icon.tag9
-rw-r--r--src/web/app/common/tags/forkit.tag37
-rw-r--r--src/web/app/common/tags/introduction.tag22
-rw-r--r--src/web/app/common/tags/number.tag15
-rw-r--r--src/web/app/common/tags/raw.tag7
-rw-r--r--src/web/app/common/tags/ripple-string.tag24
-rw-r--r--src/web/app/common/tags/signin.tag136
-rw-r--r--src/web/app/common/tags/signup.tag352
-rw-r--r--src/web/app/common/tags/special-message.tag24
-rw-r--r--src/web/app/common/tags/time.tag43
-rw-r--r--src/web/app/common/tags/uploader.tag201
-rw-r--r--src/web/app/common/tags/url-preview.tag105
-rw-r--r--src/web/app/common/tags/url.tag50
-rw-r--r--src/web/app/desktop/mixins.ls47
-rw-r--r--src/web/app/desktop/resources/header-logo.svg7
-rw-r--r--src/web/app/desktop/resources/remove.pngbin0 -> 3115 bytes
-rw-r--r--src/web/app/desktop/router.ls77
-rw-r--r--src/web/app/desktop/script.js42
-rw-r--r--src/web/app/desktop/scripts/autocomplete.ls108
-rw-r--r--src/web/app/desktop/scripts/dialog.ls17
-rw-r--r--src/web/app/desktop/scripts/follow-scroll.ls56
-rw-r--r--src/web/app/desktop/scripts/fuck-ad-block.ls19
-rw-r--r--src/web/app/desktop/scripts/input-dialog.ls13
-rw-r--r--src/web/app/desktop/scripts/notify.ls6
-rw-r--r--src/web/app/desktop/scripts/open-window.ls8
-rw-r--r--src/web/app/desktop/scripts/stream.ls38
-rw-r--r--src/web/app/desktop/scripts/update-avatar.ls81
-rw-r--r--src/web/app/desktop/scripts/update-banner.ls81
-rw-r--r--src/web/app/desktop/scripts/update-wallpaper.ls35
-rw-r--r--src/web/app/desktop/scripts/user-preview.ls74
-rw-r--r--src/web/app/desktop/style.styl114
-rw-r--r--src/web/app/desktop/tags.ls103
-rw-r--r--src/web/app/desktop/tags/analog-clock.tag102
-rw-r--r--src/web/app/desktop/tags/autocomplete-suggestion.tag182
-rw-r--r--src/web/app/desktop/tags/big-follow-button.tag134
-rw-r--r--src/web/app/desktop/tags/contextmenu.tag138
-rw-r--r--src/web/app/desktop/tags/crop-window.tag189
-rw-r--r--src/web/app/desktop/tags/debugger.tag87
-rw-r--r--src/web/app/desktop/tags/detect-slow-internet-connection-notice.tag56
-rw-r--r--src/web/app/desktop/tags/dialog.tag141
-rw-r--r--src/web/app/desktop/tags/donation.tag63
-rw-r--r--src/web/app/desktop/tags/drive/base-contextmenu.tag28
-rw-r--r--src/web/app/desktop/tags/drive/browser-window.tag29
-rw-r--r--src/web/app/desktop/tags/drive/browser.tag634
-rw-r--r--src/web/app/desktop/tags/drive/file-contextmenu.tag97
-rw-r--r--src/web/app/desktop/tags/drive/file.tag207
-rw-r--r--src/web/app/desktop/tags/drive/folder-contextmenu.tag62
-rw-r--r--src/web/app/desktop/tags/drive/folder.tag183
-rw-r--r--src/web/app/desktop/tags/drive/nav-folder.tag96
-rw-r--r--src/web/app/desktop/tags/ellipsis-icon.tag34
-rw-r--r--src/web/app/desktop/tags/follow-button.tag127
-rw-r--r--src/web/app/desktop/tags/following-setuper.tag163
-rw-r--r--src/web/app/desktop/tags/go-top.tag15
-rw-r--r--src/web/app/desktop/tags/home-widgets/broadcast.tag75
-rw-r--r--src/web/app/desktop/tags/home-widgets/calendar.tag147
-rw-r--r--src/web/app/desktop/tags/home-widgets/donation.tag37
-rw-r--r--src/web/app/desktop/tags/home-widgets/mentions.tag117
-rw-r--r--src/web/app/desktop/tags/home-widgets/nav.tag23
-rw-r--r--src/web/app/desktop/tags/home-widgets/notifications.tag49
-rw-r--r--src/web/app/desktop/tags/home-widgets/photo-stream.tag86
-rw-r--r--src/web/app/desktop/tags/home-widgets/profile.tag55
-rw-r--r--src/web/app/desktop/tags/home-widgets/rss-reader.tag94
-rw-r--r--src/web/app/desktop/tags/home-widgets/timeline.tag113
-rw-r--r--src/web/app/desktop/tags/home-widgets/tips.tag70
-rw-r--r--src/web/app/desktop/tags/home-widgets/user-recommendation.tag154
-rw-r--r--src/web/app/desktop/tags/home.tag86
-rw-r--r--src/web/app/desktop/tags/image-dialog.tag73
-rw-r--r--src/web/app/desktop/tags/images-viewer.tag43
-rw-r--r--src/web/app/desktop/tags/input-dialog.tag156
-rw-r--r--src/web/app/desktop/tags/list-user.tag100
-rw-r--r--src/web/app/desktop/tags/log-window.tag20
-rw-r--r--src/web/app/desktop/tags/log.tag62
-rw-r--r--src/web/app/desktop/tags/messaging/form.tag162
-rw-r--r--src/web/app/desktop/tags/messaging/index.tag302
-rw-r--r--src/web/app/desktop/tags/messaging/message.tag227
-rw-r--r--src/web/app/desktop/tags/messaging/room-window.tag26
-rw-r--r--src/web/app/desktop/tags/messaging/room.tag227
-rw-r--r--src/web/app/desktop/tags/messaging/window.tag29
-rw-r--r--src/web/app/desktop/tags/notifications.tag226
-rw-r--r--src/web/app/desktop/tags/pages/entrance.tag77
-rw-r--r--src/web/app/desktop/tags/pages/entrance/signin.tag128
-rw-r--r--src/web/app/desktop/tags/pages/entrance/signup.tag44
-rw-r--r--src/web/app/desktop/tags/pages/home.tag51
-rw-r--r--src/web/app/desktop/tags/pages/not-found.tag46
-rw-r--r--src/web/app/desktop/tags/pages/post.tag25
-rw-r--r--src/web/app/desktop/tags/pages/search.tag14
-rw-r--r--src/web/app/desktop/tags/pages/user.tag20
-rw-r--r--src/web/app/desktop/tags/post-detail-sub.tag141
-rw-r--r--src/web/app/desktop/tags/post-detail.tag415
-rw-r--r--src/web/app/desktop/tags/post-form-window.tag60
-rw-r--r--src/web/app/desktop/tags/post-form.tag430
-rw-r--r--src/web/app/desktop/tags/post-preview.tag94
-rw-r--r--src/web/app/desktop/tags/post-status-graph.tag72
-rw-r--r--src/web/app/desktop/tags/progress-dialog.tag92
-rw-r--r--src/web/app/desktop/tags/repost-form-window.tag38
-rw-r--r--src/web/app/desktop/tags/repost-form.tag140
-rw-r--r--src/web/app/desktop/tags/search-posts.tag88
-rw-r--r--src/web/app/desktop/tags/search.tag28
-rw-r--r--src/web/app/desktop/tags/select-file-from-drive-window.tag160
-rw-r--r--src/web/app/desktop/tags/set-avatar-suggestion.tag44
-rw-r--r--src/web/app/desktop/tags/set-banner-suggestion.tag44
-rw-r--r--src/web/app/desktop/tags/settings-window.tag26
-rw-r--r--src/web/app/desktop/tags/settings.tag255
-rw-r--r--src/web/app/desktop/tags/signin-history.tag73
-rw-r--r--src/web/app/desktop/tags/stream-indicator.tag59
-rw-r--r--src/web/app/desktop/tags/sub-post-content.tag37
-rw-r--r--src/web/app/desktop/tags/timeline-post-sub.tag95
-rw-r--r--src/web/app/desktop/tags/timeline-post.tag376
-rw-r--r--src/web/app/desktop/tags/timeline.tag86
-rw-r--r--src/web/app/desktop/tags/ui-header-account.tag219
-rw-r--r--src/web/app/desktop/tags/ui-header-clock.tag82
-rw-r--r--src/web/app/desktop/tags/ui-header-nav.tag113
-rw-r--r--src/web/app/desktop/tags/ui-header-notifications.tag111
-rw-r--r--src/web/app/desktop/tags/ui-header-post-button.tag39
-rw-r--r--src/web/app/desktop/tags/ui-header-search.tag37
-rw-r--r--src/web/app/desktop/tags/ui-header.tag85
-rw-r--r--src/web/app/desktop/tags/ui-notification.tag41
-rw-r--r--src/web/app/desktop/tags/ui.tag37
-rw-r--r--src/web/app/desktop/tags/user-followers-window.tag22
-rw-r--r--src/web/app/desktop/tags/user-followers.tag19
-rw-r--r--src/web/app/desktop/tags/user-following-window.tag22
-rw-r--r--src/web/app/desktop/tags/user-following.tag19
-rw-r--r--src/web/app/desktop/tags/user-friends-graph.tag64
-rw-r--r--src/web/app/desktop/tags/user-graphs.tag36
-rw-r--r--src/web/app/desktop/tags/user-header.tag143
-rw-r--r--src/web/app/desktop/tags/user-home.tag40
-rw-r--r--src/web/app/desktop/tags/user-likes-graph.tag39
-rw-r--r--src/web/app/desktop/tags/user-photos.tag85
-rw-r--r--src/web/app/desktop/tags/user-posts-graph.tag68
-rw-r--r--src/web/app/desktop/tags/user-preview.tag143
-rw-r--r--src/web/app/desktop/tags/user-profile.tag72
-rw-r--r--src/web/app/desktop/tags/user-timeline.tag142
-rw-r--r--src/web/app/desktop/tags/user.tag45
-rw-r--r--src/web/app/desktop/tags/users-list.tag139
-rw-r--r--src/web/app/desktop/tags/window.tag515
-rw-r--r--src/web/app/dev/router.ls51
-rw-r--r--src/web/app/dev/script.js15
-rw-r--r--src/web/app/dev/style.styl10
-rw-r--r--src/web/app/dev/tags.ls5
-rw-r--r--src/web/app/dev/tags/new-app-form.tag260
-rw-r--r--src/web/app/dev/tags/pages/app.tag24
-rw-r--r--src/web/app/dev/tags/pages/apps.tag26
-rw-r--r--src/web/app/dev/tags/pages/index.tag5
-rw-r--r--src/web/app/dev/tags/pages/new-app.tag33
-rw-r--r--src/web/app/dev/view.pug5
-rw-r--r--src/web/app/init.styl56
-rw-r--r--src/web/app/mobile/mixins.ls19
-rw-r--r--src/web/app/mobile/router.ls110
-rw-r--r--src/web/app/mobile/script.js20
-rw-r--r--src/web/app/mobile/scripts/sp-slidemenu.js839
-rw-r--r--src/web/app/mobile/scripts/stream.ls13
-rw-r--r--src/web/app/mobile/scripts/ui.ls6
-rw-r--r--src/web/app/mobile/style.styl12
-rw-r--r--src/web/app/mobile/tags.ls44
-rw-r--r--src/web/app/mobile/tags/drive-selector.tag75
-rw-r--r--src/web/app/mobile/tags/drive.tag338
-rw-r--r--src/web/app/mobile/tags/drive/file-viewer.tag8
-rw-r--r--src/web/app/mobile/tags/drive/file.tag130
-rw-r--r--src/web/app/mobile/tags/drive/folder.tag45
-rw-r--r--src/web/app/mobile/tags/follow-button.tag108
-rw-r--r--src/web/app/mobile/tags/home-timeline.tag40
-rw-r--r--src/web/app/mobile/tags/home.tag17
-rw-r--r--src/web/app/mobile/tags/images-viewer.tag25
-rw-r--r--src/web/app/mobile/tags/notification-preview.tag117
-rw-r--r--src/web/app/mobile/tags/notification.tag142
-rw-r--r--src/web/app/mobile/tags/notifications.tag98
-rw-r--r--src/web/app/mobile/tags/notify.tag35
-rw-r--r--src/web/app/mobile/tags/page/drive.tag46
-rw-r--r--src/web/app/mobile/tags/page/entrance.tag57
-rw-r--r--src/web/app/mobile/tags/page/entrance/signin.tag45
-rw-r--r--src/web/app/mobile/tags/page/entrance/signup.tag35
-rw-r--r--src/web/app/mobile/tags/page/home.tag40
-rw-r--r--src/web/app/mobile/tags/page/new-post.tag5
-rw-r--r--src/web/app/mobile/tags/page/notifications.tag18
-rw-r--r--src/web/app/mobile/tags/page/post.tag31
-rw-r--r--src/web/app/mobile/tags/page/search.tag19
-rw-r--r--src/web/app/mobile/tags/page/user-followers.tag31
-rw-r--r--src/web/app/mobile/tags/page/user-following.tag31
-rw-r--r--src/web/app/mobile/tags/page/user.tag20
-rw-r--r--src/web/app/mobile/tags/post-detail.tag415
-rw-r--r--src/web/app/mobile/tags/post-form.tag254
-rw-r--r--src/web/app/mobile/tags/post-preview.tag89
-rw-r--r--src/web/app/mobile/tags/search-posts.tag29
-rw-r--r--src/web/app/mobile/tags/search.tag12
-rw-r--r--src/web/app/mobile/tags/stream-indicator.tag59
-rw-r--r--src/web/app/mobile/tags/sub-post-content.tag36
-rw-r--r--src/web/app/mobile/tags/timeline-post-sub.tag99
-rw-r--r--src/web/app/mobile/tags/timeline-post.tag296
-rw-r--r--src/web/app/mobile/tags/timeline.tag128
-rw-r--r--src/web/app/mobile/tags/ui-header.tag98
-rw-r--r--src/web/app/mobile/tags/ui-nav.tag169
-rw-r--r--src/web/app/mobile/tags/ui.tag50
-rw-r--r--src/web/app/mobile/tags/user-followers.tag22
-rw-r--r--src/web/app/mobile/tags/user-following.tag22
-rw-r--r--src/web/app/mobile/tags/user-preview.tag103
-rw-r--r--src/web/app/mobile/tags/user-timeline.tag28
-rw-r--r--src/web/app/mobile/tags/user.tag198
-rw-r--r--src/web/app/mobile/tags/users-list.tag125
-rw-r--r--src/web/app/reset.styl27
-rw-r--r--src/web/apple-touch-icon.ts8
-rw-r--r--src/web/manifest.ts6
-rw-r--r--src/web/meta.ts13
-rw-r--r--src/web/serve-app.ts9
-rw-r--r--src/web/server.ts77
-rw-r--r--src/web/service/proxy/proxy.ts30
-rw-r--r--src/web/service/proxy/server.ts17
-rw-r--r--src/web/service/rss-proxy.ts16
-rw-r--r--src/web/service/url-preview.ts13
378 files changed, 27974 insertions, 0 deletions
diff --git a/src/api/api-handler.ts b/src/api/api-handler.ts
new file mode 100644
index 0000000000..c0714ad69a
--- /dev/null
+++ b/src/api/api-handler.ts
@@ -0,0 +1,55 @@
+import * as express from 'express';
+
+import { IEndpoint } from './endpoints';
+import authenticate from './authenticate';
+import { IAuthContext } from './authenticate';
+import _reply from './reply';
+import limitter from './limitter';
+
+export default async (endpoint: IEndpoint, req: express.Request, res: express.Response) => {
+ const reply = _reply.bind(null, res);
+ let ctx: IAuthContext;
+
+ // Authetication
+ try {
+ ctx = await authenticate(req);
+ } catch (e) {
+ return reply(403, 'AUTHENTICATION_FAILED');
+ }
+
+ if (endpoint.secure && !ctx.isSecure) {
+ return reply(403, 'ACCESS_DENIED');
+ }
+
+ if (endpoint.shouldBeSignin && ctx.user == null) {
+ return reply(401, 'PLZ_SIGNIN');
+ }
+
+ if (ctx.app && endpoint.kind) {
+ if (!ctx.app.permission.some((p: any) => p === endpoint.kind)) {
+ return reply(403, 'ACCESS_DENIED');
+ }
+ }
+
+ if (endpoint.shouldBeSignin) {
+ try {
+ await limitter(endpoint, ctx); // Rate limit
+ } catch (e) {
+ return reply(429);
+ }
+ }
+
+ let exec = require(`${__dirname}/endpoints/${endpoint.name}`);
+
+ if (endpoint.withFile) {
+ exec = exec.bind(null, req.file);
+ }
+
+ // API invoking
+ try {
+ const res = await exec(req.body, ctx.user, ctx.app, ctx.isSecure);
+ reply(res);
+ } catch (e) {
+ reply(400, e);
+ }
+};
diff --git a/src/api/authenticate.ts b/src/api/authenticate.ts
new file mode 100644
index 0000000000..5798adb83d
--- /dev/null
+++ b/src/api/authenticate.ts
@@ -0,0 +1,61 @@
+import * as express from 'express';
+import App from './models/app';
+import User from './models/user';
+import Userkey from './models/userkey';
+
+export interface IAuthContext {
+ /**
+ * App which requested
+ */
+ app: any;
+
+ /**
+ * Authenticated user
+ */
+ user: any;
+
+ /**
+ * Weather if the request is via the (Misskey Web Client or user direct) or not
+ */
+ isSecure: boolean;
+}
+
+export default (req: express.Request) =>
+ new Promise<IAuthContext>(async (resolve, reject) => {
+ const token = req.body['i'];
+ if (token) {
+ const user = await User
+ .findOne({ token: token });
+
+ if (user === null) {
+ return reject('user not found');
+ }
+
+ return resolve({
+ app: null,
+ user: user,
+ isSecure: true
+ });
+ }
+
+ const userkey = req.headers['userkey'] || req.body['_userkey'];
+ if (userkey) {
+ const userkeyDoc = await Userkey.findOne({
+ key: userkey
+ });
+
+ if (userkeyDoc === null) {
+ return reject('invalid userkey');
+ }
+
+ const app = await App
+ .findOne({ _id: userkeyDoc.app_id });
+
+ const user = await User
+ .findOne({ _id: userkeyDoc.user_id });
+
+ return resolve({ app: app, user: user, isSecure: false });
+ }
+
+ return resolve({ app: null, user: null, isSecure: false });
+});
diff --git a/src/api/common/add-file-to-drive.ts b/src/api/common/add-file-to-drive.ts
new file mode 100644
index 0000000000..0bd9f34825
--- /dev/null
+++ b/src/api/common/add-file-to-drive.ts
@@ -0,0 +1,149 @@
+import * as mongodb from 'mongodb';
+import * as crypto from 'crypto';
+import * as gm from 'gm';
+const fileType = require('file-type');
+const prominence = require('prominence');
+import DriveFile from '../models/drive-file';
+import DriveFolder from '../models/drive-folder';
+import serialize from '../serializers/drive-file';
+import event from '../event';
+
+/**
+ * Add file to drive
+ *
+ * @param user User who wish to add file
+ * @param fileName File name
+ * @param data Contents
+ * @param comment Comment
+ * @param type File type
+ * @param folderId Folder ID
+ * @param force If set to true, forcibly upload the file even if there is a file with the same hash.
+ * @return Object that represents added file
+ */
+export default (
+ user: any,
+ data: Buffer,
+ name: string = null,
+ comment: string = null,
+ folderId: mongodb.ObjectID = null,
+ force: boolean = false
+) => new Promise<any>(async (resolve, reject) => {
+ // File size
+ const size = data.byteLength;
+
+ // File type
+ let mime = 'application/octet-stream';
+ const type = fileType(data);
+ if (type !== null) {
+ mime = type.mime;
+
+ if (name === null) {
+ name = `untitled.${type.ext}`;
+ }
+ } else {
+ if (name === null) {
+ name = 'untitled';
+ }
+ }
+
+ // Generate hash
+ const hash = crypto
+ .createHash('sha256')
+ .update(data)
+ .digest('hex') as string;
+
+ if (!force) {
+ // Check if there is a file with the same hash and same data size (to be safe)
+ const much = await DriveFile.findOne({
+ user_id: user._id,
+ hash: hash,
+ datasize: size
+ });
+
+ if (much !== null) {
+ resolve(much);
+ return;
+ }
+ }
+
+ // Fetch all files to calculate drive usage
+ const files = await DriveFile
+ .find({ user_id: user._id }, {
+ datasize: true,
+ _id: false
+ })
+ .toArray();
+
+ // Calculate drive usage (in byte)
+ const usage = files.map(file => file.datasize).reduce((x, y) => x + y, 0);
+
+ // If usage limit exceeded
+ if (usage + size > user.drive_capacity) {
+ return reject('no-free-space');
+ }
+
+ // If the folder is specified
+ let folder: any = null;
+ if (folderId !== null) {
+ folder = await DriveFolder
+ .findOne({
+ _id: folderId,
+ user_id: user._id
+ });
+
+ if (folder === null) {
+ return reject('folder-not-found');
+ }
+ }
+
+ let properties: any = null;
+
+ // If the file is an image
+ if (/^image\/.*$/.test(mime)) {
+ // Calculate width and height to save in property
+ const g = gm(data, name);
+ const size = await prominence(g).size();
+ properties = {
+ width: size.width,
+ height: size.height
+ };
+ }
+
+ // Create DriveFile document
+ const res = await DriveFile.insert({
+ created_at: new Date(),
+ 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
+ });
+
+ const file = res.ops[0];
+
+ resolve(file);
+
+ // Serialize
+ const fileObj = await serialize(file);
+
+ // Publish drive_file_created event
+ event(user._id, 'drive_file_created', fileObj);
+
+ // Register to search database
+ if (config.elasticsearch.enable) {
+ const es = require('../../db/elasticsearch');
+ es.index({
+ index: 'misskey',
+ type: 'drive_file',
+ id: file._id.toString(),
+ body: {
+ name: file.name,
+ user_id: user._id.toString()
+ }
+ });
+ }
+});
diff --git a/src/api/common/get-friends.ts b/src/api/common/get-friends.ts
new file mode 100644
index 0000000000..5d50bcdb13
--- /dev/null
+++ b/src/api/common/get-friends.ts
@@ -0,0 +1,25 @@
+import * as mongodb from 'mongodb';
+import Following from '../models/following';
+
+export default async (me: mongodb.ObjectID, includeMe: boolean = true) => {
+ // Fetch relation to other users who the I follows
+ // SELECT followee
+ const myfollowing = await Following
+ .find({
+ follower_id: me,
+ // 削除されたドキュメントは除く
+ deleted_at: { $exists: false }
+ }, {
+ followee_id: true
+ })
+ .toArray();
+
+ // ID list of other users who the I follows
+ const myfollowingIds = myfollowing.map(follow => follow.followee_id);
+
+ if (includeMe) {
+ myfollowingIds.push(me);
+ }
+
+ return myfollowingIds;
+};
diff --git a/src/api/common/notify.ts b/src/api/common/notify.ts
new file mode 100644
index 0000000000..c4c94ee704
--- /dev/null
+++ b/src/api/common/notify.ts
@@ -0,0 +1,32 @@
+import * as mongo from 'mongodb';
+import Notification from '../models/notification';
+import event from '../event';
+import serialize from '../serializers/notification';
+
+export default (
+ notifiee: mongo.ObjectID,
+ notifier: mongo.ObjectID,
+ type: string,
+ content: any
+) => new Promise<any>(async (resolve, reject) => {
+ if (notifiee.equals(notifier)) {
+ return resolve();
+ }
+
+ // Create notification
+ const res = await Notification.insert(Object.assign({
+ created_at: new Date(),
+ notifiee_id: notifiee,
+ notifier_id: notifier,
+ type: type,
+ is_read: false
+ }, content));
+
+ const notification = res.ops[0];
+
+ resolve(notification);
+
+ // Publish notification event
+ event(notifiee, 'notification',
+ await serialize(notification));
+});
diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts
new file mode 100644
index 0000000000..ad45f42bc7
--- /dev/null
+++ b/src/api/endpoints.ts
@@ -0,0 +1,101 @@
+const second = 1000;
+const minute = 60 * second;
+const hour = 60 * minute;
+const day = 24 * hour;
+
+export interface IEndpoint {
+ name: string;
+ shouldBeSignin: boolean;
+ limitKey?: string;
+ limitDuration?: number;
+ limitMax?: number;
+ minInterval?: number;
+ withFile?: boolean;
+ secure?: boolean;
+ kind?: string;
+}
+
+export default [
+ { name: 'meta', shouldBeSignin: false },
+
+ { name: 'username/available', shouldBeSignin: false },
+
+ { name: 'my/apps', shouldBeSignin: true },
+
+ { name: 'app/create', shouldBeSignin: true, limitDuration: day, limitMax: 3 },
+ { name: 'app/show', shouldBeSignin: false },
+ { name: 'app/name_id/available', shouldBeSignin: false },
+
+ { name: 'auth/session/generate', shouldBeSignin: false },
+ { name: 'auth/session/show', shouldBeSignin: false },
+ { name: 'auth/session/userkey', shouldBeSignin: false },
+ { name: 'auth/accept', shouldBeSignin: true, secure: true },
+ { name: 'auth/deny', shouldBeSignin: true, secure: true },
+
+ { name: 'aggregation/users/post', shouldBeSignin: false },
+ { name: 'aggregation/users/like', shouldBeSignin: false },
+ { name: 'aggregation/users/followers', shouldBeSignin: false },
+ { name: 'aggregation/users/following', shouldBeSignin: false },
+ { name: 'aggregation/posts/like', shouldBeSignin: false },
+ { name: 'aggregation/posts/likes', shouldBeSignin: false },
+ { name: 'aggregation/posts/repost', shouldBeSignin: false },
+ { name: 'aggregation/posts/reply', shouldBeSignin: false },
+
+ { name: 'i', shouldBeSignin: true },
+ { name: 'i/update', shouldBeSignin: true, limitDuration: day, limitMax: 50, kind: 'account-write' },
+ { name: 'i/appdata/get', shouldBeSignin: true },
+ { name: 'i/appdata/set', shouldBeSignin: true },
+ { name: 'i/signin_history', shouldBeSignin: true, kind: 'account-read' },
+
+ { name: 'i/notifications', shouldBeSignin: true, kind: 'notification-read' },
+ { name: 'notifications/delete', shouldBeSignin: true, kind: 'notification-write' },
+ { name: 'notifications/delete_all', shouldBeSignin: true, kind: 'notification-write' },
+ { name: 'notifications/mark_as_read', shouldBeSignin: true, kind: 'notification-write' },
+ { name: 'notifications/mark_as_read_all', shouldBeSignin: true, kind: 'notification-write' },
+
+ { name: 'drive', shouldBeSignin: true, kind: 'drive-read' },
+ { name: 'drive/stream', shouldBeSignin: true, kind: 'drive-read' },
+ { name: 'drive/files', shouldBeSignin: true, kind: 'drive-read' },
+ { name: 'drive/files/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, withFile: true, kind: 'drive-write' },
+ { name: 'drive/files/show', shouldBeSignin: true, kind: 'drive-read' },
+ { name: 'drive/files/find', shouldBeSignin: true, kind: 'drive-read' },
+ { name: 'drive/files/delete', shouldBeSignin: true, kind: 'drive-write' },
+ { name: 'drive/files/update', shouldBeSignin: true, kind: 'drive-write' },
+ { name: 'drive/folders', shouldBeSignin: true, kind: 'drive-read' },
+ { name: 'drive/folders/create', shouldBeSignin: true, limitDuration: hour, limitMax: 50, kind: 'drive-write' },
+ { name: 'drive/folders/show', shouldBeSignin: true, kind: 'drive-read' },
+ { name: 'drive/folders/find', shouldBeSignin: true, kind: 'drive-read' },
+ { name: 'drive/folders/update', shouldBeSignin: true, kind: 'drive-write' },
+
+ { name: 'users', shouldBeSignin: false },
+ { name: 'users/show', shouldBeSignin: false },
+ { name: 'users/search', shouldBeSignin: false },
+ { name: 'users/search_by_username', shouldBeSignin: false },
+ { name: 'users/posts', shouldBeSignin: false },
+ { name: 'users/following', shouldBeSignin: false },
+ { name: 'users/followers', shouldBeSignin: false },
+ { name: 'users/recommendation', shouldBeSignin: true, kind: 'account-read' },
+
+ { name: 'following/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'following-write' },
+ { name: 'following/delete', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'following-write' },
+
+ { name: 'posts/show', shouldBeSignin: false },
+ { name: 'posts/replies', shouldBeSignin: false },
+ { name: 'posts/context', shouldBeSignin: false },
+ { name: 'posts/create', shouldBeSignin: true, limitDuration: hour, limitMax: 120, minInterval: 1 * second, kind: 'post-write' },
+ { name: 'posts/reposts', shouldBeSignin: false },
+ { name: 'posts/search', shouldBeSignin: false },
+ { name: 'posts/timeline', shouldBeSignin: true, limitDuration: 10 * minute, limitMax: 100 },
+ { name: 'posts/mentions', shouldBeSignin: true, limitDuration: 10 * minute, limitMax: 100 },
+ { name: 'posts/likes', shouldBeSignin: true },
+ { name: 'posts/likes/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'like-write' },
+ { name: 'posts/likes/delete', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'like-write' },
+ { name: 'posts/favorites/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'favorite-write' },
+ { name: 'posts/favorites/delete', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'favorite-write' },
+
+ { name: 'messaging/history', shouldBeSignin: true, kind: 'messaging-read' },
+ { name: 'messaging/unread', shouldBeSignin: true, kind: 'messaging-read' },
+ { name: 'messaging/messages', shouldBeSignin: true, kind: 'messaging-read' },
+ { name: 'messaging/messages/create', shouldBeSignin: true, kind: 'messaging-write' }
+
+] as IEndpoint[];
diff --git a/src/api/endpoints/aggregation/posts/like.js b/src/api/endpoints/aggregation/posts/like.js
new file mode 100644
index 0000000000..b82c494ff1
--- /dev/null
+++ b/src/api/endpoints/aggregation/posts/like.js
@@ -0,0 +1,83 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Post from '../../../models/post';
+import Like from '../../../models/like';
+
+/**
+ * Aggregate like of a post
+ *
+ * @param {Object} params
+ * @return {Promise<object>}
+ */
+module.exports = (params) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'post_id' parameter
+ const postId = params.post_id;
+ if (postId === undefined || postId === null) {
+ return rej('post_id is required');
+ }
+
+ // Lookup post
+ const post = await Post.findOne({
+ _id: new mongo.ObjectID(postId)
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ const datas = await Like
+ .aggregate([
+ { $match: { post_id: post._id } },
+ { $project: {
+ created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
+ }},
+ { $project: {
+ date: {
+ year: { $year: '$created_at' },
+ month: { $month: '$created_at' },
+ day: { $dayOfMonth: '$created_at' }
+ }
+ }},
+ { $group: {
+ _id: '$date',
+ count: { $sum: 1 }
+ }}
+ ])
+ .toArray();
+
+ datas.forEach(data => {
+ data.date = data._id;
+ delete data._id;
+ });
+
+ const graph = [];
+
+ for (let i = 0; i < 30; i++) {
+ let day = new Date(new Date().setDate(new Date().getDate() - i));
+
+ const data = datas.filter(d =>
+ d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate()
+ )[0];
+
+ if (data) {
+ graph.push(data)
+ } else {
+ graph.push({
+ date: {
+ year: day.getFullYear(),
+ month: day.getMonth() + 1, // In JavaScript, month is zero-based.
+ day: day.getDate()
+ },
+ count: 0
+ })
+ };
+ }
+
+ res(graph);
+});
diff --git a/src/api/endpoints/aggregation/posts/likes.js b/src/api/endpoints/aggregation/posts/likes.js
new file mode 100644
index 0000000000..0317245159
--- /dev/null
+++ b/src/api/endpoints/aggregation/posts/likes.js
@@ -0,0 +1,76 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Post from '../../../models/post';
+import Like from '../../../models/like';
+
+/**
+ * Aggregate likes of a post
+ *
+ * @param {Object} params
+ * @return {Promise<object>}
+ */
+module.exports = (params) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'post_id' parameter
+ const postId = params.post_id;
+ if (postId === undefined || postId === null) {
+ return rej('post_id is required');
+ }
+
+ // Lookup post
+ const post = await Post.findOne({
+ _id: new mongo.ObjectID(postId)
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ const startTime = new Date(new Date().setMonth(new Date().getMonth() - 1));
+
+ const likes = await Like
+ .find({
+ post_id: post._id,
+ $or: [
+ { deleted_at: { $exists: false } },
+ { deleted_at: { $gt: startTime } }
+ ]
+ }, {
+ _id: false,
+ post_id: false
+ }, {
+ sort: { created_at: -1 }
+ })
+ .toArray();
+
+ const graph = [];
+
+ for (let i = 0; i < 30; i++) {
+ let day = new Date(new Date().setDate(new Date().getDate() - i));
+ day = new Date(day.setMilliseconds(999));
+ day = new Date(day.setSeconds(59));
+ day = new Date(day.setMinutes(59));
+ day = new Date(day.setHours(23));
+ //day = day.getTime();
+
+ const count = likes.filter(l =>
+ l.created_at < day && (l.deleted_at == null || l.deleted_at > day)
+ ).length;
+
+ graph.push({
+ date: {
+ year: day.getFullYear(),
+ month: day.getMonth() + 1, // In JavaScript, month is zero-based.
+ day: day.getDate()
+ },
+ count: count
+ });
+ }
+
+ res(graph);
+});
diff --git a/src/api/endpoints/aggregation/posts/reply.js b/src/api/endpoints/aggregation/posts/reply.js
new file mode 100644
index 0000000000..e578bc6d7d
--- /dev/null
+++ b/src/api/endpoints/aggregation/posts/reply.js
@@ -0,0 +1,82 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Post from '../../../models/post';
+
+/**
+ * Aggregate reply of a post
+ *
+ * @param {Object} params
+ * @return {Promise<object>}
+ */
+module.exports = (params) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'post_id' parameter
+ const postId = params.post_id;
+ if (postId === undefined || postId === null) {
+ return rej('post_id is required');
+ }
+
+ // Lookup post
+ const post = await Post.findOne({
+ _id: new mongo.ObjectID(postId)
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ const datas = await Post
+ .aggregate([
+ { $match: { reply_to: post._id } },
+ { $project: {
+ created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
+ }},
+ { $project: {
+ date: {
+ year: { $year: '$created_at' },
+ month: { $month: '$created_at' },
+ day: { $dayOfMonth: '$created_at' }
+ }
+ }},
+ { $group: {
+ _id: '$date',
+ count: { $sum: 1 }
+ }}
+ ])
+ .toArray();
+
+ datas.forEach(data => {
+ data.date = data._id;
+ delete data._id;
+ });
+
+ const graph = [];
+
+ for (let i = 0; i < 30; i++) {
+ let day = new Date(new Date().setDate(new Date().getDate() - i));
+
+ const data = datas.filter(d =>
+ d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate()
+ )[0];
+
+ if (data) {
+ graph.push(data)
+ } else {
+ graph.push({
+ date: {
+ year: day.getFullYear(),
+ month: day.getMonth() + 1, // In JavaScript, month is zero-based.
+ day: day.getDate()
+ },
+ count: 0
+ })
+ };
+ }
+
+ res(graph);
+});
diff --git a/src/api/endpoints/aggregation/posts/repost.js b/src/api/endpoints/aggregation/posts/repost.js
new file mode 100644
index 0000000000..38d63442a8
--- /dev/null
+++ b/src/api/endpoints/aggregation/posts/repost.js
@@ -0,0 +1,82 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Post from '../../../models/post';
+
+/**
+ * Aggregate repost of a post
+ *
+ * @param {Object} params
+ * @return {Promise<object>}
+ */
+module.exports = (params) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'post_id' parameter
+ const postId = params.post_id;
+ if (postId === undefined || postId === null) {
+ return rej('post_id is required');
+ }
+
+ // Lookup post
+ const post = await Post.findOne({
+ _id: new mongo.ObjectID(postId)
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ const datas = await Post
+ .aggregate([
+ { $match: { repost_id: post._id } },
+ { $project: {
+ created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
+ }},
+ { $project: {
+ date: {
+ year: { $year: '$created_at' },
+ month: { $month: '$created_at' },
+ day: { $dayOfMonth: '$created_at' }
+ }
+ }},
+ { $group: {
+ _id: '$date',
+ count: { $sum: 1 }
+ }}
+ ])
+ .toArray();
+
+ datas.forEach(data => {
+ data.date = data._id;
+ delete data._id;
+ });
+
+ const graph = [];
+
+ for (let i = 0; i < 30; i++) {
+ let day = new Date(new Date().setDate(new Date().getDate() - i));
+
+ const data = datas.filter(d =>
+ d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate()
+ )[0];
+
+ if (data) {
+ graph.push(data)
+ } else {
+ graph.push({
+ date: {
+ year: day.getFullYear(),
+ month: day.getMonth() + 1, // In JavaScript, month is zero-based.
+ day: day.getDate()
+ },
+ count: 0
+ })
+ };
+ }
+
+ res(graph);
+});
diff --git a/src/api/endpoints/aggregation/users/followers.js b/src/api/endpoints/aggregation/users/followers.js
new file mode 100644
index 0000000000..16dda09675
--- /dev/null
+++ b/src/api/endpoints/aggregation/users/followers.js
@@ -0,0 +1,77 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import User from '../../../models/user';
+import Following from '../../../models/following';
+
+/**
+ * Aggregate followers of a user
+ *
+ * @param {Object} params
+ * @return {Promise<object>}
+ */
+module.exports = (params) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'user_id' parameter
+ const userId = params.user_id;
+ if (userId === undefined || userId === null) {
+ return rej('user_id is required');
+ }
+
+ // Lookup user
+ const user = await User.findOne({
+ _id: new mongo.ObjectID(userId)
+ });
+
+ if (user === null) {
+ return rej('user not found');
+ }
+
+ const startTime = new Date(new Date().setMonth(new Date().getMonth() - 1));
+
+ const following = await Following
+ .find({
+ followee_id: user._id,
+ $or: [
+ { deleted_at: { $exists: false } },
+ { deleted_at: { $gt: startTime } }
+ ]
+ }, {
+ _id: false,
+ follower_id: false,
+ followee_id: false
+ }, {
+ sort: { created_at: -1 }
+ })
+ .toArray();
+
+ const graph = [];
+
+ for (let i = 0; i < 30; i++) {
+ let day = new Date(new Date().setDate(new Date().getDate() - i));
+ day = new Date(day.setMilliseconds(999));
+ day = new Date(day.setSeconds(59));
+ day = new Date(day.setMinutes(59));
+ day = new Date(day.setHours(23));
+ // day = day.getTime();
+
+ const count = following.filter(f =>
+ f.created_at < day && (f.deleted_at == null || f.deleted_at > day)
+ ).length;
+
+ graph.push({
+ date: {
+ year: day.getFullYear(),
+ month: day.getMonth() + 1, // In JavaScript, month is zero-based.
+ day: day.getDate()
+ },
+ count: count
+ });
+ }
+
+ res(graph);
+});
diff --git a/src/api/endpoints/aggregation/users/following.js b/src/api/endpoints/aggregation/users/following.js
new file mode 100644
index 0000000000..7b7448d715
--- /dev/null
+++ b/src/api/endpoints/aggregation/users/following.js
@@ -0,0 +1,76 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import User from '../../../models/user';
+import Following from '../../../models/following';
+
+/**
+ * Aggregate following of a user
+ *
+ * @param {Object} params
+ * @return {Promise<object>}
+ */
+module.exports = (params) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'user_id' parameter
+ const userId = params.user_id;
+ if (userId === undefined || userId === null) {
+ return rej('user_id is required');
+ }
+
+ // Lookup user
+ const user = await User.findOne({
+ _id: new mongo.ObjectID(userId)
+ });
+
+ if (user === null) {
+ return rej('user not found');
+ }
+
+ const startTime = new Date(new Date().setMonth(new Date().getMonth() - 1));
+
+ const following = await Following
+ .find({
+ follower_id: user._id,
+ $or: [
+ { deleted_at: { $exists: false } },
+ { deleted_at: { $gt: startTime } }
+ ]
+ }, {
+ _id: false,
+ follower_id: false,
+ followee_id: false
+ }, {
+ sort: { created_at: -1 }
+ })
+ .toArray();
+
+ const graph = [];
+
+ for (let i = 0; i < 30; i++) {
+ let day = new Date(new Date().setDate(new Date().getDate() - i));
+ day = new Date(day.setMilliseconds(999));
+ day = new Date(day.setSeconds(59));
+ day = new Date(day.setMinutes(59));
+ day = new Date(day.setHours(23));
+
+ const count = following.filter(f =>
+ f.created_at < day && (f.deleted_at == null || f.deleted_at > day)
+ ).length;
+
+ graph.push({
+ date: {
+ year: day.getFullYear(),
+ month: day.getMonth() + 1, // In JavaScript, month is zero-based.
+ day: day.getDate()
+ },
+ count: count
+ });
+ }
+
+ res(graph);
+});
diff --git a/src/api/endpoints/aggregation/users/like.js b/src/api/endpoints/aggregation/users/like.js
new file mode 100644
index 0000000000..830f1f1bba
--- /dev/null
+++ b/src/api/endpoints/aggregation/users/like.js
@@ -0,0 +1,83 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import User from '../../../models/user';
+import Like from '../../../models/like';
+
+/**
+ * Aggregate like of a user
+ *
+ * @param {Object} params
+ * @return {Promise<object>}
+ */
+module.exports = (params) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'user_id' parameter
+ const userId = params.user_id;
+ if (userId === undefined || userId === null) {
+ return rej('user_id is required');
+ }
+
+ // Lookup user
+ const user = await User.findOne({
+ _id: new mongo.ObjectID(userId)
+ });
+
+ if (user === null) {
+ return rej('user not found');
+ }
+
+ const datas = await Like
+ .aggregate([
+ { $match: { user_id: user._id } },
+ { $project: {
+ created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
+ }},
+ { $project: {
+ date: {
+ year: { $year: '$created_at' },
+ month: { $month: '$created_at' },
+ day: { $dayOfMonth: '$created_at' }
+ }
+ }},
+ { $group: {
+ _id: '$date',
+ count: { $sum: 1 }
+ }}
+ ])
+ .toArray();
+
+ datas.forEach(data => {
+ data.date = data._id;
+ delete data._id;
+ });
+
+ const graph = [];
+
+ for (let i = 0; i < 30; i++) {
+ let day = new Date(new Date().setDate(new Date().getDate() - i));
+
+ const data = datas.filter(d =>
+ d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate()
+ )[0];
+
+ if (data) {
+ graph.push(data)
+ } else {
+ graph.push({
+ date: {
+ year: day.getFullYear(),
+ month: day.getMonth() + 1, // In JavaScript, month is zero-based.
+ day: day.getDate()
+ },
+ count: 0
+ })
+ };
+ }
+
+ res(graph);
+});
diff --git a/src/api/endpoints/aggregation/users/post.js b/src/api/endpoints/aggregation/users/post.js
new file mode 100644
index 0000000000..d75df30f5d
--- /dev/null
+++ b/src/api/endpoints/aggregation/users/post.js
@@ -0,0 +1,113 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import User from '../../../models/user';
+import Post from '../../../models/post';
+
+/**
+ * Aggregate post of a user
+ *
+ * @param {Object} params
+ * @return {Promise<object>}
+ */
+module.exports = (params) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'user_id' parameter
+ const userId = params.user_id;
+ if (userId === undefined || userId === null) {
+ return rej('user_id is required');
+ }
+
+ // Lookup user
+ const user = await User.findOne({
+ _id: new mongo.ObjectID(userId)
+ });
+
+ if (user === null) {
+ return rej('user not found');
+ }
+
+ const datas = await Post
+ .aggregate([
+ { $match: { user_id: user._id } },
+ { $project: {
+ repost_id: '$repost_id',
+ reply_to_id: '$reply_to_id',
+ created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
+ }},
+ { $project: {
+ date: {
+ year: { $year: '$created_at' },
+ month: { $month: '$created_at' },
+ day: { $dayOfMonth: '$created_at' }
+ },
+ type: {
+ $cond: {
+ if: { $ne: ['$repost_id', null] },
+ then: 'repost',
+ else: {
+ $cond: {
+ if: { $ne: ['$reply_to_id', null] },
+ then: 'reply',
+ else: 'post'
+ }
+ }
+ }
+ }}
+ },
+ { $group: { _id: {
+ date: '$date',
+ type: '$type'
+ }, count: { $sum: 1 } } },
+ { $group: {
+ _id: '$_id.date',
+ data: { $addToSet: {
+ type: '$_id.type',
+ count: '$count'
+ }}
+ } }
+ ])
+ .toArray();
+
+ datas.forEach(data => {
+ data.date = data._id;
+ delete data._id;
+
+ data.posts = (data.data.filter(x => x.type == 'post')[0] || { count: 0 }).count;
+ data.reposts = (data.data.filter(x => x.type == 'repost')[0] || { count: 0 }).count;
+ data.replies = (data.data.filter(x => x.type == 'reply')[0] || { count: 0 }).count;
+
+ delete data.data;
+ });
+
+ const graph = [];
+
+ for (let i = 0; i < 30; i++) {
+ let day = new Date(new Date().setDate(new Date().getDate() - i));
+
+ const data = datas.filter(d =>
+ d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate()
+ )[0];
+
+ if (data) {
+ graph.push(data)
+ } else {
+ graph.push({
+ date: {
+ year: day.getFullYear(),
+ month: day.getMonth() + 1, // In JavaScript, month is zero-based.
+ day: day.getDate()
+ },
+ posts: 0,
+ reposts: 0,
+ replies: 0
+ })
+ };
+ }
+
+ res(graph);
+});
diff --git a/src/api/endpoints/app/create.js b/src/api/endpoints/app/create.js
new file mode 100644
index 0000000000..d83062c8e9
--- /dev/null
+++ b/src/api/endpoints/app/create.js
@@ -0,0 +1,75 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import rndstr from 'rndstr';
+import App from '../../models/app';
+import serialize from '../../serializers/app';
+
+/**
+ * Create an app
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = async (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'name_id' parameter
+ const nameId = params.name_id;
+ if (nameId == null || nameId == '') {
+ return rej('name_id is required');
+ }
+
+ // Validate name_id
+ if (!/^[a-zA-Z0-9\-]{3,30}$/.test(nameId)) {
+ return rej('invalid name_id');
+ }
+
+ // Get 'name' parameter
+ const name = params.name;
+ if (name == null || name == '') {
+ return rej('name is required');
+ }
+
+ // Get 'description' parameter
+ const description = params.description;
+ if (description == null || description == '') {
+ return rej('description is required');
+ }
+
+ // Get 'permission' parameter
+ const permission = params.permission;
+ if (permission == null || permission == '') {
+ return rej('permission is required');
+ }
+
+ // Get 'callback_url' parameter
+ let callback = params.callback_url;
+ if (callback === '') {
+ callback = null;
+ }
+
+ // Generate secret
+ const secret = rndstr('a-zA-Z0-9', 32);
+
+ // Create account
+ const inserted = await App.insert({
+ created_at: new Date(),
+ user_id: user._id,
+ name: name,
+ name_id: nameId,
+ name_id_lower: nameId.toLowerCase(),
+ description: description,
+ permission: permission.split(','),
+ callback_url: callback,
+ secret: secret
+ });
+
+ const app = inserted.ops[0];
+
+ // Response
+ res(await serialize(app));
+});
diff --git a/src/api/endpoints/app/name_id/available.js b/src/api/endpoints/app/name_id/available.js
new file mode 100644
index 0000000000..179925dce4
--- /dev/null
+++ b/src/api/endpoints/app/name_id/available.js
@@ -0,0 +1,40 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import App from '../../../models/app';
+
+/**
+ * Check available name_id of app
+ *
+ * @param {Object} params
+ * @return {Promise<object>}
+ */
+module.exports = async (params) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'name_id' parameter
+ const nameId = params.name_id;
+ if (nameId == null || nameId == '') {
+ return rej('name_id is required');
+ }
+
+ // Validate name_id
+ if (!/^[a-zA-Z0-9\-]{3,30}$/.test(nameId)) {
+ return rej('invalid name_id');
+ }
+
+ // Get exist
+ const exist = await App
+ .count({
+ name_id_lower: nameId.toLowerCase()
+ }, {
+ limit: 1
+ });
+
+ // Reply
+ res({
+ available: exist === 0
+ });
+});
diff --git a/src/api/endpoints/app/show.js b/src/api/endpoints/app/show.js
new file mode 100644
index 0000000000..8d12f9aeb1
--- /dev/null
+++ b/src/api/endpoints/app/show.js
@@ -0,0 +1,51 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import App from '../../models/app';
+import serialize from '../../serializers/app';
+
+/**
+ * Show an app
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @param {Object} _
+ * @param {Object} isSecure
+ * @return {Promise<object>}
+ */
+module.exports = (params, user, _, isSecure) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'app_id' parameter
+ let appId = params.app_id;
+ if (appId == null || appId == '') {
+ appId = null;
+ }
+
+ // Get 'name_id' parameter
+ let nameId = params.name_id;
+ if (nameId == null || nameId == '') {
+ nameId = null;
+ }
+
+ if (appId === null && nameId === null) {
+ return rej('app_id or name_id is required');
+ }
+
+ // Lookup app
+ const app = appId !== null
+ ? await App.findOne({ _id: new mongo.ObjectID(appId) })
+ : await App.findOne({ name_id_lower: nameId.toLowerCase() });
+
+ if (app === null) {
+ return rej('app not found');
+ }
+
+ // Send response
+ res(await serialize(app, user, {
+ includeSecret: isSecure && app.user_id.equals(user._id)
+ }));
+});
diff --git a/src/api/endpoints/auth/accept.js b/src/api/endpoints/auth/accept.js
new file mode 100644
index 0000000000..7c45650c6b
--- /dev/null
+++ b/src/api/endpoints/auth/accept.js
@@ -0,0 +1,64 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import rndstr from 'rndstr';
+import AuthSess from '../../models/auth-session';
+import Userkey from '../../models/userkey';
+
+/**
+ * Accept
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'token' parameter
+ const token = params.token;
+ if (token == null) {
+ return rej('token is required');
+ }
+
+ // Fetch token
+ const session = await AuthSess
+ .findOne({ token: token });
+
+ if (session === null) {
+ return rej('session not found');
+ }
+
+ // Generate userkey
+ const key = rndstr('a-zA-Z0-9', 32);
+
+ // Fetch exist userkey
+ const exist = await Userkey.findOne({
+ app_id: session.app_id,
+ user_id: user._id,
+ });
+
+ if (exist === null) {
+ // Insert userkey doc
+ await Userkey.insert({
+ created_at: new Date(),
+ app_id: session.app_id,
+ user_id: user._id,
+ key: key
+ });
+ }
+
+ // Update session
+ await AuthSess.updateOne({
+ _id: session._id
+ }, {
+ $set: {
+ user_id: user._id
+ }
+ });
+
+ // Response
+ res();
+});
diff --git a/src/api/endpoints/auth/session/generate.js b/src/api/endpoints/auth/session/generate.js
new file mode 100644
index 0000000000..bb49cf090d
--- /dev/null
+++ b/src/api/endpoints/auth/session/generate.js
@@ -0,0 +1,51 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as uuid from 'uuid';
+import App from '../../../models/app';
+import AuthSess from '../../../models/auth-session';
+
+/**
+ * Generate a session
+ *
+ * @param {Object} params
+ * @return {Promise<object>}
+ */
+module.exports = (params) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'app_secret' parameter
+ const appSecret = params.app_secret;
+ if (appSecret == null) {
+ return rej('app_secret is required');
+ }
+
+ // Lookup app
+ const app = await App.findOne({
+ secret: appSecret
+ });
+
+ if (app == null) {
+ return rej('app not found');
+ }
+
+ // Generate token
+ const token = uuid.v4();
+
+ // Create session token document
+ const inserted = await AuthSess.insert({
+ created_at: new Date(),
+ app_id: app._id,
+ token: token
+ });
+
+ const doc = inserted.ops[0];
+
+ // Response
+ res({
+ token: doc.token,
+ url: `${config.auth_url}/${doc.token}`
+ });
+});
diff --git a/src/api/endpoints/auth/session/show.js b/src/api/endpoints/auth/session/show.js
new file mode 100644
index 0000000000..67160c6993
--- /dev/null
+++ b/src/api/endpoints/auth/session/show.js
@@ -0,0 +1,36 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import AuthSess from '../../../models/auth-session';
+import serialize from '../../../serializers/auth-session';
+
+/**
+ * Show a session
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'token' parameter
+ const token = params.token;
+ if (token == null) {
+ return rej('token is required');
+ }
+
+ // Lookup session
+ const session = await AuthSess.findOne({
+ token: token
+ });
+
+ if (session == null) {
+ return rej('session not found');
+ }
+
+ // Response
+ res(await serialize(session, user));
+});
diff --git a/src/api/endpoints/auth/session/userkey.js b/src/api/endpoints/auth/session/userkey.js
new file mode 100644
index 0000000000..2626e4ce39
--- /dev/null
+++ b/src/api/endpoints/auth/session/userkey.js
@@ -0,0 +1,74 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import App from '../../../models/app';
+import AuthSess from '../../../models/auth-session';
+import Userkey from '../../../models/userkey';
+import serialize from '../../../serializers/user';
+
+/**
+ * Generate a session
+ *
+ * @param {Object} params
+ * @return {Promise<object>}
+ */
+module.exports = (params) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'app_secret' parameter
+ const appSecret = params.app_secret;
+ if (appSecret == null) {
+ return rej('app_secret is required');
+ }
+
+ // Lookup app
+ const app = await App.findOne({
+ secret: appSecret
+ });
+
+ if (app == null) {
+ return rej('app not found');
+ }
+
+ // Get 'token' parameter
+ const token = params.token;
+ if (token == null) {
+ return rej('token is required');
+ }
+
+ // Fetch token
+ const session = await AuthSess
+ .findOne({
+ token: token,
+ app_id: app._id
+ });
+
+ if (session === null) {
+ return rej('session not found');
+ }
+
+ if (session.user_id == null) {
+ return rej('this session is not allowed yet');
+ }
+
+ // Lookup userkey
+ const userkey = await Userkey.findOne({
+ app_id: app._id,
+ user_id: session.user_id
+ });
+
+ // Delete session
+ AuthSess.deleteOne({
+ _id: session._id
+ });
+
+ // Response
+ res({
+ userkey: userkey.key,
+ user: await serialize(session.user_id, null, {
+ detail: true
+ })
+ });
+});
diff --git a/src/api/endpoints/drive.js b/src/api/endpoints/drive.js
new file mode 100644
index 0000000000..4df4ac33fa
--- /dev/null
+++ b/src/api/endpoints/drive.js
@@ -0,0 +1,33 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import DriveFile from './models/drive-file';
+
+/**
+ * Get drive information
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Fetch all files to calculate drive usage
+ const files = await DriveFile
+ .find({ user_id: user._id }, {
+ datasize: true,
+ _id: false
+ })
+ .toArray();
+
+ // Calculate drive usage (in byte)
+ const usage = files.map(file => file.datasize).reduce((x, y) => x + y, 0);
+
+ res({
+ capacity: user.drive_capacity,
+ usage: usage
+ });
+});
diff --git a/src/api/endpoints/drive/files.js b/src/api/endpoints/drive/files.js
new file mode 100644
index 0000000000..7e8ff59f2a
--- /dev/null
+++ b/src/api/endpoints/drive/files.js
@@ -0,0 +1,82 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import DriveFile from '../../models/drive-file';
+import serialize from '../../serializers/drive-file';
+
+/**
+ * Get drive files
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @param {Object} app
+ * @return {Promise<object>}
+ */
+module.exports = (params, user, app) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ const since = params.since_id || null;
+ const max = params.max_id || null;
+
+ // Check if both of since_id and max_id is specified
+ if (since !== null && max !== null) {
+ return rej('cannot set since_id and max_id');
+ }
+
+ // Get 'folder_id' parameter
+ let folder = params.folder_id;
+ if (folder === undefined || folder === null || folder === 'null') {
+ folder = null;
+ } else {
+ folder = new mongo.ObjectID(folder);
+ }
+
+ // Construct query
+ const sort = {
+ _id: -1
+ };
+ const query = {
+ user_id: user._id,
+ folder_id: folder
+ };
+ if (since !== null) {
+ sort._id = 1;
+ query._id = {
+ $gt: new mongo.ObjectID(since)
+ };
+ } else if (max !== null) {
+ query._id = {
+ $lt: new mongo.ObjectID(max)
+ };
+ }
+
+ // Issue query
+ const files = await DriveFile
+ .find(query, {
+ data: false
+ }, {
+ limit: limit,
+ sort: sort
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(files.map(async file =>
+ await serialize(file))));
+});
diff --git a/src/api/endpoints/drive/files/create.js b/src/api/endpoints/drive/files/create.js
new file mode 100644
index 0000000000..5966499c59
--- /dev/null
+++ b/src/api/endpoints/drive/files/create.js
@@ -0,0 +1,59 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as fs from 'fs';
+import * as mongo from 'mongodb';
+import File from '../../../models/drive-file';
+import { validateFileName } from '../../../models/drive-file';
+import User from '../../../models/user';
+import serialize from '../../../serializers/drive-file';
+import create from '../../../common/add-file-to-drive';
+
+/**
+ * Create a file
+ *
+ * @param {Object} file
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (file, params, user) =>
+ new Promise(async (res, rej) =>
+{
+ const buffer = fs.readFileSync(file.path);
+ fs.unlink(file.path);
+
+ // Get 'name' parameter
+ let name = file.originalname;
+ if (name !== undefined && name !== null) {
+ name = name.trim();
+ if (name.length === 0) {
+ name = null;
+ } else if (name === 'blob') {
+ name = null;
+ } else if (!validateFileName(name)) {
+ return rej('invalid name');
+ }
+ } else {
+ name = null;
+ }
+
+ // Get 'folder_id' parameter
+ let folder = params.folder_id;
+ if (folder === undefined || folder === null || folder === 'null') {
+ folder = null;
+ } else {
+ folder = new mongo.ObjectID(folder);
+ }
+
+ // Create file
+ const driveFile = await create(user, buffer, name, null, folder);
+
+ // Serialize
+ const fileObj = await serialize(driveFile);
+
+ // Response
+ res(fileObj);
+});
diff --git a/src/api/endpoints/drive/files/find.js b/src/api/endpoints/drive/files/find.js
new file mode 100644
index 0000000000..e4e4c230d2
--- /dev/null
+++ b/src/api/endpoints/drive/files/find.js
@@ -0,0 +1,48 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import DriveFile from '../../../models/drive-file';
+import serialize from '../../../serializers/drive-file';
+
+/**
+ * Find a file(s)
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'name' parameter
+ const name = params.name;
+ if (name === undefined || name === null) {
+ return rej('name is required');
+ }
+
+ // Get 'folder_id' parameter
+ let folder = params.folder_id;
+ if (folder === undefined || folder === null || folder === 'null') {
+ folder = null;
+ } else {
+ folder = new mongo.ObjectID(folder);
+ }
+
+ // Issue query
+ const files = await DriveFile
+ .find({
+ name: name,
+ user_id: user._id,
+ folder_id: folder
+ }, {
+ data: false
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(files.map(async file =>
+ await serialize(file))));
+});
diff --git a/src/api/endpoints/drive/files/show.js b/src/api/endpoints/drive/files/show.js
new file mode 100644
index 0000000000..79b07dace2
--- /dev/null
+++ b/src/api/endpoints/drive/files/show.js
@@ -0,0 +1,40 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import DriveFile from '../../../models/drive-file';
+import serialize from '../../../serializers/drive-file';
+
+/**
+ * Show a file
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'file_id' parameter
+ const fileId = params.file_id;
+ if (fileId === undefined || fileId === null) {
+ return rej('file_id is required');
+ }
+
+ const file = await DriveFile
+ .findOne({
+ _id: new mongo.ObjectID(fileId),
+ user_id: user._id
+ }, {
+ data: false
+ });
+
+ if (file === null) {
+ return rej('file-not-found');
+ }
+
+ // Serialize
+ res(await serialize(file));
+});
diff --git a/src/api/endpoints/drive/files/update.js b/src/api/endpoints/drive/files/update.js
new file mode 100644
index 0000000000..bbcb10b42d
--- /dev/null
+++ b/src/api/endpoints/drive/files/update.js
@@ -0,0 +1,89 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import DriveFolder from '../../../models/drive-folder';
+import DriveFile from '../../../models/drive-file';
+import { validateFileName } from '../../../models/drive-file';
+import serialize from '../../../serializers/drive-file';
+import event from '../../../event';
+
+/**
+ * Update a file
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'file_id' parameter
+ const fileId = params.file_id;
+ if (fileId === undefined || fileId === null) {
+ return rej('file_id is required');
+ }
+
+ const file = await DriveFile
+ .findOne({
+ _id: new mongo.ObjectID(fileId),
+ user_id: user._id
+ }, {
+ data: false
+ });
+
+ if (file === null) {
+ return rej('file-not-found');
+ }
+
+ // Get 'name' parameter
+ let name = params.name;
+ if (name) {
+ name = name.trim();
+ if (validateFileName(name)) {
+ file.name = name;
+ } else {
+ return rej('invalid file name');
+ }
+ }
+
+ // Get 'folder_id' parameter
+ let folderId = params.folder_id;
+ if (folderId !== undefined && folderId !== 'null') {
+ folderId = new mongo.ObjectID(folderId);
+ }
+
+ let folder = null;
+ if (folderId !== undefined && folderId !== null) {
+ if (folderId === 'null') {
+ file.folder_id = null;
+ } else {
+ folder = await DriveFolder
+ .findOne({
+ _id: folderId,
+ user_id: user._id
+ });
+
+ if (folder === null) {
+ return reject('folder-not-found');
+ }
+
+ file.folder_id = folder._id;
+ }
+ }
+
+ DriveFile.updateOne({ _id: file._id }, {
+ $set: file
+ });
+
+ // Serialize
+ const fileObj = await serialize(file);
+
+ // Response
+ res(fileObj);
+
+ // Publish drive_file_updated event
+ event(user._id, 'drive_file_updated', fileObj);
+});
diff --git a/src/api/endpoints/drive/folders.js b/src/api/endpoints/drive/folders.js
new file mode 100644
index 0000000000..f95a60036f
--- /dev/null
+++ b/src/api/endpoints/drive/folders.js
@@ -0,0 +1,82 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import DriveFolder from '../../models/drive-folder';
+import serialize from '../../serializers/drive-folder';
+
+/**
+ * Get drive folders
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @param {Object} app
+ * @return {Promise<object>}
+ */
+module.exports = (params, user, app) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ const since = params.since_id || null;
+ const max = params.max_id || null;
+
+ // Check if both of since_id and max_id is specified
+ if (since !== null && max !== null) {
+ return rej('cannot set since_id and max_id');
+ }
+
+ // Get 'folder_id' parameter
+ let folder = params.folder_id;
+ if (folder === undefined || folder === null || folder === 'null') {
+ folder = null;
+ } else {
+ folder = new mongo.ObjectID(folder);
+ }
+
+ // Construct query
+ const sort = {
+ created_at: -1
+ };
+ const query = {
+ user_id: user._id,
+ parent_id: folder
+ };
+ if (since !== null) {
+ sort.created_at = 1;
+ query._id = {
+ $gt: new mongo.ObjectID(since)
+ };
+ } else if (max !== null) {
+ query._id = {
+ $lt: new mongo.ObjectID(max)
+ };
+ }
+
+ // Issue query
+ const folders = await DriveFolder
+ .find(query, {
+ data: false
+ }, {
+ limit: limit,
+ sort: sort
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(folders.map(async folder =>
+ await serialize(folder))));
+});
diff --git a/src/api/endpoints/drive/folders/create.js b/src/api/endpoints/drive/folders/create.js
new file mode 100644
index 0000000000..ba40d1763e
--- /dev/null
+++ b/src/api/endpoints/drive/folders/create.js
@@ -0,0 +1,79 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import DriveFolder from '../../../models/drive-folder';
+import { isValidFolderName } from '../../../models/drive-folder';
+import serialize from '../../../serializers/drive-folder';
+import event from '../../../event';
+
+/**
+ * Create drive folder
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'name' parameter
+ let name = params.name;
+ if (name !== undefined && name !== null) {
+ name = name.trim();
+ if (name.length === 0) {
+ name = null;
+ } else if (!isValidFolderName(name)) {
+ return rej('invalid name');
+ }
+ } else {
+ name = null;
+ }
+
+ if (name == null) {
+ name = '無題のフォルダー';
+ }
+
+ // Get 'folder_id' parameter
+ let parentId = params.folder_id;
+ if (parentId === undefined || parentId === null) {
+ parentId = null;
+ } else {
+ parentId = new mongo.ObjectID(parentId);
+ }
+
+ // If the parent folder is specified
+ let parent = null;
+ if (parentId !== null) {
+ parent = await DriveFolder
+ .findOne({
+ _id: parentId,
+ user_id: user._id
+ });
+
+ if (parent === null) {
+ return reject('parent-not-found');
+ }
+ }
+
+ // Create folder
+ const inserted = await DriveFolder.insert({
+ created_at: new Date(),
+ name: name,
+ parent_id: parent !== null ? parent._id : null,
+ user_id: user._id
+ });
+
+ const folder = inserted.ops[0];
+
+ // Serialize
+ const folderObj = await serialize(folder);
+
+ // Response
+ res(folderObj);
+
+ // Publish drive_folder_created event
+ event(user._id, 'drive_folder_created', folderObj);
+});
diff --git a/src/api/endpoints/drive/folders/find.js b/src/api/endpoints/drive/folders/find.js
new file mode 100644
index 0000000000..01805dc910
--- /dev/null
+++ b/src/api/endpoints/drive/folders/find.js
@@ -0,0 +1,46 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import DriveFolder from '../../../models/drive-folder';
+import serialize from '../../../serializers/drive-folder';
+
+/**
+ * Find a folder(s)
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'name' parameter
+ const name = params.name;
+ if (name === undefined || name === null) {
+ return rej('name is required');
+ }
+
+ // Get 'parent_id' parameter
+ let parentId = params.parent_id;
+ if (parentId === undefined || parentId === null || parentId === 'null') {
+ parentId = null;
+ } else {
+ parentId = new mongo.ObjectID(parentId);
+ }
+
+ // Issue query
+ const folders = await DriveFolder
+ .find({
+ name: name,
+ user_id: user._id,
+ parent_id: parentId
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(folders.map(async folder =>
+ await serialize(folder))));
+});
diff --git a/src/api/endpoints/drive/folders/show.js b/src/api/endpoints/drive/folders/show.js
new file mode 100644
index 0000000000..4424361a87
--- /dev/null
+++ b/src/api/endpoints/drive/folders/show.js
@@ -0,0 +1,41 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import DriveFolder from '../../../models/drive-folder';
+import serialize from '../../../serializers/drive-folder';
+
+/**
+ * Show a folder
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'folder_id' parameter
+ const folderId = params.folder_id;
+ if (folderId === undefined || folderId === null) {
+ return rej('folder_id is required');
+ }
+
+ // Get folder
+ const folder = await DriveFolder
+ .findOne({
+ _id: new mongo.ObjectID(folderId),
+ user_id: user._id
+ });
+
+ if (folder === null) {
+ return rej('folder-not-found');
+ }
+
+ // Serialize
+ res(await serialize(folder, {
+ includeParent: true
+ }));
+});
diff --git a/src/api/endpoints/drive/folders/update.js b/src/api/endpoints/drive/folders/update.js
new file mode 100644
index 0000000000..ff26a09aae
--- /dev/null
+++ b/src/api/endpoints/drive/folders/update.js
@@ -0,0 +1,114 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import DriveFolder from '../../../models/drive-folder';
+import { isValidFolderName } from '../../../models/drive-folder';
+import serialize from '../../../serializers/drive-file';
+import event from '../../../event';
+
+/**
+ * Update a folder
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'folder_id' parameter
+ const folderId = params.folder_id;
+ if (folderId === undefined || folderId === null) {
+ return rej('folder_id is required');
+ }
+
+ // Fetch folder
+ const folder = await DriveFolder
+ .findOne({
+ _id: new mongo.ObjectID(folderId),
+ user_id: user._id
+ });
+
+ if (folder === null) {
+ return rej('folder-not-found');
+ }
+
+ // Get 'name' parameter
+ let name = params.name;
+ if (name) {
+ name = name.trim();
+ if (isValidFolderName(name)) {
+ folder.name = name;
+ } else {
+ return rej('invalid folder name');
+ }
+ }
+
+ // Get 'parent_id' parameter
+ let parentId = params.parent_id;
+ if (parentId !== undefined && parentId !== 'null') {
+ parentId = new mongo.ObjectID(parentId);
+ }
+
+ let parent = null;
+ if (parentId !== undefined && parentId !== null) {
+ if (parentId === 'null') {
+ folder.parent_id = null;
+ } else {
+ // Get parent folder
+ parent = await DriveFolder
+ .findOne({
+ _id: parentId,
+ user_id: user._id
+ });
+
+ if (parent === null) {
+ return rej('parent-folder-not-found');
+ }
+
+ // Check if the circular reference will be occured
+ async function checkCircle(folderId) {
+ // Fetch folder
+ const folder2 = await DriveFolder.findOne({
+ _id: folderId
+ }, {
+ _id: true,
+ parent_id: true
+ });
+
+ if (folder2._id.equals(folder._id)) {
+ return true;
+ } else if (folder2.parent_id) {
+ return await checkCircle(folder2.parent_id);
+ } else {
+ return false;
+ }
+ }
+
+ if (parent.parent_id !== null) {
+ if (await checkCircle(parent.parent_id)) {
+ return rej('detected-circular-definition');
+ }
+ }
+
+ folder.parent_id = parent._id;
+ }
+ }
+
+ // Update
+ DriveFolder.updateOne({ _id: folder._id }, {
+ $set: folder
+ });
+
+ // Serialize
+ const folderObj = await serialize(folder);
+
+ // Response
+ res(folderObj);
+
+ // Publish drive_folder_updated event
+ event(user._id, 'drive_folder_updated', folderObj);
+});
diff --git a/src/api/endpoints/drive/stream.js b/src/api/endpoints/drive/stream.js
new file mode 100644
index 0000000000..0f407f5591
--- /dev/null
+++ b/src/api/endpoints/drive/stream.js
@@ -0,0 +1,85 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import DriveFile from '../../models/drive-file';
+import serialize from '../../serializers/drive-file';
+
+/**
+ * Get drive stream
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ const since = params.since_id || null;
+ const max = params.max_id || null;
+
+ // Check if both of since_id and max_id is specified
+ if (since !== null && max !== null) {
+ return rej('cannot set since_id and max_id');
+ }
+
+ // Get 'type' parameter
+ let type = params.type;
+ if (type === undefined || type === null) {
+ type = null;
+ } else if (!/^[a-zA-Z\/\-\*]+$/.test(type)) {
+ return rej('invalid type format');
+ } else {
+ type = new RegExp(`^${type.replace(/\*/g, '.+?')}$`);
+ }
+
+ // Construct query
+ const sort = {
+ created_at: -1
+ };
+ const query = {
+ user_id: user._id
+ };
+ if (since !== null) {
+ sort.created_at = 1;
+ query._id = {
+ $gt: new mongo.ObjectID(since)
+ };
+ } else if (max !== null) {
+ query._id = {
+ $lt: new mongo.ObjectID(max)
+ };
+ }
+ if (type !== null) {
+ query.type = type;
+ }
+
+ // Issue query
+ const files = await DriveFile
+ .find(query, {
+ data: false
+ }, {
+ limit: limit,
+ sort: sort
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(files.map(async file =>
+ await serialize(file))));
+});
diff --git a/src/api/endpoints/following/create.js b/src/api/endpoints/following/create.js
new file mode 100644
index 0000000000..da714cb180
--- /dev/null
+++ b/src/api/endpoints/following/create.js
@@ -0,0 +1,86 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import User from '../../models/user';
+import Following from '../../models/following';
+import notify from '../../common/notify';
+import event from '../../event';
+import serializeUser from '../../serializers/user';
+
+/**
+ * Follow a user
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ const follower = user;
+
+ // Get 'user_id' parameter
+ let userId = params.user_id;
+ if (userId === undefined || userId === null) {
+ return rej('user_id is required');
+ }
+
+ // 自分自身
+ if (user._id.equals(userId)) {
+ return rej('followee is yourself');
+ }
+
+ // Get followee
+ const followee = await User.findOne({
+ _id: new mongo.ObjectID(userId)
+ });
+
+ if (followee === null) {
+ return rej('user not found');
+ }
+
+ // Check arleady following
+ const exist = await Following.findOne({
+ follower_id: follower._id,
+ followee_id: followee._id,
+ deleted_at: { $exists: false }
+ });
+
+ if (exist !== null) {
+ return rej('already following');
+ }
+
+ // Create following
+ await Following.insert({
+ created_at: new Date(),
+ follower_id: follower._id,
+ followee_id: followee._id
+ });
+
+ // Send response
+ res();
+
+ // Increment following count
+ User.updateOne({ _id: follower._id }, {
+ $inc: {
+ following_count: 1
+ }
+ });
+
+ // Increment followers count
+ User.updateOne({ _id: followee._id }, {
+ $inc: {
+ followers_count: 1
+ }
+ });
+
+ // Publish follow event
+ event(follower._id, 'follow', await serializeUser(followee, follower));
+ event(followee._id, 'followed', await serializeUser(follower, followee));
+
+ // Notify
+ notify(followee._id, follower._id, 'follow');
+});
diff --git a/src/api/endpoints/following/delete.js b/src/api/endpoints/following/delete.js
new file mode 100644
index 0000000000..f1096801b6
--- /dev/null
+++ b/src/api/endpoints/following/delete.js
@@ -0,0 +1,83 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import User from '../../models/user';
+import Following from '../../models/following';
+import event from '../../event';
+import serializeUser from '../../serializers/user';
+
+/**
+ * Unfollow a user
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ const follower = user;
+
+ // Get 'user_id' parameter
+ let userId = params.user_id;
+ if (userId === undefined || userId === null) {
+ return rej('user_id is required');
+ }
+
+ // Check if the followee is yourself
+ if (user._id.equals(userId)) {
+ return rej('followee is yourself');
+ }
+
+ // Get followee
+ const followee = await User.findOne({
+ _id: new mongo.ObjectID(userId)
+ });
+
+ if (followee === null) {
+ return rej('user not found');
+ }
+
+ // Check not following
+ const exist = await Following.findOne({
+ follower_id: follower._id,
+ followee_id: followee._id,
+ deleted_at: { $exists: false }
+ });
+
+ if (exist === null) {
+ return rej('already not following');
+ }
+
+ // Delete following
+ await Following.updateOne({
+ _id: exist._id
+ }, {
+ $set: {
+ deleted_at: new Date()
+ }
+ });
+
+ // Send response
+ res();
+
+ // Decrement following count
+ User.updateOne({ _id: follower._id }, {
+ $inc: {
+ following_count: -1
+ }
+ });
+
+ // Decrement followers count
+ User.updateOne({ _id: followee._id }, {
+ $inc: {
+ followers_count: -1
+ }
+ });
+
+ // Publish follow event
+ event(follower._id, 'unfollow', await serializeUser(followee, follower));
+});
diff --git a/src/api/endpoints/i.js b/src/api/endpoints/i.js
new file mode 100644
index 0000000000..481ddbb9fa
--- /dev/null
+++ b/src/api/endpoints/i.js
@@ -0,0 +1,25 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import serialize from '../serializers/user';
+
+/**
+ * Show myself
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @param {Object} app
+ * @param {Boolean} isSecure
+ * @return {Promise<object>}
+ */
+module.exports = (params, user, _, isSecure) =>
+ new Promise(async (res, rej) =>
+{
+ // Serialize
+ res(await serialize(user, user, {
+ detail: true,
+ includeSecrets: isSecure
+ }));
+});
diff --git a/src/api/endpoints/i/appdata/get.js b/src/api/endpoints/i/appdata/get.js
new file mode 100644
index 0000000000..0a86697469
--- /dev/null
+++ b/src/api/endpoints/i/appdata/get.js
@@ -0,0 +1,53 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import Appdata from '../../../models/appdata';
+
+/**
+ * Get app data
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @param {Object} app
+ * @param {Boolean} isSecure
+ * @return {Promise<object>}
+ */
+module.exports = (params, user, app, isSecure) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'key' parameter
+ let key = params.key;
+ if (key === undefined) {
+ key = null;
+ }
+
+ if (isSecure) {
+ if (!user.data) {
+ return res();
+ }
+ if (key !== null) {
+ const data = {};
+ data[key] = user.data[key];
+ res(data);
+ } else {
+ res(user.data);
+ }
+ } else {
+ const select = {};
+ if (key !== null) {
+ select['data.' + key] = true;
+ }
+ const appdata = await Appdata.findOne({
+ app_id: app._id,
+ user_id: user._id
+ }, select);
+
+ if (appdata) {
+ res(appdata.data);
+ } else {
+ res();
+ }
+ }
+});
diff --git a/src/api/endpoints/i/appdata/set.js b/src/api/endpoints/i/appdata/set.js
new file mode 100644
index 0000000000..e161a803d0
--- /dev/null
+++ b/src/api/endpoints/i/appdata/set.js
@@ -0,0 +1,55 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import Appdata from '../../../models/appdata';
+import User from '../../../models/user';
+
+/**
+ * Set app data
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @param {Object} app
+ * @param {Boolean} isSecure
+ * @return {Promise<object>}
+ */
+module.exports = (params, user, app, isSecure) =>
+ new Promise(async (res, rej) =>
+{
+ const data = params.data;
+ if (data == null) {
+ return rej('data is required');
+ }
+
+ if (isSecure) {
+ const set = {
+ $set: {
+ data: Object.assign(user.data || {}, JSON.parse(data))
+ }
+ };
+ await User.updateOne({ _id: user._id }, set);
+ res(204);
+ } else {
+ const appdata = await Appdata.findOne({
+ app_id: app._id,
+ user_id: user._id
+ });
+ const set = {
+ $set: {
+ data: Object.assign((appdata || {}).data || {}, JSON.parse(data))
+ }
+ };
+ await Appdata.updateOne({
+ app_id: app._id,
+ user_id: user._id
+ }, Object.assign({
+ app_id: app._id,
+ user_id: user._id
+ }, set), {
+ upsert: true
+ });
+ res(204);
+ }
+});
diff --git a/src/api/endpoints/i/favorites.js b/src/api/endpoints/i/favorites.js
new file mode 100644
index 0000000000..e30ea2867b
--- /dev/null
+++ b/src/api/endpoints/i/favorites.js
@@ -0,0 +1,60 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Favorite from '../../models/favorite';
+import serialize from '../../serializers/post';
+
+/**
+ * Get followers of a user
+ *
+ * @param {Object} params
+ * @return {Promise<object>}
+ */
+module.exports = (params) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ // Get 'offset' parameter
+ let offset = params.offset;
+ if (offset !== undefined && offset !== null) {
+ offset = parseInt(offset, 10);
+ } else {
+ offset = 0;
+ }
+
+ // Get 'sort' parameter
+ let sort = params.sort || 'desc';
+
+ // Get favorites
+ const favorites = await Favorites
+ .find({
+ user_id: user._id
+ }, {}, {
+ limit: limit,
+ skip: offset,
+ sort: {
+ _id: sort == 'asc' ? 1 : -1
+ }
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(favorites.map(async favorite =>
+ await serialize(favorite.post)
+ )));
+});
diff --git a/src/api/endpoints/i/notifications.js b/src/api/endpoints/i/notifications.js
new file mode 100644
index 0000000000..a28ceb76a0
--- /dev/null
+++ b/src/api/endpoints/i/notifications.js
@@ -0,0 +1,120 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Notification from '../../models/notification';
+import serialize from '../../serializers/notification';
+import getFriends from '../../common/get-friends';
+
+/**
+ * Get notifications
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'following' parameter
+ const following = params.following === 'true';
+
+ // Get 'mark_as_read' parameter
+ let markAsRead = params.mark_as_read;
+ if (markAsRead == null) {
+ markAsRead = true;
+ } else {
+ markAsRead = markAsRead === 'true';
+ }
+
+ // Get 'type' parameter
+ let type = params.type;
+ if (type !== undefined && type !== null) {
+ type = type.split(',').map(x => x.trim());
+ }
+
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ const since = params.since_id || null;
+ const max = params.max_id || null;
+
+ // Check if both of since_id and max_id is specified
+ if (since !== null && max !== null) {
+ return rej('cannot set since_id and max_id');
+ }
+
+ const query = {
+ notifiee_id: user._id
+ };
+
+ const sort = {
+ _id: -1
+ };
+
+ if (following) {
+ // ID list of the user itself and other users who the user follows
+ const followingIds = await getFriends(user._id);
+
+ query.notifier_id = {
+ $in: followingIds
+ };
+ }
+
+ if (type) {
+ query.type = {
+ $in: type
+ };
+ }
+
+ if (since !== null) {
+ sort._id = 1;
+ query._id = {
+ $gt: new mongo.ObjectID(since)
+ };
+ } else if (max !== null) {
+ query._id = {
+ $lt: new mongo.ObjectID(max)
+ };
+ }
+
+ // Issue query
+ const notifications = await Notification
+ .find(query, {}, {
+ limit: limit,
+ sort: sort
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(notifications.map(async notification =>
+ await serialize(notification))));
+
+ // 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
+ });
+ }
+});
diff --git a/src/api/endpoints/i/signin_history.js b/src/api/endpoints/i/signin_history.js
new file mode 100644
index 0000000000..7def8a41e5
--- /dev/null
+++ b/src/api/endpoints/i/signin_history.js
@@ -0,0 +1,71 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Signin from '../../models/signin';
+import serialize from '../../serializers/signin';
+
+/**
+ * Get signin history of my account
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ const since = params.since_id || null;
+ const max = params.max_id || null;
+
+ // Check if both of since_id and max_id is specified
+ if (since !== null && max !== null) {
+ return rej('cannot set since_id and max_id');
+ }
+
+ const query = {
+ user_id: user._id
+ };
+
+ const sort = {
+ _id: -1
+ };
+
+ if (since !== null) {
+ sort._id = 1;
+ query._id = {
+ $gt: new mongo.ObjectID(since)
+ };
+ } else if (max !== null) {
+ query._id = {
+ $lt: new mongo.ObjectID(max)
+ };
+ }
+
+ // Issue query
+ const history = await Signin
+ .find(query, {}, {
+ limit: limit,
+ sort: sort
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(history.map(async record =>
+ await serialize(record))));
+});
diff --git a/src/api/endpoints/i/update.js b/src/api/endpoints/i/update.js
new file mode 100644
index 0000000000..a6b68cf01e
--- /dev/null
+++ b/src/api/endpoints/i/update.js
@@ -0,0 +1,95 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import User from '../../models/user';
+import serialize from '../../serializers/user';
+import event from '../../event';
+
+/**
+ * Update myself
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @param {Object} _
+ * @param {boolean} isSecure
+ * @return {Promise<object>}
+ */
+module.exports = async (params, user, _, isSecure) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'name' parameter
+ const name = params.name;
+ if (name !== undefined && name !== null) {
+ if (name.length > 50) {
+ return rej('too long name');
+ }
+
+ user.name = name;
+ }
+
+ // Get 'location' parameter
+ const location = params.location;
+ if (location !== undefined && location !== null) {
+ if (location.length > 50) {
+ return rej('too long location');
+ }
+
+ user.location = location;
+ }
+
+ // Get 'bio' parameter
+ const bio = params.bio;
+ if (bio !== undefined && bio !== null) {
+ if (bio.length > 500) {
+ return rej('too long bio');
+ }
+
+ user.bio = bio;
+ }
+
+ // Get 'avatar_id' parameter
+ const avatar = params.avatar_id;
+ if (avatar !== undefined && avatar !== null) {
+ user.avatar_id = new mongo.ObjectID(avatar);
+ }
+
+ // Get 'banner_id' parameter
+ const banner = params.banner_id;
+ if (banner !== undefined && banner !== null) {
+ user.banner_id = new mongo.ObjectID(banner);
+ }
+
+ await User.updateOne({ _id: user._id }, {
+ $set: user
+ });
+
+ // Serialize
+ const iObj = await serialize(user, user, {
+ detail: true,
+ includeSecrets: isSecure
+ })
+
+ // Send response
+ res(iObj);
+
+ // Publish i updated event
+ event(user._id, 'i_updated', iObj);
+
+ // Update search index
+ if (config.elasticsearch.enable) {
+ const es = require('../../../db/elasticsearch');
+
+ es.index({
+ index: 'misskey',
+ type: 'user',
+ id: user._id.toString(),
+ body: {
+ name: user.name,
+ bio: user.bio
+ }
+ });
+ }
+});
diff --git a/src/api/endpoints/messaging/history.js b/src/api/endpoints/messaging/history.js
new file mode 100644
index 0000000000..dafb38fd1a
--- /dev/null
+++ b/src/api/endpoints/messaging/history.js
@@ -0,0 +1,48 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import History from '../../models/messaging-history';
+import serialize from '../../serializers/messaging-message';
+
+/**
+ * Show messaging history
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ // Get history
+ const history = await History
+ .find({
+ user_id: user._id
+ }, {}, {
+ limit: limit,
+ sort: {
+ updated_at: -1
+ }
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(history.map(async h =>
+ await serialize(h.message, user))));
+});
diff --git a/src/api/endpoints/messaging/messages.js b/src/api/endpoints/messaging/messages.js
new file mode 100644
index 0000000000..12bd13597a
--- /dev/null
+++ b/src/api/endpoints/messaging/messages.js
@@ -0,0 +1,139 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Message from '../../models/messaging-message';
+import User from '../../models/user';
+import serialize from '../../serializers/messaging-message';
+import publishUserStream from '../../event';
+import { publishMessagingStream } from '../../event';
+
+/**
+ * Get messages
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'user_id' parameter
+ let recipient = params.user_id;
+ if (recipient !== undefined && recipient !== null) {
+ recipient = await User.findOne({
+ _id: new mongo.ObjectID(recipient)
+ });
+
+ if (recipient === null) {
+ return rej('user not found');
+ }
+ } else {
+ return rej('user_id is required');
+ }
+
+ // Get 'mark_as_read' parameter
+ let markAsRead = params.mark_as_read;
+ if (markAsRead == null) {
+ markAsRead = true;
+ } else {
+ markAsRead = markAsRead === 'true';
+ }
+
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ const since = params.since_id || null;
+ const max = params.max_id || null;
+
+ // Check if both of since_id and max_id is specified
+ if (since !== null && max !== null) {
+ return rej('cannot set since_id and max_id');
+ }
+
+ const query = {
+ $or: [{
+ user_id: user._id,
+ recipient_id: recipient._id
+ }, {
+ user_id: recipient._id,
+ recipient_id: user._id
+ }]
+ };
+
+ const sort = {
+ created_at: -1
+ };
+
+ if (since !== null) {
+ sort.created_at = 1;
+ query._id = {
+ $gt: new mongo.ObjectID(since)
+ };
+ } else if (max !== null) {
+ query._id = {
+ $lt: new mongo.ObjectID(max)
+ };
+ }
+
+ // Issue query
+ const messages = await Message
+ .find(query, {}, {
+ limit: limit,
+ sort: sort
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(messages.map(async message =>
+ await serialize(message, user, {
+ populateRecipient: false
+ }))));
+
+ if (messages.length === 0) {
+ return;
+ }
+
+ // Mark as read all
+ if (markAsRead) {
+ const ids = messages
+ .filter(m => m.is_read == false)
+ .filter(m => m.recipient_id.equals(user._id))
+ .map(m => m._id);
+
+ // Update documents
+ await Message.update({
+ _id: { $in: ids }
+ }, {
+ $set: { is_read: true }
+ }, {
+ multi: true
+ });
+
+ // Publish event
+ publishMessagingStream(recipient._id, user._id, 'read', ids.map(id => id.toString()));
+
+ const count = await Message
+ .count({
+ recipient_id: user._id,
+ is_read: false
+ });
+
+ if (count == 0) {
+ // 全ての(いままで未読だった)メッセージを(これで)読みましたよというイベントを発行
+ publishUserStream(user._id, 'read_all_messaging_messages');
+ }
+ }
+});
diff --git a/src/api/endpoints/messaging/messages/create.js b/src/api/endpoints/messaging/messages/create.js
new file mode 100644
index 0000000000..33634a6140
--- /dev/null
+++ b/src/api/endpoints/messaging/messages/create.js
@@ -0,0 +1,152 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Message from '../../../models/messaging-message';
+import History from '../../../models/messaging-history';
+import User from '../../../models/user';
+import DriveFile from '../../../models/drive-file';
+import serialize from '../../../serializers/messaging-message';
+import publishUserStream from '../../../event';
+import { publishMessagingStream } from '../../../event';
+
+/**
+ * 最大文字数
+ */
+const maxTextLength = 500;
+
+/**
+ * Create a message
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'user_id' parameter
+ let recipient = params.user_id;
+ if (recipient !== undefined && recipient !== null) {
+ recipient = await User.findOne({
+ _id: new mongo.ObjectID(recipient)
+ });
+
+ if (recipient === null) {
+ return rej('user not found');
+ }
+ } else {
+ return rej('user_id is required');
+ }
+
+ // Get 'text' parameter
+ let text = params.text;
+ if (text !== undefined && text !== null) {
+ text = text.trim();
+ if (text.length === 0) {
+ text = null;
+ } else if (text.length > maxTextLength) {
+ return rej('too long text');
+ }
+ } else {
+ text = null;
+ }
+
+ // Get 'file_id' parameter
+ let file = params.file_id;
+ if (file !== undefined && file !== null) {
+ file = await DriveFile.findOne({
+ _id: new mongo.ObjectID(file),
+ user_id: user._id
+ }, {
+ data: false
+ });
+
+ if (file === null) {
+ return rej('file not found');
+ }
+ } else {
+ file = null;
+ }
+
+ // テキストが無いかつ添付ファイルも無かったらエラー
+ if (text === null && file === null) {
+ return rej('text or file is required');
+ }
+
+ // メッセージを作成
+ const inserted = await Message.insert({
+ created_at: new Date(),
+ file_id: file ? file._id : undefined,
+ recipient_id: recipient._id,
+ text: text ? text : undefined,
+ user_id: user._id,
+ is_read: false
+ });
+
+ const message = inserted.ops[0];
+
+ // Serialize
+ const messageObj = await serialize(message);
+
+ // Reponse
+ res(messageObj);
+
+ // 自分のストリーム
+ publishMessagingStream(message.user_id, message.recipient_id, 'message', messageObj);
+ publishUserStream(message.user_id, 'messaging_message', messageObj);
+
+ // 相手のストリーム
+ publishMessagingStream(message.recipient_id, message.user_id, 'message', messageObj);
+ publishUserStream(message.recipient_id, 'messaging_message', messageObj);
+
+ // 5秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する
+ setTimeout(async () => {
+ const freshMessage = await Message.findOne({ _id: message._id }, { is_read: true });
+ if (!freshMessage.is_read) {
+ publishUserStream(message.recipient_id, 'unread_messaging_message', messageObj);
+ }
+ }, 5000);
+
+ // Register to search database
+ if (message.text && config.elasticsearch.enable) {
+ const es = require('../../../db/elasticsearch');
+
+ es.index({
+ index: 'misskey',
+ type: 'messaging_message',
+ id: message._id.toString(),
+ body: {
+ text: message.text
+ }
+ });
+ }
+
+ // 履歴作成(自分)
+ History.updateOne({
+ user_id: user._id,
+ partner: recipient._id
+ }, {
+ updated_at: new Date(),
+ user_id: user._id,
+ partner: recipient._id,
+ message: message._id
+ }, {
+ upsert: true
+ });
+
+ // 履歴作成(相手)
+ History.updateOne({
+ user_id: recipient._id,
+ partner: user._id
+ }, {
+ updated_at: new Date(),
+ user_id: recipient._id,
+ partner: user._id,
+ message: message._id
+ }, {
+ upsert: true
+ });
+});
diff --git a/src/api/endpoints/messaging/unread.js b/src/api/endpoints/messaging/unread.js
new file mode 100644
index 0000000000..d2de0bc448
--- /dev/null
+++ b/src/api/endpoints/messaging/unread.js
@@ -0,0 +1,27 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import Message from '../../models/messaging-message';
+
+/**
+ * Get count of unread messages
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ const count = await Message
+ .count({
+ recipient_id: user._id,
+ is_read: false
+ });
+
+ res({
+ count: count
+ });
+});
diff --git a/src/api/endpoints/meta.js b/src/api/endpoints/meta.js
new file mode 100644
index 0000000000..7938cb91b4
--- /dev/null
+++ b/src/api/endpoints/meta.js
@@ -0,0 +1,24 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import Git from 'nodegit';
+
+/**
+ * Show core info
+ *
+ * @param {Object} params
+ * @return {Promise<object>}
+ */
+module.exports = (params) =>
+ new Promise(async (res, rej) =>
+{
+ const repository = await Git.Repository.open(__dirname + '/../../');
+
+ res({
+ maintainer: config.maintainer,
+ commit: (await repository.getHeadCommit()).sha(),
+ secure: config.https.enable
+ });
+});
diff --git a/src/api/endpoints/my/apps.js b/src/api/endpoints/my/apps.js
new file mode 100644
index 0000000000..d23bc38b1c
--- /dev/null
+++ b/src/api/endpoints/my/apps.js
@@ -0,0 +1,59 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import App from '../../models/app';
+import serialize from '../../serializers/app';
+
+/**
+ * Get my apps
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ // Get 'offset' parameter
+ let offset = params.offset;
+ if (offset !== undefined && offset !== null) {
+ offset = parseInt(offset, 10);
+ } else {
+ offset = 0;
+ }
+
+ const query = {
+ user_id: user._id
+ };
+
+ // Execute query
+ const apps = await App
+ .find(query, {}, {
+ limit: limit,
+ skip: offset,
+ sort: {
+ created_at: -1
+ }
+ })
+ .toArray();
+
+ // Reply
+ res(await Promise.all(apps.map(async app =>
+ await serialize(app))));
+});
diff --git a/src/api/endpoints/notifications/mark_as_read.js b/src/api/endpoints/notifications/mark_as_read.js
new file mode 100644
index 0000000000..16eb2009ac
--- /dev/null
+++ b/src/api/endpoints/notifications/mark_as_read.js
@@ -0,0 +1,54 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Notification from '../../../models/notification';
+import serialize from '../../../serializers/notification';
+import event from '../../../event';
+
+/**
+ * Mark as read a notification
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ const notificationId = params.notification;
+
+ if (notificationId === undefined || notificationId === null) {
+ return rej('notification is required');
+ }
+
+ // Get notifcation
+ const notification = await Notification
+ .findOne({
+ _id: new mongo.ObjectID(notificationId),
+ i: user._id
+ });
+
+ if (notification === null) {
+ return rej('notification-not-found');
+ }
+
+ // Update
+ notification.is_read = true;
+ Notification.updateOne({ _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/posts.js b/src/api/endpoints/posts.js
new file mode 100644
index 0000000000..05fc871ec1
--- /dev/null
+++ b/src/api/endpoints/posts.js
@@ -0,0 +1,65 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import Post from '../models/post';
+import serialize from '../serializers/post';
+
+/**
+ * Lists all posts
+ *
+ * @param {Object} params
+ * @return {Promise<object>}
+ */
+module.exports = (params) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ const since = params.since_id || null;
+ const max = params.max_id || null;
+
+ // Check if both of since_id and max_id is specified
+ if (since !== null && max !== null) {
+ return rej('cannot set since_id and max_id');
+ }
+
+ // Construct query
+ const sort = {
+ created_at: -1
+ };
+ const query = {};
+ if (since !== null) {
+ sort.created_at = 1;
+ query._id = {
+ $gt: new mongo.ObjectID(since)
+ };
+ } else if (max !== null) {
+ query._id = {
+ $lt: new mongo.ObjectID(max)
+ };
+ }
+
+ // Issue query
+ const posts = await Post
+ .find(query, {}, {
+ limit: limit,
+ sort: sort
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(posts.map(async post => await serialize(post))));
+});
diff --git a/src/api/endpoints/posts/context.js b/src/api/endpoints/posts/context.js
new file mode 100644
index 0000000000..5f040b8505
--- /dev/null
+++ b/src/api/endpoints/posts/context.js
@@ -0,0 +1,83 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Post from '../../models/post';
+import serialize from '../../serializers/post';
+
+/**
+ * Show a context of a post
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'post_id' parameter
+ const postId = params.post_id;
+ if (postId === undefined || postId === null) {
+ return rej('post_id is required');
+ }
+
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ // Get 'offset' parameter
+ let offset = params.offset;
+ if (offset !== undefined && offset !== null) {
+ offset = parseInt(offset, 10);
+ } else {
+ offset = 0;
+ }
+
+ // Lookup post
+ const post = await Post.findOne({
+ _id: new mongo.ObjectID(postId)
+ });
+
+ if (post === null) {
+ return rej('post not found', 'POST_NOT_FOUND');
+ }
+
+ const context = [];
+ let i = 0;
+
+ async function get(id) {
+ i++;
+ const p = await Post.findOne({ _id: id });
+
+ if (i > offset) {
+ context.push(p);
+ }
+
+ if (context.length == limit) {
+ return;
+ }
+
+ if (p.reply_to_id) {
+ await get(p.reply_to_id);
+ }
+ }
+
+ if (post.reply_to_id) {
+ await get(post.reply_to_id);
+ }
+
+ // Serialize
+ res(await Promise.all(context.map(async post =>
+ await serialize(post, user))));
+});
diff --git a/src/api/endpoints/posts/create.js b/src/api/endpoints/posts/create.js
new file mode 100644
index 0000000000..cdcbf4f966
--- /dev/null
+++ b/src/api/endpoints/posts/create.js
@@ -0,0 +1,345 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import parse from '../../../common/text';
+import Post from '../../models/post';
+import User from '../../models/user';
+import Following from '../../models/following';
+import DriveFile from '../../models/drive-file';
+import serialize from '../../serializers/post';
+import createFile from '../../common/add-file-to-drive';
+import notify from '../../common/notify';
+import event from '../../event';
+
+/**
+ * 最大文字数
+ */
+const maxTextLength = 300;
+
+/**
+ * 添付できるファイルの数
+ */
+const maxMediaCount = 4;
+
+/**
+ * Create a post
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @param {Object} app
+ * @return {Promise<object>}
+ */
+module.exports = (params, user, app) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'text' parameter
+ let text = params.text;
+ if (text !== undefined && text !== null) {
+ text = text.trim();
+ if (text.length == 0) {
+ text = null;
+ } else if (text.length > maxTextLength) {
+ return rej('too long text');
+ }
+ } else {
+ text = null;
+ }
+
+ // Get 'media_ids' parameter
+ let media = params.media_ids;
+ let files = [];
+ if (media !== undefined && media !== null) {
+ media = media.split(',');
+
+ if (media.length > maxMediaCount) {
+ return rej('too many media');
+ }
+
+ // Drop duplicates
+ media = media.filter((x, i, s) => s.indexOf(x) == i);
+
+ // Fetch files
+ // forEach だと途中でエラーなどがあっても return できないので
+ // 敢えて for を使っています。
+ for (let i = 0; i < media.length; i++) {
+ const image = media[i];
+
+ // Fetch file
+ // SELECT _id
+ const entity = await DriveFile.findOne({
+ _id: new mongo.ObjectID(image),
+ user_id: user._id
+ }, {
+ _id: true
+ });
+
+ if (entity === null) {
+ return rej('file not found');
+ } else {
+ files.push(entity);
+ }
+ }
+ } else {
+ files = null;
+ }
+
+ // Get 'repost_id' parameter
+ let repost = params.repost_id;
+ if (repost !== undefined && repost !== null) {
+ // Fetch repost to post
+ repost = await Post.findOne({
+ _id: new mongo.ObjectID(repost)
+ });
+
+ if (repost == null) {
+ return rej('repostee is not found');
+ } else if (repost.repost_id && !repost.text && !repost.media_ids) {
+ return rej('cannot repost to repost');
+ }
+
+ // Fetch recently post
+ const latestPost = await Post.findOne({
+ user_id: user._id
+ }, {}, {
+ sort: {
+ _id: -1
+ }
+ });
+
+ // 直近と同じRepost対象かつ引用じゃなかったらエラー
+ if (latestPost &&
+ latestPost.repost_id &&
+ latestPost.repost_id.equals(repost._id) &&
+ text === null && files === null) {
+ return rej('二重Repostです(NEED TRANSLATE)');
+ }
+
+ // 直近がRepost対象かつ引用じゃなかったらエラー
+ if (latestPost &&
+ latestPost._id.equals(repost._id) &&
+ text === null && files === null) {
+ return rej('二重Repostです(NEED TRANSLATE)');
+ }
+ } else {
+ repost = null;
+ }
+
+ // Get 'reply_to_id' parameter
+ let replyTo = params.reply_to_id;
+ if (replyTo !== undefined && replyTo !== null) {
+ replyTo = await Post.findOne({
+ _id: new mongo.ObjectID(replyTo)
+ });
+
+ if (replyTo === null) {
+ return rej('reply to post is not found');
+ }
+
+ // 返信対象が引用でないRepostだったらエラー
+ if (replyTo.repost_id && !replyTo.text && !replyTo.media_ids) {
+ return rej('cannot reply to repost');
+ }
+ } else {
+ replyTo = null;
+ }
+
+ // テキストが無いかつ添付ファイルが無いかつRepostも無かったらエラー
+ if (text === null && files === null && repost === null) {
+ return rej('text, media_ids or repost_id is required');
+ }
+
+ // 投稿を作成
+ const inserted = await Post.insert({
+ created_at: new Date(),
+ media_ids: media ? files.map(file => file._id) : undefined,
+ reply_to_id: replyTo ? replyTo._id : undefined,
+ repost_id: repost ? repost._id : undefined,
+ text: text,
+ user_id: user._id,
+ app_id: app ? app._id : null
+ });
+
+ const post = inserted.ops[0];
+
+ // Serialize
+ const postObj = await serialize(post);
+
+ // Reponse
+ res(postObj);
+
+ //--------------------------------
+ // Post processes
+
+ let mentions = [];
+
+ function addMention(mentionee, type) {
+ // Reject if already added
+ if (mentions.some(x => x.equals(mentionee))) return;
+
+ // Add mention
+ mentions.push(mentionee);
+
+ // Publish event
+ if (!user._id.equals(mentionee)) {
+ event(mentionee, type, postObj);
+ }
+ }
+
+ // 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
+ })
+ .toArray();
+
+ // Publish event to followers stream
+ followers.forEach(following =>
+ event(following.follower_id, 'post', postObj));
+
+ // Increment my posts count
+ User.updateOne({ _id: user._id }, {
+ $inc: {
+ posts_count: 1
+ }
+ });
+
+ // If has in reply to post
+ if (replyTo) {
+ // Increment replies count
+ Post.updateOne({ _id: replyTo._id }, {
+ $inc: {
+ replies_count: 1
+ }
+ });
+
+ // 自分自身へのリプライでない限りは通知を作成
+ notify(replyTo.user_id, user._id, 'reply', {
+ post_id: post._id
+ });
+
+ // Add mention
+ addMention(replyTo.user_id, 'reply');
+ }
+
+ // If it is repost
+ if (repost) {
+ // Notify
+ const type = text ? 'quote' : 'repost';
+ notify(repost.user_id, user._id, type, {
+ post_id: post._id
+ });
+
+ // If it is quote repost
+ if (text) {
+ // Add mention
+ addMention(repost.user_id, 'quote');
+ } else {
+ // Publish event
+ if (!user._id.equals(repost.user_id)) {
+ event(repost.user_id, 'repost', postObj);
+ }
+ }
+
+ // 今までで同じ投稿をRepostしているか
+ const existRepost = await Post.findOne({
+ user_id: user._id,
+ repost_id: repost._id,
+ _id: {
+ $ne: post._id
+ }
+ });
+
+ if (!existRepost) {
+ // Update repostee status
+ Post.updateOne({ _id: repost._id }, {
+ $inc: {
+ repost_count: 1
+ }
+ });
+ }
+ }
+
+ // If has text content
+ if (text) {
+ // Analyze
+ const tokens = parse(text);
+
+ // Extract a hashtags
+ const hashtags = tokens
+ .filter(t => t.type == 'hashtag')
+ .map(t => t.hashtag)
+ // Drop dupulicates
+ .filter((v, i, s) => s.indexOf(v) == i);
+
+ // ハッシュタグをデータベースに登録
+ //registerHashtags(user, hashtags);
+
+ // Extract an '@' mentions
+ const atMentions = tokens
+ .filter(t => t.type == 'mention')
+ .map(m => m.username)
+ // Drop dupulicates
+ .filter((v, i, s) => s.indexOf(v) == i);
+
+ // Resolve all mentions
+ await Promise.all(atMentions.map(async (mention) => {
+ // Fetch mentioned user
+ // SELECT _id
+ const mentionee = await User
+ .findOne({
+ username_lower: mention.toLowerCase()
+ }, { _id: true });
+
+ // When mentioned user not found
+ if (mentionee == null) return;
+
+ // 既に言及されたユーザーに対する返信や引用repostの場合も無視
+ if (replyTo && replyTo.user_id.equals(mentionee._id)) return;
+ if (repost && repost.user_id.equals(mentionee._id)) return;
+
+ // Add mention
+ addMention(mentionee._id, 'mention');
+
+ // Create notification
+ notify(mentionee._id, user._id, 'mention', {
+ post_id: post._id
+ });
+
+ return;
+ }));
+ }
+
+ // Register to search database
+ if (text && config.elasticsearch.enable) {
+ const es = require('../../../db/elasticsearch');
+
+ es.index({
+ index: 'misskey',
+ type: 'post',
+ id: post._id.toString(),
+ body: {
+ text: post.text
+ }
+ });
+ }
+
+ // Append mentions data
+ if (mentions.length > 0) {
+ Post.updateOne({ _id: post._id }, {
+ $set: {
+ mentions: mentions
+ }
+ });
+ }
+});
diff --git a/src/api/endpoints/posts/favorites/create.js b/src/api/endpoints/posts/favorites/create.js
new file mode 100644
index 0000000000..d20a523d5d
--- /dev/null
+++ b/src/api/endpoints/posts/favorites/create.js
@@ -0,0 +1,56 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Favorite from '../../models/favorite';
+import Post from '../../models/post';
+
+/**
+ * Favorite a post
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'post_id' parameter
+ let postId = params.post_id;
+ if (postId === undefined || postId === null) {
+ return rej('post_id is required');
+ }
+
+ // Get favoritee
+ const post = await Post.findOne({
+ _id: new mongo.ObjectID(postId)
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ // Check arleady favorited
+ const exist = await Favorite.findOne({
+ post_id: post._id,
+ user_id: user._id
+ });
+
+ if (exist !== null) {
+ return rej('already favorited');
+ }
+
+ // Create favorite
+ const inserted = await Favorite.insert({
+ created_at: new Date(),
+ post_id: post._id,
+ user_id: user._id
+ });
+
+ const favorite = inserted.ops[0];
+
+ // Send response
+ res();
+});
diff --git a/src/api/endpoints/posts/favorites/delete.js b/src/api/endpoints/posts/favorites/delete.js
new file mode 100644
index 0000000000..e250d1772c
--- /dev/null
+++ b/src/api/endpoints/posts/favorites/delete.js
@@ -0,0 +1,52 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Favorite from '../../models/favorite';
+import Post from '../../models/post';
+
+/**
+ * Unfavorite a post
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'post_id' parameter
+ let postId = params.post_id;
+ if (postId === undefined || postId === null) {
+ return rej('post_id is required');
+ }
+
+ // Get favoritee
+ const post = await Post.findOne({
+ _id: new mongo.ObjectID(postId)
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ // Check arleady favorited
+ const exist = await Favorite.findOne({
+ post_id: post._id,
+ user_id: user._id
+ });
+
+ if (exist === null) {
+ return rej('already not favorited');
+ }
+
+ // Delete favorite
+ await Favorite.deleteOne({
+ _id: exist._id
+ });
+
+ // Send response
+ res();
+});
diff --git a/src/api/endpoints/posts/likes.js b/src/api/endpoints/posts/likes.js
new file mode 100644
index 0000000000..4778189fc6
--- /dev/null
+++ b/src/api/endpoints/posts/likes.js
@@ -0,0 +1,77 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Post from '../../models/post';
+import Like from '../../models/like';
+import serialize from '../../serializers/user';
+
+/**
+ * Show a likes of a post
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'post_id' parameter
+ const postId = params.post_id;
+ if (postId === undefined || postId === null) {
+ return rej('post_id is required');
+ }
+
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ // Get 'offset' parameter
+ let offset = params.offset;
+ if (offset !== undefined && offset !== null) {
+ offset = parseInt(offset, 10);
+ } else {
+ offset = 0;
+ }
+
+ // Get 'sort' parameter
+ let sort = params.sort || 'desc';
+
+ // Lookup post
+ const post = await Post.findOne({
+ _id: new mongo.ObjectID(postId)
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ // Issue query
+ const likes = await Like
+ .find({
+ post_id: post._id,
+ deleted_at: { $exists: false }
+ }, {}, {
+ limit: limit,
+ skip: offset,
+ sort: {
+ _id: sort == 'asc' ? 1 : -1
+ }
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(likes.map(async like =>
+ await serialize(like.user_id, user))));
+});
diff --git a/src/api/endpoints/posts/likes/create.js b/src/api/endpoints/posts/likes/create.js
new file mode 100644
index 0000000000..eb35c1e4b0
--- /dev/null
+++ b/src/api/endpoints/posts/likes/create.js
@@ -0,0 +1,93 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Like from '../../../models/like';
+import Post from '../../../models/post';
+import User from '../../../models/user';
+import notify from '../../../common/notify';
+import event from '../../../event';
+import serializeUser from '../../../serializers/user';
+import serializePost from '../../../serializers/post';
+
+/**
+ * Like a post
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'post_id' parameter
+ let postId = params.post_id;
+ if (postId === undefined || postId === null) {
+ return rej('post_id is required');
+ }
+
+ // Get likee
+ const post = await Post.findOne({
+ _id: new mongo.ObjectID(postId)
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ // Myself
+ if (post.user_id.equals(user._id)) {
+ return rej('-need-translate-');
+ }
+
+ // Check arleady liked
+ const exist = await Like.findOne({
+ post_id: post._id,
+ user_id: user._id,
+ deleted_at: { $exists: false }
+ });
+
+ if (exist !== null) {
+ return rej('already liked');
+ }
+
+ // Create like
+ const inserted = await Like.insert({
+ created_at: new Date(),
+ post_id: post._id,
+ user_id: user._id
+ });
+
+ const like = inserted.ops[0];
+
+ // Send response
+ res();
+
+ // Increment likes count
+ Post.updateOne({ _id: post._id }, {
+ $inc: {
+ likes_count: 1
+ }
+ });
+
+ // Increment user likes count
+ User.updateOne({ _id: user._id }, {
+ $inc: {
+ likes_count: 1
+ }
+ });
+
+ // Increment user liked count
+ User.updateOne({ _id: post.user_id }, {
+ $inc: {
+ liked_count: 1
+ }
+ });
+
+ // Notify
+ notify(post.user_id, user._id, 'like', {
+ post_id: post._id
+ });
+});
diff --git a/src/api/endpoints/posts/likes/delete.js b/src/api/endpoints/posts/likes/delete.js
new file mode 100644
index 0000000000..b60df63af5
--- /dev/null
+++ b/src/api/endpoints/posts/likes/delete.js
@@ -0,0 +1,80 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Like from '../../../models/like';
+import Post from '../../../models/post';
+import User from '../../../models/user';
+// import event from '../../../event';
+
+/**
+ * Unlike a post
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'post_id' parameter
+ let postId = params.post_id;
+ if (postId === undefined || postId === null) {
+ return rej('post_id is required');
+ }
+
+ // Get likee
+ const post = await Post.findOne({
+ _id: new mongo.ObjectID(postId)
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ // Check arleady liked
+ const exist = await Like.findOne({
+ post_id: post._id,
+ user_id: user._id,
+ deleted_at: { $exists: false }
+ });
+
+ if (exist === null) {
+ return rej('already not liked');
+ }
+
+ // Delete like
+ await Like.updateOne({
+ _id: exist._id
+ }, {
+ $set: {
+ deleted_at: new Date()
+ }
+ });
+
+ // Send response
+ res();
+
+ // Decrement likes count
+ Post.updateOne({ _id: post._id }, {
+ $inc: {
+ likes_count: -1
+ }
+ });
+
+ // Decrement user likes count
+ User.updateOne({ _id: user._id }, {
+ $inc: {
+ likes_count: -1
+ }
+ });
+
+ // Decrement user liked count
+ User.updateOne({ _id: post.user_id }, {
+ $inc: {
+ liked_count: -1
+ }
+ });
+});
diff --git a/src/api/endpoints/posts/mentions.js b/src/api/endpoints/posts/mentions.js
new file mode 100644
index 0000000000..6358e1f4a9
--- /dev/null
+++ b/src/api/endpoints/posts/mentions.js
@@ -0,0 +1,85 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Post from '../../models/post';
+import getFriends from '../../common/get-friends';
+import serialize from '../../serializers/post';
+
+/**
+ * Get mentions of myself
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'following' parameter
+ const following = params.following === 'true';
+
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ const since = params.since_id || null;
+ const max = params.max_id || null;
+
+ // Check if both of since_id and max_id is specified
+ if (since !== null && max !== null) {
+ return rej('cannot set since_id and max_id');
+ }
+
+ // Construct query
+ const query = {
+ mentions: user._id
+ };
+
+ const sort = {
+ _id: -1
+ };
+
+ if (following) {
+ const followingIds = await getFriends(user._id);
+
+ query.user_id = {
+ $in: followingIds
+ };
+ }
+
+ if (since) {
+ sort._id = 1;
+ query._id = {
+ $gt: new mongo.ObjectID(since)
+ };
+ } else if (max) {
+ query._id = {
+ $lt: new mongo.ObjectID(max)
+ };
+ }
+
+ // Issue query
+ const mentions = await Post
+ .find(query, {}, {
+ limit: limit,
+ sort: sort
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(mentions.map(async mention =>
+ await serialize(mention, user)
+ )));
+});
diff --git a/src/api/endpoints/posts/replies.js b/src/api/endpoints/posts/replies.js
new file mode 100644
index 0000000000..5eab6f896f
--- /dev/null
+++ b/src/api/endpoints/posts/replies.js
@@ -0,0 +1,73 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Post from '../../models/post';
+import serialize from '../../serializers/post';
+
+/**
+ * Show a replies of a post
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'post_id' parameter
+ const postId = params.post_id;
+ if (postId === undefined || postId === null) {
+ return rej('post_id is required');
+ }
+
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ // Get 'offset' parameter
+ let offset = params.offset;
+ if (offset !== undefined && offset !== null) {
+ offset = parseInt(offset, 10);
+ } else {
+ offset = 0;
+ }
+
+ // Get 'sort' parameter
+ let sort = params.sort || 'desc';
+
+ // Lookup post
+ const post = await Post.findOne({
+ _id: new mongo.ObjectID(postId)
+ });
+
+ if (post === null) {
+ return rej('post not found', 'POST_NOT_FOUND');
+ }
+
+ // Issue query
+ const replies = await Post
+ .find({ reply_to_id: post._id }, {}, {
+ limit: limit,
+ skip: offset,
+ sort: {
+ _id: sort == 'asc' ? 1 : -1
+ }
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(replies.map(async post =>
+ await serialize(post, user))));
+});
diff --git a/src/api/endpoints/posts/reposts.js b/src/api/endpoints/posts/reposts.js
new file mode 100644
index 0000000000..8b418a682f
--- /dev/null
+++ b/src/api/endpoints/posts/reposts.js
@@ -0,0 +1,85 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Post from '../../models/post';
+import serialize from '../../serializers/post';
+
+/**
+ * Show a reposts of a post
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'post_id' parameter
+ const postId = params.post_id;
+ if (postId === undefined || postId === null) {
+ return rej('post_id is required');
+ }
+
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ const since = params.since_id || null;
+ const max = params.max_id || null;
+
+ // Check if both of since_id and max_id is specified
+ if (since !== null && max !== null) {
+ return rej('cannot set since_id and max_id');
+ }
+
+ // Lookup post
+ const post = await Post.findOne({
+ _id: new mongo.ObjectID(postId)
+ });
+
+ if (post === null) {
+ return rej('post not found', 'POST_NOT_FOUND');
+ }
+
+ // Construct query
+ const sort = {
+ created_at: -1
+ };
+ const query = {
+ repost_id: post._id
+ };
+ if (since !== null) {
+ sort.created_at = 1;
+ query._id = {
+ $gt: new mongo.ObjectID(since)
+ };
+ } else if (max !== null) {
+ query._id = {
+ $lt: new mongo.ObjectID(max)
+ };
+ }
+
+ // Issue query
+ const reposts = await Post
+ .find(query, {}, {
+ limit: limit,
+ sort: sort
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(reposts.map(async post =>
+ await serialize(post, user))));
+});
diff --git a/src/api/endpoints/posts/search.js b/src/api/endpoints/posts/search.js
new file mode 100644
index 0000000000..0f214ef7ae
--- /dev/null
+++ b/src/api/endpoints/posts/search.js
@@ -0,0 +1,138 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Post from '../../models/post';
+import serialize from '../../serializers/post';
+const escapeRegexp = require('escape-regexp');
+
+/**
+ * Search a post
+ *
+ * @param {Object} params
+ * @param {Object} me
+ * @return {Promise<object>}
+ */
+module.exports = (params, me) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'query' parameter
+ let query = params.query;
+ if (query === undefined || query === null || query.trim() === '') {
+ return rej('query is required');
+ }
+
+ // Get 'offset' parameter
+ let offset = params.offset;
+ if (offset !== undefined && offset !== null) {
+ offset = parseInt(offset, 10);
+ } else {
+ offset = 0;
+ }
+
+ // Get 'max' parameter
+ let max = params.max;
+ if (max !== undefined && max !== null) {
+ max = parseInt(max, 10);
+
+ // From 1 to 30
+ if (!(1 <= max && max <= 30)) {
+ return rej('invalid max range');
+ }
+ } else {
+ max = 10;
+ }
+
+ // If Elasticsearch is available, search by it
+ // If not, search by MongoDB
+ (config.elasticsearch.enable ? byElasticsearch : byNative)
+ (res, rej, me, query, offset, max);
+});
+
+// Search by MongoDB
+async function byNative(res, rej, me, query, offset, max) {
+ const escapedQuery = escapeRegexp(query);
+
+ // Search posts
+ const posts = await Post
+ .find({
+ text: new RegExp(escapedQuery)
+ }, {
+ sort: {
+ _id: -1
+ },
+ limit: max,
+ skip: offset
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(posts.map(async post =>
+ await serialize(post, me))));
+}
+
+// Search by Elasticsearch
+async function byElasticsearch(res, rej, me, query, offset, max) {
+ const es = require('../../db/elasticsearch');
+
+ es.search({
+ index: 'misskey',
+ type: 'post',
+ body: {
+ size: max,
+ from: offset,
+ query: {
+ simple_query_string: {
+ fields: ['text'],
+ query: query,
+ default_operator: 'and'
+ }
+ },
+ sort: [
+ { _doc: 'desc' }
+ ],
+ highlight: {
+ pre_tags: ['<mark>'],
+ post_tags: ['</mark>'],
+ encoder: 'html',
+ fields: {
+ text: {}
+ }
+ }
+ }
+ }, async (error, response) => {
+ if (error) {
+ console.error(error);
+ return res(500);
+ }
+
+ if (response.hits.total === 0) {
+ return res([]);
+ }
+
+ const hits = response.hits.hits.map(hit => new mongo.ObjectID(hit._id));
+
+ // Fetxh found posts
+ const posts = await Post
+ .find({
+ _id: {
+ $in: hits
+ }
+ }, {}, {
+ sort: {
+ _id: -1
+ }
+ })
+ .toArray();
+
+ posts.map(post => {
+ post._highlight = response.hits.hits.filter(hit => post._id.equals(hit._id))[0].highlight.text[0];
+ });
+
+ // Serialize
+ res(await Promise.all(posts.map(async post =>
+ await serialize(post, me))));
+ });
+}
diff --git a/src/api/endpoints/posts/show.js b/src/api/endpoints/posts/show.js
new file mode 100644
index 0000000000..19cdb74251
--- /dev/null
+++ b/src/api/endpoints/posts/show.js
@@ -0,0 +1,40 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Post from '../../models/post';
+import serialize from '../../serializers/post';
+
+/**
+ * Show a post
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @return {Promise<object>}
+ */
+module.exports = (params, user) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'post_id' parameter
+ const postId = params.post_id;
+ if (postId === undefined || postId === null) {
+ return rej('post_id is required');
+ }
+
+ // Get post
+ const post = await Post.findOne({
+ _id: new mongo.ObjectID(postId)
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ // Serialize
+ res(await serialize(post, user, {
+ serializeReplyTo: true,
+ includeIsLiked: true
+ }));
+});
diff --git a/src/api/endpoints/posts/timeline.js b/src/api/endpoints/posts/timeline.js
new file mode 100644
index 0000000000..489542da71
--- /dev/null
+++ b/src/api/endpoints/posts/timeline.js
@@ -0,0 +1,78 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Post from '../../models/post';
+import getFriends from '../../common/get-friends';
+import serialize from '../../serializers/post';
+
+/**
+ * Get timeline of myself
+ *
+ * @param {Object} params
+ * @param {Object} user
+ * @param {Object} app
+ * @return {Promise<object>}
+ */
+module.exports = (params, user, app) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ const since = params.since_id || null;
+ const max = params.max_id || null;
+
+ // Check if both of since_id and max_id is specified
+ if (since !== null && max !== null) {
+ return rej('cannot set since_id and max_id');
+ }
+
+ // ID list of the user itself and other users who the user follows
+ const followingIds = await getFriends(user._id);
+
+ // Construct query
+ const sort = {
+ _id: -1
+ };
+ const query = {
+ user_id: {
+ $in: followingIds
+ }
+ };
+ if (since !== null) {
+ sort._id = 1;
+ query._id = {
+ $gt: new mongo.ObjectID(since)
+ };
+ } else if (max !== null) {
+ query._id = {
+ $lt: new mongo.ObjectID(max)
+ };
+ }
+
+ // Issue query
+ const timeline = await Post
+ .find(query, {}, {
+ limit: limit,
+ sort: sort
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(timeline.map(async post =>
+ await serialize(post, user)
+ )));
+});
diff --git a/src/api/endpoints/username/available.js b/src/api/endpoints/username/available.js
new file mode 100644
index 0000000000..a93637bc1f
--- /dev/null
+++ b/src/api/endpoints/username/available.js
@@ -0,0 +1,41 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import User from '../../models/user';
+import { validateUsername } from '../../models/user';
+
+/**
+ * Check available username
+ *
+ * @param {Object} params
+ * @return {Promise<object>}
+ */
+module.exports = async (params) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'username' parameter
+ const username = params.username;
+ if (username == null || username == '') {
+ return rej('username-is-required');
+ }
+
+ // Validate username
+ if (!validateUsername(username)) {
+ return rej('invalid-username');
+ }
+
+ // Get exist
+ const exist = await User
+ .count({
+ username_lower: username.toLowerCase()
+ }, {
+ limit: 1
+ });
+
+ // Reply
+ res({
+ available: exist === 0
+ });
+});
diff --git a/src/api/endpoints/users.js b/src/api/endpoints/users.js
new file mode 100644
index 0000000000..cd40cdf4e1
--- /dev/null
+++ b/src/api/endpoints/users.js
@@ -0,0 +1,67 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import User from '../models/user';
+import serialize from '../serializers/user';
+
+/**
+ * Lists all users
+ *
+ * @param {Object} params
+ * @param {Object} me
+ * @return {Promise<object>}
+ */
+module.exports = (params, me) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ const since = params.since_id || null;
+ const max = params.max_id || null;
+
+ // Check if both of since_id and max_id is specified
+ if (since !== null && max !== null) {
+ return rej('cannot set since_id and max_id');
+ }
+
+ // Construct query
+ const sort = {
+ created_at: -1
+ };
+ const query = {};
+ if (since !== null) {
+ sort.created_at = 1;
+ query._id = {
+ $gt: new mongo.ObjectID(since)
+ };
+ } else if (max !== null) {
+ query._id = {
+ $lt: new mongo.ObjectID(max)
+ };
+ }
+
+ // Issue query
+ const users = await User
+ .find(query, {}, {
+ limit: limit,
+ sort: sort
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(users.map(async user =>
+ await serialize(user, me))));
+});
diff --git a/src/api/endpoints/users/followers.js b/src/api/endpoints/users/followers.js
new file mode 100644
index 0000000000..303f55e450
--- /dev/null
+++ b/src/api/endpoints/users/followers.js
@@ -0,0 +1,102 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import User from '../../models/user';
+import Following from '../../models/following';
+import serialize from '../../serializers/user';
+import getFriends from '../../common/get-friends';
+
+/**
+ * Get followers of a user
+ *
+ * @param {Object} params
+ * @param {Object} me
+ * @return {Promise<object>}
+ */
+module.exports = (params, me) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'user_id' parameter
+ const userId = params.user_id;
+ if (userId === undefined || userId === null) {
+ return rej('user_id is required');
+ }
+
+ // Get 'iknow' parameter
+ const iknow = params.iknow === 'true';
+
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ // Get 'cursor' parameter
+ const cursor = params.cursor || null;
+
+ // Lookup user
+ const user = await User.findOne({
+ _id: new mongo.ObjectID(userId)
+ });
+
+ if (user === null) {
+ return rej('user not found');
+ }
+
+ // Construct query
+ const query = {
+ followee_id: user._id,
+ deleted_at: { $exists: false }
+ };
+
+ // ログインしていてかつ iknow フラグがあるとき
+ if (me && iknow) {
+ // Get my friends
+ const myFriends = await getFriends(me._id);
+
+ query.follower_id = {
+ $in: myFriends
+ };
+ }
+
+ // カーソルが指定されている場合
+ if (cursor) {
+ query._id = {
+ $lt: new mongo.ObjectID(cursor)
+ };
+ }
+
+ // Get followers
+ const following = await Following
+ .find(query, {}, {
+ limit: limit + 1,
+ sort: { _id: -1 }
+ })
+ .toArray();
+
+ // 「次のページ」があるかどうか
+ const inStock = following.length === limit + 1;
+ if (inStock) {
+ following.pop();
+ }
+
+ // Serialize
+ const users = await Promise.all(following.map(async f =>
+ await serialize(f.follower_id, me, { detail: true })));
+
+ // Response
+ res({
+ users: users,
+ next: inStock ? following[following.length - 1]._id : null,
+ });
+});
diff --git a/src/api/endpoints/users/following.js b/src/api/endpoints/users/following.js
new file mode 100644
index 0000000000..ec3954563a
--- /dev/null
+++ b/src/api/endpoints/users/following.js
@@ -0,0 +1,102 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import User from '../../models/user';
+import Following from '../../models/following';
+import serialize from '../../serializers/user';
+import getFriends from '../../common/get-friends';
+
+/**
+ * Get following users of a user
+ *
+ * @param {Object} params
+ * @param {Object} me
+ * @return {Promise<object>}
+ */
+module.exports = (params, me) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'user_id' parameter
+ const userId = params.user_id;
+ if (userId === undefined || userId === null) {
+ return rej('user_id is required');
+ }
+
+ // Get 'iknow' parameter
+ const iknow = params.iknow === 'true';
+
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ // Get 'cursor' parameter
+ const cursor = params.cursor || null;
+
+ // Lookup user
+ const user = await User.findOne({
+ _id: new mongo.ObjectID(userId)
+ });
+
+ if (user === null) {
+ return rej('user not found');
+ }
+
+ // Construct query
+ const query = {
+ follower_id: user._id,
+ deleted_at: { $exists: false }
+ };
+
+ // ログインしていてかつ iknow フラグがあるとき
+ if (me && iknow) {
+ // Get my friends
+ const myFriends = await getFriends(me._id);
+
+ query.followee_id = {
+ $in: myFriends
+ };
+ }
+
+ // カーソルが指定されている場合
+ if (cursor) {
+ query._id = {
+ $lt: new mongo.ObjectID(cursor)
+ };
+ }
+
+ // Get followers
+ const following = await Following
+ .find(query, {}, {
+ limit: limit + 1,
+ sort: { _id: -1 }
+ })
+ .toArray();
+
+ // 「次のページ」があるかどうか
+ const inStock = following.length === limit + 1;
+ if (inStock) {
+ following.pop();
+ }
+
+ // Serialize
+ const users = await Promise.all(following.map(async f =>
+ await serialize(f.followee_id, me, { detail: true })));
+
+ // Response
+ res({
+ users: users,
+ next: inStock ? following[following.length - 1]._id : null,
+ });
+});
diff --git a/src/api/endpoints/users/posts.js b/src/api/endpoints/users/posts.js
new file mode 100644
index 0000000000..6d6f8a6904
--- /dev/null
+++ b/src/api/endpoints/users/posts.js
@@ -0,0 +1,114 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Post from '../../models/post';
+import User from '../../models/user';
+import serialize from '../../serializers/post';
+
+/**
+ * Get posts of a user
+ *
+ * @param {Object} params
+ * @param {Object} me
+ * @return {Promise<object>}
+ */
+module.exports = (params, me) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'user_id' parameter
+ const userId = params.user_id;
+ if (userId === undefined || userId === null) {
+ return rej('user_id is required');
+ }
+
+ // Get 'with_replies' parameter
+ let withReplies = params.with_replies;
+ if (withReplies !== undefined && withReplies !== null && withReplies === 'true') {
+ withReplies = true;
+ } else {
+ withReplies = false;
+ }
+
+ // Get 'with_media' parameter
+ let withMedia = params.with_media;
+ if (withMedia !== undefined && withMedia !== null && withMedia === 'true') {
+ withMedia = true;
+ } else {
+ withMedia = false;
+ }
+
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ const since = params.since_id || null;
+ const max = params.max_id || null;
+
+ // Check if both of since_id and max_id is specified
+ if (since !== null && max !== null) {
+ return rej('cannot set since_id and max_id');
+ }
+
+ // Lookup user
+ const user = await User.findOne({
+ _id: new mongo.ObjectID(userId)
+ });
+
+ if (user === null) {
+ return rej('user not found');
+ }
+
+ // Construct query
+ const sort = {
+ _id: -1
+ };
+ const query = {
+ user_id: user._id
+ };
+ if (since !== null) {
+ sort._id = 1;
+ query._id = {
+ $gt: new mongo.ObjectID(since)
+ };
+ } else if (max !== null) {
+ query._id = {
+ $lt: new mongo.ObjectID(max)
+ };
+ }
+
+ if (!withReplies) {
+ query.reply_to_id = null;
+ }
+
+ if (withMedia) {
+ query.media_ids = {
+ $exists: true,
+ $ne: null
+ };
+ }
+
+ // Issue query
+ const posts = await Post
+ .find(query, {}, {
+ limit: limit,
+ sort: sort
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(posts.map(async (post) =>
+ await serialize(post, me)
+ )));
+});
diff --git a/src/api/endpoints/users/recommendation.js b/src/api/endpoints/users/recommendation.js
new file mode 100644
index 0000000000..9daab0ec57
--- /dev/null
+++ b/src/api/endpoints/users/recommendation.js
@@ -0,0 +1,61 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import User from '../../models/user';
+import serialize from '../../serializers/user';
+import getFriends from '../../common/get-friends';
+
+/**
+ * Get recommended users
+ *
+ * @param {Object} params
+ * @param {Object} me
+ * @return {Promise<object>}
+ */
+module.exports = (params, me) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ // Get 'offset' parameter
+ let offset = params.offset;
+ if (offset !== undefined && offset !== null) {
+ offset = parseInt(offset, 10);
+ } else {
+ offset = 0;
+ }
+
+ // ID list of the user itself and other users who the user follows
+ const followingIds = await getFriends(me._id);
+
+ const users = await User
+ .find({
+ _id: {
+ $nin: followingIds
+ }
+ }, {}, {
+ limit: limit,
+ skip: offset,
+ sort: {
+ followers_count: -1
+ }
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(users.map(async user =>
+ await serialize(user, me, { detail: true }))));
+});
diff --git a/src/api/endpoints/users/search.js b/src/api/endpoints/users/search.js
new file mode 100644
index 0000000000..3a3fe677db
--- /dev/null
+++ b/src/api/endpoints/users/search.js
@@ -0,0 +1,116 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import User from '../../models/user';
+import serialize from '../../serializers/user';
+const escapeRegexp = require('escape-regexp');
+
+/**
+ * Search a user
+ *
+ * @param {Object} params
+ * @param {Object} me
+ * @return {Promise<object>}
+ */
+module.exports = (params, me) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'query' parameter
+ let query = params.query;
+ if (query === undefined || query === null || query.trim() === '') {
+ return rej('query is required');
+ }
+
+ // Get 'offset' parameter
+ let offset = params.offset;
+ if (offset !== undefined && offset !== null) {
+ offset = parseInt(offset, 10);
+ } else {
+ offset = 0;
+ }
+
+ // Get 'max' parameter
+ let max = params.max;
+ if (max !== undefined && max !== null) {
+ max = parseInt(max, 10);
+
+ // From 1 to 30
+ if (!(1 <= max && max <= 30)) {
+ return rej('invalid max range');
+ }
+ } else {
+ max = 10;
+ }
+
+ // If Elasticsearch is available, search by it
+ // If not, search by MongoDB
+ (config.elasticsearch.enable ? byElasticsearch : byNative)
+ (res, rej, me, query, offset, max);
+});
+
+// Search by MongoDB
+async function byNative(res, rej, me, query, offset, max) {
+ const escapedQuery = escapeRegexp(query);
+
+ // Search users
+ const users = await User
+ .find({
+ $or: [{
+ username_lower: new RegExp(escapedQuery.toLowerCase())
+ }, {
+ name: new RegExp(escapedQuery)
+ }]
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(users.map(async user =>
+ await serialize(user, me, { detail: true }))));
+}
+
+// Search by Elasticsearch
+async function byElasticsearch(res, rej, me, query, offset, max) {
+ const es = require('../../db/elasticsearch');
+
+ es.search({
+ index: 'misskey',
+ type: 'user',
+ body: {
+ size: max,
+ from: offset,
+ query: {
+ simple_query_string: {
+ fields: ['username', 'name', 'bio'],
+ query: query,
+ default_operator: 'and'
+ }
+ }
+ }
+ }, async (error, response) => {
+ if (error) {
+ console.error(error);
+ return res(500);
+ }
+
+ if (response.hits.total === 0) {
+ return res([]);
+ }
+
+ const hits = response.hits.hits.map(hit => new mongo.ObjectID(hit._id));
+
+ const users = await User
+ .find({
+ _id: {
+ $in: hits
+ }
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(users.map(async user =>
+ await serialize(user, me, { detail: true }))));
+ });
+}
diff --git a/src/api/endpoints/users/search_by_username.js b/src/api/endpoints/users/search_by_username.js
new file mode 100644
index 0000000000..9e3efbd85c
--- /dev/null
+++ b/src/api/endpoints/users/search_by_username.js
@@ -0,0 +1,65 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import User from '../../models/user';
+import serialize from '../../serializers/user';
+
+/**
+ * Search a user by username
+ *
+ * @param {Object} params
+ * @param {Object} me
+ * @return {Promise<object>}
+ */
+module.exports = (params, me) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'query' parameter
+ let query = params.query;
+ if (query === undefined || query === null || query.trim() === '') {
+ return rej('query is required');
+ }
+
+ query = query.trim();
+
+ if (!/^[a-zA-Z0-9-]+$/.test(query)) {
+ return rej('invalid query');
+ }
+
+ // Get 'limit' parameter
+ let limit = params.limit;
+ if (limit !== undefined && limit !== null) {
+ limit = parseInt(limit, 10);
+
+ // From 1 to 100
+ if (!(1 <= limit && limit <= 100)) {
+ return rej('invalid limit range');
+ }
+ } else {
+ limit = 10;
+ }
+
+ // Get 'offset' parameter
+ let offset = params.offset;
+ if (offset !== undefined && offset !== null) {
+ offset = parseInt(offset, 10);
+ } else {
+ offset = 0;
+ }
+
+ const users = await User
+ .find({
+ username_lower: new RegExp(query.toLowerCase())
+ }, {
+ limit: limit,
+ skip: offset
+ })
+ .toArray();
+
+ // Serialize
+ res(await Promise.all(users.map(async user =>
+ await serialize(user, me, { detail: true }))));
+});
diff --git a/src/api/endpoints/users/show.js b/src/api/endpoints/users/show.js
new file mode 100644
index 0000000000..af475c6cb9
--- /dev/null
+++ b/src/api/endpoints/users/show.js
@@ -0,0 +1,49 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import User from '../../models/user';
+import serialize from '../../serializers/user';
+
+/**
+ * Show a user
+ *
+ * @param {Object} params
+ * @param {Object} me
+ * @return {Promise<object>}
+ */
+module.exports = (params, me) =>
+ new Promise(async (res, rej) =>
+{
+ // Get 'user_id' parameter
+ let userId = params.user_id;
+ if (userId === undefined || userId === null || userId === '') {
+ userId = null;
+ }
+
+ // Get 'username' parameter
+ let username = params.username;
+ if (username === undefined || username === null || username === '') {
+ username = null;
+ }
+
+ if (userId === null && username === null) {
+ return rej('user_id or username is required');
+ }
+
+ // Lookup user
+ const user = userId !== null
+ ? await User.findOne({ _id: new mongo.ObjectID(userId) })
+ : await User.findOne({ username_lower: username.toLowerCase() });
+
+ if (user === null) {
+ return rej('user not found');
+ }
+
+ // Send response
+ res(await serialize(user, me, {
+ detail: true
+ }));
+});
diff --git a/src/api/event.ts b/src/api/event.ts
new file mode 100644
index 0000000000..584fc8e86c
--- /dev/null
+++ b/src/api/event.ts
@@ -0,0 +1,36 @@
+import * as mongo from 'mongodb';
+import * as redis from 'redis';
+
+type ID = string | mongo.ObjectID;
+
+class MisskeyEvent {
+ private redisClient: redis.RedisClient;
+
+ constructor() {
+ // Connect to Redis
+ this.redisClient = redis.createClient(
+ config.redis.port, config.redis.host);
+ }
+
+ private publish(channel: string, type: string, value?: Object): void {
+ const message = value == null ?
+ { type: type } :
+ { type: type, body: value };
+
+ this.redisClient.publish(`misskey:${channel}`, JSON.stringify(message));
+ }
+
+ public publishUserStream(userId: ID, type: string, value?: Object): void {
+ this.publish(`user-stream:${userId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
+ public publishMessagingStream(userId: ID, otherpartyId: ID, type: string, value?: Object): void {
+ this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value);
+ }
+}
+
+const ev = new MisskeyEvent();
+
+export default ev.publishUserStream.bind(ev);
+
+export const publishMessagingStream = ev.publishMessagingStream.bind(ev);
diff --git a/src/api/limitter.ts b/src/api/limitter.ts
new file mode 100644
index 0000000000..9cc25675d8
--- /dev/null
+++ b/src/api/limitter.ts
@@ -0,0 +1,69 @@
+import * as Limiter from 'ratelimiter';
+import limiterDB from '../db/redis';
+import { IEndpoint } from './endpoints';
+import { IAuthContext } from './authenticate';
+
+export default (endpoint: IEndpoint, ctx: IAuthContext) => new Promise((ok, reject) => {
+ const limitKey = endpoint.hasOwnProperty('limitKey')
+ ? endpoint.limitKey
+ : endpoint.name;
+
+ const hasMinInterval =
+ endpoint.hasOwnProperty('minInterval');
+
+ const hasRateLimit =
+ endpoint.hasOwnProperty('limitDuration') &&
+ endpoint.hasOwnProperty('limitMax');
+
+ if (hasMinInterval) {
+ min();
+ } else if (hasRateLimit) {
+ max();
+ } else {
+ ok();
+ }
+
+ // Short-term limit
+ function min(): void {
+ const minIntervalLimiter = new Limiter({
+ id: `${ctx.user._id}:${limitKey}:min`,
+ duration: endpoint.minInterval,
+ max: 1,
+ db: limiterDB
+ });
+
+ minIntervalLimiter.get((limitErr, limit) => {
+ if (limitErr) {
+ reject('ERR');
+ } else if (limit.remaining === 0) {
+ reject('BRIEF_REQUEST_INTERVAL');
+ } else {
+ if (hasRateLimit) {
+ max();
+ } else {
+ ok();
+ }
+ }
+ });
+ }
+
+ // Long term limit
+ function max(): void {
+ const limiter = new Limiter({
+ id: `${ctx.user._id}:${limitKey}`,
+ duration: endpoint.limitDuration,
+ max: endpoint.limitMax,
+ db: limiterDB
+ });
+
+ limiter.get((limitErr, limit) => {
+ if (limitErr) {
+ reject('ERR');
+ } else if (limit.remaining === 0) {
+ reject('RATE_LIMIT_EXCEEDED');
+ } else {
+ ok();
+ }
+ });
+ }
+});
diff --git a/src/api/models/app.ts b/src/api/models/app.ts
new file mode 100644
index 0000000000..221a53906a
--- /dev/null
+++ b/src/api/models/app.ts
@@ -0,0 +1,7 @@
+const collection = global.db.collection('apps');
+
+collection.createIndex('name_id');
+collection.createIndex('name_id_lower');
+collection.createIndex('secret');
+
+export default collection;
diff --git a/src/api/models/appdata.ts b/src/api/models/appdata.ts
new file mode 100644
index 0000000000..2d471c4347
--- /dev/null
+++ b/src/api/models/appdata.ts
@@ -0,0 +1 @@
+export default global.db.collection('appdata');
diff --git a/src/api/models/auth-session.ts b/src/api/models/auth-session.ts
new file mode 100644
index 0000000000..6dbe2fa70e
--- /dev/null
+++ b/src/api/models/auth-session.ts
@@ -0,0 +1 @@
+export default global.db.collection('auth_sessions');
diff --git a/src/api/models/drive-file.ts b/src/api/models/drive-file.ts
new file mode 100644
index 0000000000..06ebf02005
--- /dev/null
+++ b/src/api/models/drive-file.ts
@@ -0,0 +1,11 @@
+export default global.db.collection('drive_files');
+
+export function validateFileName(name: string): boolean {
+ return (
+ (name.trim().length > 0) &&
+ (name.length <= 200) &&
+ (name.indexOf('\\') === -1) &&
+ (name.indexOf('/') === -1) &&
+ (name.indexOf('..') === -1)
+ );
+}
diff --git a/src/api/models/drive-folder.ts b/src/api/models/drive-folder.ts
new file mode 100644
index 0000000000..f345b3c340
--- /dev/null
+++ b/src/api/models/drive-folder.ts
@@ -0,0 +1,8 @@
+export default global.db.collection('drive_folders');
+
+export function isValidFolderName(name: string): boolean {
+ return (
+ (name.trim().length > 0) &&
+ (name.length <= 200)
+ );
+}
diff --git a/src/api/models/drive-tag.ts b/src/api/models/drive-tag.ts
new file mode 100644
index 0000000000..83c0a8f681
--- /dev/null
+++ b/src/api/models/drive-tag.ts
@@ -0,0 +1 @@
+export default global.db.collection('drive_tags');
diff --git a/src/api/models/favorite.ts b/src/api/models/favorite.ts
new file mode 100644
index 0000000000..6d9e7c72b3
--- /dev/null
+++ b/src/api/models/favorite.ts
@@ -0,0 +1 @@
+export default global.db.collection('favorites');
diff --git a/src/api/models/following.ts b/src/api/models/following.ts
new file mode 100644
index 0000000000..f9d8a41c5e
--- /dev/null
+++ b/src/api/models/following.ts
@@ -0,0 +1 @@
+export default global.db.collection('following');
diff --git a/src/api/models/like.ts b/src/api/models/like.ts
new file mode 100644
index 0000000000..aa3bd75c1c
--- /dev/null
+++ b/src/api/models/like.ts
@@ -0,0 +1 @@
+export default global.db.collection('likes');
diff --git a/src/api/models/messaging-history.ts b/src/api/models/messaging-history.ts
new file mode 100644
index 0000000000..3505e94b57
--- /dev/null
+++ b/src/api/models/messaging-history.ts
@@ -0,0 +1 @@
+export default global.db.collection('messaging_histories');
diff --git a/src/api/models/messaging-message.ts b/src/api/models/messaging-message.ts
new file mode 100644
index 0000000000..0e900bda58
--- /dev/null
+++ b/src/api/models/messaging-message.ts
@@ -0,0 +1 @@
+export default global.db.collection('messaging_messages');
diff --git a/src/api/models/notification.ts b/src/api/models/notification.ts
new file mode 100644
index 0000000000..1cb7b80838
--- /dev/null
+++ b/src/api/models/notification.ts
@@ -0,0 +1 @@
+export default global.db.collection('notifications');
diff --git a/src/api/models/post.ts b/src/api/models/post.ts
new file mode 100644
index 0000000000..bea92a5f61
--- /dev/null
+++ b/src/api/models/post.ts
@@ -0,0 +1 @@
+export default global.db.collection('posts');
diff --git a/src/api/models/signin.ts b/src/api/models/signin.ts
new file mode 100644
index 0000000000..896afaaf84
--- /dev/null
+++ b/src/api/models/signin.ts
@@ -0,0 +1 @@
+export default global.db.collection('signin');
diff --git a/src/api/models/user.ts b/src/api/models/user.ts
new file mode 100644
index 0000000000..1742f5cafb
--- /dev/null
+++ b/src/api/models/user.ts
@@ -0,0 +1,10 @@
+const collection = global.db.collection('users');
+
+collection.createIndex('username');
+collection.createIndex('token');
+
+export default collection;
+
+export function validateUsername(username: string): boolean {
+ return /^[a-zA-Z0-9\-]{3,20}$/.test(username);
+}
diff --git a/src/api/models/userkey.ts b/src/api/models/userkey.ts
new file mode 100644
index 0000000000..204f283a28
--- /dev/null
+++ b/src/api/models/userkey.ts
@@ -0,0 +1,5 @@
+const collection = global.db.collection('userkeys');
+
+collection.createIndex('key');
+
+export default collection;
diff --git a/src/api/private/signin.ts b/src/api/private/signin.ts
new file mode 100644
index 0000000000..b68fc89aa0
--- /dev/null
+++ b/src/api/private/signin.ts
@@ -0,0 +1,57 @@
+import * as express from 'express';
+import * as bcrypt from 'bcrypt';
+import User from '../models/user';
+import Signin from '../models/signin';
+import serialize from '../serializers/signin';
+import event from '../event';
+
+
+export default async (req: express.Request, res: express.Response) => {
+ res.header('Access-Control-Allow-Credentials', 'true');
+
+ const username = req.body['username'];
+ const password = req.body['password'];
+
+ // Fetch user
+ const user = await User.findOne({
+ username_lower: username.toLowerCase()
+ });
+
+ if (user === null) {
+ res.status(404).send('user not found');
+ return;
+ }
+
+ // Compare password
+ const same = await bcrypt.compare(password, user.password);
+
+ if (same) {
+ const expires = 1000 * 60 * 60 * 24 * 365; // One Year
+ res.cookie('i', user.token, {
+ path: '/',
+ domain: `.${config.host}`,
+ secure: config.url.substr(0, 5) === 'https',
+ httpOnly: false,
+ expires: new Date(Date.now() + expires),
+ maxAge: expires
+ });
+
+ res.sendStatus(204);
+ } else {
+ res.status(400).send('incorrect password');
+ }
+
+ // Append signin history
+ const inserted = await Signin.insert({
+ created_at: new Date(),
+ user_id: user._id,
+ ip: req.ip,
+ headers: req.headers,
+ success: same
+ });
+
+ const record = inserted.ops[0];
+
+ // Publish signin event
+ event(user._id, 'signin', await serialize(record));
+};
diff --git a/src/api/private/signup.ts b/src/api/private/signup.ts
new file mode 100644
index 0000000000..7df1f25b37
--- /dev/null
+++ b/src/api/private/signup.ts
@@ -0,0 +1,94 @@
+import * as express from 'express';
+import * as bcrypt from 'bcrypt';
+import rndstr from 'rndstr';
+const recaptcha = require('recaptcha-promise');
+import User from '../models/user';
+import { validateUsername } from '../models/user';
+import serialize from '../serializers/user';
+
+
+recaptcha.init({
+ secret_key: config.recaptcha.secretKey
+});
+
+export default async (req: express.Request, res: express.Response) => {
+ // Verify recaptcha
+ const success = await recaptcha(req.body['g-recaptcha-response']);
+
+ if (!success) {
+ res.status(400).send('recaptcha-failed');
+ return;
+ }
+
+ const username = req.body['username'];
+ const password = req.body['password'];
+ const name = '名無し';
+
+ // Validate username
+ if (!validateUsername(username)) {
+ res.sendStatus(400);
+ return;
+ }
+
+ // Fetch exist user that same username
+ const usernameExist = await User
+ .count({
+ username_lower: username.toLowerCase()
+ }, {
+ limit: 1
+ });
+
+ // Check username already used
+ if (usernameExist !== 0) {
+ res.sendStatus(400);
+ return;
+ }
+
+ // Generate hash of password
+ const salt = bcrypt.genSaltSync(14);
+ const hash = bcrypt.hashSync(password, salt);
+
+ // Generate secret
+ const secret = rndstr('a-zA-Z0-9', 32);
+
+ // Create account
+ const inserted = await User.insert({
+ token: secret,
+ avatar_id: null,
+ banner_id: null,
+ birthday: null,
+ created_at: new Date(),
+ bio: null,
+ email: null,
+ followers_count: 0,
+ following_count: 0,
+ links: null,
+ location: null,
+ name: name,
+ password: hash,
+ posts_count: 0,
+ likes_count: 0,
+ liked_count: 0,
+ drive_capacity: 1073741824, // 1GB
+ username: username,
+ username_lower: username.toLowerCase()
+ });
+
+ const account = inserted.ops[0];
+
+ // Response
+ res.send(await serialize(account));
+
+ // Create search index
+ if (config.elasticsearch.enable) {
+ const es = require('../../db/elasticsearch');
+ es.index({
+ index: 'misskey',
+ type: 'user',
+ id: account._id.toString(),
+ body: {
+ username: username
+ }
+ });
+ }
+};
diff --git a/src/api/reply.ts b/src/api/reply.ts
new file mode 100644
index 0000000000..e47fc85b9b
--- /dev/null
+++ b/src/api/reply.ts
@@ -0,0 +1,13 @@
+import * as express from 'express';
+
+export default (res: express.Response, x?: any, y?: any) => {
+ if (x === undefined) {
+ res.sendStatus(204);
+ } else if (typeof x === 'number') {
+ res.status(x).send({
+ error: x === 500 ? 'INTERNAL_ERROR' : y
+ });
+ } else {
+ res.send(x);
+ }
+};
diff --git a/src/api/serializers/app.ts b/src/api/serializers/app.ts
new file mode 100644
index 0000000000..23a12c977d
--- /dev/null
+++ b/src/api/serializers/app.ts
@@ -0,0 +1,85 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+const deepcopy = require('deepcopy');
+import App from '../models/app';
+import User from '../models/user';
+import Userkey from '../models/userkey';
+
+/**
+ * Serialize an app
+ *
+ * @param {Object} app
+ * @param {Object} me?
+ * @param {Object} options?
+ * @return {Promise<Object>}
+ */
+export default (
+ app: any,
+ me?: any,
+ options?: {
+ includeSecret: boolean,
+ includeProfileImageIds: boolean
+ }
+) => new Promise<any>(async (resolve, reject) => {
+ const opts = options || {
+ includeSecret: false,
+ includeProfileImageIds: false
+ };
+
+ let _app: any;
+
+ // Populate the app if 'app' is ID
+ if (mongo.ObjectID.prototype.isPrototypeOf(app)) {
+ _app = await App.findOne({
+ _id: app
+ });
+ } else if (typeof app === 'string') {
+ _app = await User.findOne({
+ _id: new mongo.ObjectID(app)
+ });
+ } else {
+ _app = deepcopy(app);
+ }
+
+ // Me
+ if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) {
+ if (typeof me === 'string') {
+ me = new mongo.ObjectID(me);
+ } else {
+ me = me._id;
+ }
+ }
+
+ // Rename _id to id
+ _app.id = _app._id;
+ delete _app._id;
+
+ delete _app.name_id_lower;
+
+ // Visible by only owner
+ if (!opts.includeSecret) {
+ delete _app.secret;
+ }
+
+ _app.icon_url = _app.icon != null
+ ? `${config.drive_url}/${_app.icon}`
+ : `${config.drive_url}/app-default.jpg`;
+
+ if (me) {
+ // 既に連携しているか
+ const exist = await Userkey.count({
+ app_id: _app.id,
+ user_id: me,
+ }, {
+ limit: 1
+ });
+
+ _app.is_authorized = exist === 1;
+ }
+
+ resolve(_app);
+});
diff --git a/src/api/serializers/auth-session.ts b/src/api/serializers/auth-session.ts
new file mode 100644
index 0000000000..786684b4e0
--- /dev/null
+++ b/src/api/serializers/auth-session.ts
@@ -0,0 +1,42 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+const deepcopy = require('deepcopy');
+import serializeApp from './app';
+
+/**
+ * Serialize an auth session
+ *
+ * @param {Object} session
+ * @param {Object} me?
+ * @return {Promise<Object>}
+ */
+export default (
+ session: any,
+ me?: any
+) => new Promise<any>(async (resolve, reject) => {
+ let _session: any;
+
+ // TODO: Populate session if it ID
+
+ _session = deepcopy(session);
+
+ // Me
+ if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) {
+ if (typeof me === 'string') {
+ me = new mongo.ObjectID(me);
+ } else {
+ me = me._id;
+ }
+ }
+
+ delete _session._id;
+
+ // Populate app
+ _session.app = await serializeApp(_session.app_id, me);
+
+ resolve(_session);
+});
diff --git a/src/api/serializers/drive-file.ts b/src/api/serializers/drive-file.ts
new file mode 100644
index 0000000000..635cf13867
--- /dev/null
+++ b/src/api/serializers/drive-file.ts
@@ -0,0 +1,63 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import DriveFile from '../models/drive-file';
+import serializeDriveTag from './drive-tag';
+const deepcopy = require('deepcopy');
+
+/**
+ * Serialize a drive file
+ *
+ * @param {Object} file
+ * @param {Object} options?
+ * @return {Promise<Object>}
+ */
+export default (
+ file: any,
+ options?: {
+ includeTags: boolean
+ }
+) => new Promise<Object>(async (resolve, reject) => {
+ const opts = options || {
+ includeTags: true
+ };
+
+ let _file: any;
+
+ // Populate the file if 'file' is ID
+ if (mongo.ObjectID.prototype.isPrototypeOf(file)) {
+ _file = await DriveFile.findOne({
+ _id: file
+ }, {
+ data: false
+ });
+ } else if (typeof file === 'string') {
+ _file = await DriveFile.findOne({
+ _id: new mongo.ObjectID(file)
+ }, {
+ data: false
+ });
+ } else {
+ _file = deepcopy(file);
+ }
+
+ // Rename _id to id
+ _file.id = _file._id;
+ delete _file._id;
+
+ delete _file.data;
+
+ _file.url = `${config.drive_url}/${_file.id}/${encodeURIComponent(_file.name)}`;
+
+ if (opts.includeTags && _file.tags) {
+ // Populate tags
+ _file.tags = await _file.tags.map(async (tag: any) =>
+ await serializeDriveTag(tag)
+ );
+ }
+
+ resolve(_file);
+});
diff --git a/src/api/serializers/drive-folder.ts b/src/api/serializers/drive-folder.ts
new file mode 100644
index 0000000000..ee5a973e14
--- /dev/null
+++ b/src/api/serializers/drive-folder.ts
@@ -0,0 +1,52 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import DriveFolder from '../models/drive-folder';
+const deepcopy = require('deepcopy');
+
+/**
+ * Serialize a drive folder
+ *
+ * @param {Object} folder
+ * @param {Object} options?
+ * @return {Promise<Object>}
+ */
+const self = (
+ folder: any,
+ options?: {
+ includeParent: boolean
+ }
+) => new Promise<Object>(async (resolve, reject) => {
+ const opts = options || {
+ includeParent: false
+ };
+
+ let _folder: any;
+
+ // Populate the folder if 'folder' is ID
+ if (mongo.ObjectID.prototype.isPrototypeOf(folder)) {
+ _folder = await DriveFolder.findOne({_id: folder});
+ } else if (typeof folder === 'string') {
+ _folder = await DriveFolder.findOne({_id: new mongo.ObjectID(folder)});
+ } else {
+ _folder = deepcopy(folder);
+ }
+
+ // Rename _id to id
+ _folder.id = _folder._id;
+ delete _folder._id;
+
+ if (opts.includeParent && _folder.parent_id) {
+ // Populate parent folder
+ _folder.parent = await self(_folder.parent_id, {
+ includeParent: true
+ });
+ }
+
+ resolve(_folder);
+});
+
+export default self;
diff --git a/src/api/serializers/drive-tag.ts b/src/api/serializers/drive-tag.ts
new file mode 100644
index 0000000000..182e9a66d4
--- /dev/null
+++ b/src/api/serializers/drive-tag.ts
@@ -0,0 +1,37 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import DriveTag from '../models/drive-tag';
+const deepcopy = require('deepcopy');
+
+/**
+ * Serialize a drive tag
+ *
+ * @param {Object} tag
+ * @return {Promise<Object>}
+ */
+const self = (
+ tag: any
+) => new Promise<Object>(async (resolve, reject) => {
+ let _tag: any;
+
+ // Populate the tag if 'tag' is ID
+ if (mongo.ObjectID.prototype.isPrototypeOf(tag)) {
+ _tag = await DriveTag.findOne({_id: tag});
+ } else if (typeof tag === 'string') {
+ _tag = await DriveTag.findOne({_id: new mongo.ObjectID(tag)});
+ } else {
+ _tag = deepcopy(tag);
+ }
+
+ // Rename _id to id
+ _tag.id = _tag._id;
+ delete _tag._id;
+
+ resolve(_tag);
+});
+
+export default self;
diff --git a/src/api/serializers/messaging-message.ts b/src/api/serializers/messaging-message.ts
new file mode 100644
index 0000000000..0855b25d16
--- /dev/null
+++ b/src/api/serializers/messaging-message.ts
@@ -0,0 +1,64 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Message from '../models/messaging-message';
+import serializeUser from './user';
+import serializeDriveFile from './drive-file';
+const deepcopy = require('deepcopy');
+
+/**
+ * Serialize a message
+ *
+ * @param {Object} message
+ * @param {Object} me?
+ * @param {Object} options?
+ * @return {Promise<Object>}
+ */
+export default (
+ message: any,
+ me: any,
+ options?: {
+ populateRecipient: boolean
+ }
+) => new Promise<Object>(async (resolve, reject) => {
+ const opts = options || {
+ populateRecipient: true
+ };
+
+ let _message: any;
+
+ // Populate the message if 'message' is ID
+ if (mongo.ObjectID.prototype.isPrototypeOf(message)) {
+ _message = await Message.findOne({
+ _id: message
+ });
+ } else if (typeof message === 'string') {
+ _message = await Message.findOne({
+ _id: new mongo.ObjectID(message)
+ });
+ } else {
+ _message = deepcopy(message);
+ }
+
+ // Rename _id to id
+ _message.id = _message._id;
+ delete _message._id;
+
+ // Populate user
+ _message.user = await serializeUser(_message.user_id, me);
+
+ if (_message.file) {
+ // Populate file
+ _message.file = await serializeDriveFile(_message.file_id);
+ }
+
+ if (opts.populateRecipient) {
+ // Populate recipient
+ _message.recipient = await serializeUser(_message.recipient_id, me);
+ }
+
+ resolve(_message);
+});
diff --git a/src/api/serializers/notification.ts b/src/api/serializers/notification.ts
new file mode 100644
index 0000000000..56769f50d0
--- /dev/null
+++ b/src/api/serializers/notification.ts
@@ -0,0 +1,66 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Notification from '../models/notification';
+import serializeUser from './user';
+import serializePost from './post';
+const deepcopy = require('deepcopy');
+
+/**
+ * Serialize a notification
+ *
+ * @param {Object} notification
+ * @return {Promise<Object>}
+ */
+export default (notification: any) => new Promise<Object>(async (resolve, reject) => {
+ let _notification: any;
+
+ // Populate the notification if 'notification' is ID
+ if (mongo.ObjectID.prototype.isPrototypeOf(notification)) {
+ _notification = await Notification.findOne({
+ _id: notification
+ });
+ } else if (typeof notification === 'string') {
+ _notification = await Notification.findOne({
+ _id: new mongo.ObjectID(notification)
+ });
+ } else {
+ _notification = deepcopy(notification);
+ }
+
+ // Rename _id to id
+ _notification.id = _notification._id;
+ delete _notification._id;
+
+ // Rename notifier_id to user_id
+ _notification.user_id = _notification.notifier_id;
+ delete _notification.notifier_id;
+
+ const me = _notification.notifiee_id;
+ delete _notification.notifiee_id;
+
+ // Populate notifier
+ _notification.user = await serializeUser(_notification.user_id, me);
+
+ switch (_notification.type) {
+ case 'follow':
+ // nope
+ break;
+ case 'mention':
+ case 'reply':
+ case 'repost':
+ case 'quote':
+ case 'like':
+ // Populate post
+ _notification.post = await serializePost(_notification.post_id, me);
+ break;
+ default:
+ console.error(`Unknown type: ${_notification.type}`);
+ break;
+ }
+
+ resolve(_notification);
+});
diff --git a/src/api/serializers/post.ts b/src/api/serializers/post.ts
new file mode 100644
index 0000000000..a17aa9035b
--- /dev/null
+++ b/src/api/serializers/post.ts
@@ -0,0 +1,103 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import Post from '../models/post';
+import Like from '../models/like';
+import serializeUser from './user';
+import serializeDriveFile from './drive-file';
+const deepcopy = require('deepcopy');
+
+/**
+ * Serialize a post
+ *
+ * @param {Object} post
+ * @param {Object} me?
+ * @param {Object} options?
+ * @return {Promise<Object>}
+ */
+const self = (
+ post: any,
+ me?: any,
+ options?: {
+ serializeReplyTo: boolean,
+ serializeRepost: boolean,
+ includeIsLiked: boolean
+ }
+) => new Promise<Object>(async (resolve, reject) => {
+ const opts = options || {
+ serializeReplyTo: true,
+ serializeRepost: true,
+ includeIsLiked: true
+ };
+
+ let _post: any;
+
+ // Populate the post if 'post' is ID
+ if (mongo.ObjectID.prototype.isPrototypeOf(post)) {
+ _post = await Post.findOne({
+ _id: post
+ });
+ } else if (typeof post === 'string') {
+ _post = await Post.findOne({
+ _id: new mongo.ObjectID(post)
+ });
+ } else {
+ _post = deepcopy(post);
+ }
+
+ const id = _post._id;
+
+ // Rename _id to id
+ _post.id = _post._id;
+ delete _post._id;
+
+ delete _post.mentions;
+
+ // Populate user
+ _post.user = await serializeUser(_post.user_id, me);
+
+ if (_post.media_ids) {
+ // Populate media
+ _post.media = await Promise.all(_post.media_ids.map(async fileId =>
+ await serializeDriveFile(fileId)
+ ));
+ }
+
+ if (_post.reply_to_id && opts.serializeReplyTo) {
+ // Populate reply to post
+ _post.reply_to = await self(_post.reply_to_id, me, {
+ serializeReplyTo: false,
+ serializeRepost: false,
+ includeIsLiked: false
+ });
+ }
+
+ if (_post.repost_id && opts.serializeRepost) {
+ // Populate repost
+ _post.repost = await self(_post.repost_id, me, {
+ serializeReplyTo: _post.text == null,
+ serializeRepost: _post.text == null,
+ includeIsLiked: _post.text == null
+ });
+ }
+
+ // Check if it is liked
+ if (me && opts.includeIsLiked) {
+ const liked = await Like
+ .count({
+ user_id: me._id,
+ post_id: id
+ }, {
+ limit: 1
+ });
+
+ _post.is_liked = liked === 1;
+ }
+
+ resolve(_post);
+});
+
+export default self;
diff --git a/src/api/serializers/signin.ts b/src/api/serializers/signin.ts
new file mode 100644
index 0000000000..d6d7a39471
--- /dev/null
+++ b/src/api/serializers/signin.ts
@@ -0,0 +1,25 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+const deepcopy = require('deepcopy');
+
+/**
+ * Serialize a signin record
+ *
+ * @param {Object} record
+ * @return {Promise<Object>}
+ */
+export default (
+ record: any
+) => new Promise<Object>(async (resolve, reject) => {
+
+ const _record = deepcopy(record);
+
+ // Rename _id to id
+ _record.id = _record._id;
+ delete _record._id;
+
+ resolve(_record);
+});
diff --git a/src/api/serializers/user.ts b/src/api/serializers/user.ts
new file mode 100644
index 0000000000..0585863950
--- /dev/null
+++ b/src/api/serializers/user.ts
@@ -0,0 +1,138 @@
+'use strict';
+
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+const deepcopy = require('deepcopy');
+import User from '../models/user';
+import Following from '../models/following';
+import getFriends from '../common/get-friends';
+
+/**
+ * Serialize a user
+ *
+ * @param {Object} user
+ * @param {Object} me?
+ * @param {Object} options?
+ * @return {Promise<Object>}
+ */
+export default (
+ user: any,
+ me?: any,
+ options?: {
+ detail: boolean,
+ includeSecrets: boolean
+ }
+) => new Promise<any>(async (resolve, reject) => {
+
+ const opts = Object.assign({
+ detail: false,
+ includeSecrets: false
+ }, options);
+
+ let _user: any;
+
+ // Populate the user if 'user' is ID
+ if (mongo.ObjectID.prototype.isPrototypeOf(user)) {
+ _user = await User.findOne({
+ _id: user
+ });
+ } else if (typeof user === 'string') {
+ _user = await User.findOne({
+ _id: new mongo.ObjectID(user)
+ });
+ } else {
+ _user = deepcopy(user);
+ }
+
+ // Me
+ if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) {
+ if (typeof me === 'string') {
+ me = new mongo.ObjectID(me);
+ } else {
+ me = me._id;
+ }
+ }
+
+ // Rename _id to id
+ _user.id = _user._id;
+ delete _user._id;
+
+ // Remove private properties
+ delete _user.password;
+ delete _user.token;
+ delete _user.username_lower;
+
+ // Visible via only the official client
+ if (!opts.includeSecrets) {
+ delete _user.data;
+ delete _user.email;
+ }
+
+ _user.avatar_url = _user.avatar_id != null
+ ? `${config.drive_url}/${_user.avatar_id}`
+ : `${config.drive_url}/default-avatar.jpg`;
+
+ _user.banner_url = _user.banner_id != null
+ ? `${config.drive_url}/${_user.banner_id}`
+ : null;
+
+ if (!me || !me.equals(_user.id) || !opts.detail) {
+ delete _user.avatar_id;
+ delete _user.banner_id;
+
+ delete _user.drive_capacity;
+ }
+
+ if (me && !me.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;
+
+ // 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;
+ }
+
+ if (me && !me.equals(_user.id) && opts.detail) {
+ const myFollowingIds = await getFriends(me);
+
+ // 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;
+
+ // 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;
+ }
+
+ resolve(_user);
+});
+/*
+function img(url) {
+ return {
+ thumbnail: {
+ large: `${url}`,
+ medium: '',
+ small: ''
+ }
+ };
+}
+*/
diff --git a/src/api/server.ts b/src/api/server.ts
new file mode 100644
index 0000000000..78b0d0aea8
--- /dev/null
+++ b/src/api/server.ts
@@ -0,0 +1,52 @@
+/**
+ * API Server
+ */
+
+import * as express from 'express';
+import * as bodyParser from 'body-parser';
+import * as cors from 'cors';
+import * as multer from 'multer';
+
+import authenticate from './authenticate';
+import endpoints from './endpoints';
+
+/**
+ * Init app
+ */
+const app = express();
+
+app.disable('x-powered-by');
+app.set('etag', false);
+app.use(bodyParser.urlencoded({ extended: true }));
+app.use(cors({
+ origin: true
+}));
+
+/**
+ * Authetication
+ */
+/*app.post('*', async (req, res, next) => {
+ try {
+ ctx = await authenticate(req);
+ next();
+ } catch (e) {
+ res.status(403).send('AUTHENTICATION_FAILED');
+ }
+});
+*/
+/**
+ * Register endpoint handlers
+ */
+endpoints.forEach(endpoint =>
+ endpoint.withFile ?
+ app.post('/' + endpoint.name,
+ endpoint.withFile ? multer({ dest: 'uploads/' }).single('file') : null,
+ require('./api-handler').default.bind(null, endpoint)) :
+ app.post('/' + endpoint.name,
+ require('./api-handler').default.bind(null, endpoint))
+);
+
+app.post('/signup', require('./private/signup').default);
+app.post('/signin', require('./private/signin').default);
+
+module.exports = app;
diff --git a/src/api/stream/home.ts b/src/api/stream/home.ts
new file mode 100644
index 0000000000..975bea4c60
--- /dev/null
+++ b/src/api/stream/home.ts
@@ -0,0 +1,10 @@
+import * as websocket from 'websocket';
+import * as redis from 'redis';
+
+export default function homeStream(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void {
+ // Subscribe Home stream channel
+ subscriber.subscribe(`misskey:user-stream:${user._id}`);
+ subscriber.on('message', (_, data) => {
+ connection.send(data);
+ });
+}
diff --git a/src/api/stream/messaging.ts b/src/api/stream/messaging.ts
new file mode 100644
index 0000000000..4ec139b82b
--- /dev/null
+++ b/src/api/stream/messaging.ts
@@ -0,0 +1,60 @@
+import * as mongodb from 'mongodb';
+import * as websocket from 'websocket';
+import * as redis from 'redis';
+import Message from '../models/messaging-message';
+import { publishMessagingStream } from '../event';
+
+export default function messagingStream(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void {
+ const otherparty = request.resourceURL.query.otherparty;
+
+ // Subscribe messaging stream
+ subscriber.subscribe(`misskey:messaging-stream:${user._id}-${otherparty}`);
+ subscriber.on('message', (_, data) => {
+ connection.send(data);
+ });
+
+ connection.on('message', async (data) => {
+ const msg = JSON.parse(data.utf8Data);
+
+ switch (msg.type) {
+ case 'read':
+ if (!msg.id) {
+ return;
+ }
+
+ const id = new mongodb.ObjectID(msg.id);
+
+ // Fetch message
+ // SELECT _id, user_id, is_read
+ const message = await Message.findOne({
+ _id: id,
+ recipient_id: user._id
+ }, {
+ fields: {
+ _id: true,
+ user_id: true,
+ is_read: true
+ }
+ });
+
+ if (message == null) {
+ return;
+ }
+
+ if (message.is_read) {
+ return;
+ }
+
+ // Update documents
+ await Message.update({
+ _id: id
+ }, {
+ $set: { is_read: true }
+ });
+
+ // Publish event
+ publishMessagingStream(message.user_id, user._id, 'read', id.toString());
+ break;
+ }
+ });
+}
diff --git a/src/api/streaming.ts b/src/api/streaming.ts
new file mode 100644
index 0000000000..38068d1e3d
--- /dev/null
+++ b/src/api/streaming.ts
@@ -0,0 +1,69 @@
+import * as http from 'http';
+import * as websocket from 'websocket';
+import * as redis from 'redis';
+import User from './models/user';
+
+import homeStream from './stream/home';
+import messagingStream from './stream/messaging';
+
+module.exports = (server: http.Server) => {
+ /**
+ * Init websocket server
+ */
+ const ws = new websocket.server({
+ httpServer: server
+ });
+
+ ws.on('request', async (request) => {
+ const connection = request.accept();
+
+ const user = await authenticate(connection);
+
+ // Connect to Redis
+ const subscriber = redis.createClient(
+ config.redis.port, config.redis.host);
+
+ connection.on('close', () => {
+ subscriber.unsubscribe();
+ subscriber.quit();
+ });
+
+ const channel =
+ request.resourceURL.pathname === '/' ? homeStream :
+ request.resourceURL.pathname === '/messaging' ? messagingStream :
+ null;
+
+ if (channel !== null) {
+ channel(request, connection, subscriber, user);
+ } else {
+ connection.close();
+ }
+ });
+};
+
+function authenticate(connection: websocket.connection): Promise<any> {
+ return new Promise((resolve, reject) => {
+ // Listen first message
+ connection.once('message', async (data) => {
+ const msg = JSON.parse(data.utf8Data);
+
+ // Fetch user
+ // SELECT _id
+ const user = await User
+ .findOne({
+ token: msg.i
+ }, {
+ _id: true
+ });
+
+ if (user === null) {
+ connection.close();
+ return;
+ }
+
+ connection.send('authenticated');
+
+ resolve(user);
+ });
+ });
+}
diff --git a/src/common/text/elements/bold.js b/src/common/text/elements/bold.js
new file mode 100644
index 0000000000..41a01399dd
--- /dev/null
+++ b/src/common/text/elements/bold.js
@@ -0,0 +1,17 @@
+/**
+ * Bold
+ */
+
+const regexp = /\*\*(.+?)\*\*/;
+
+module.exports = {
+ test: x => new RegExp('^' + regexp.source).test(x),
+ parse: text => {
+ const bold = text.match(new RegExp('^' + regexp.source))[0];
+ return {
+ type: 'bold',
+ content: bold,
+ bold: bold.substr(2, bold.length - 4)
+ };
+ }
+};
diff --git a/src/common/text/elements/hashtag.js b/src/common/text/elements/hashtag.js
new file mode 100644
index 0000000000..f04b782007
--- /dev/null
+++ b/src/common/text/elements/hashtag.js
@@ -0,0 +1,23 @@
+/**
+ * Hashtag
+ */
+
+module.exports = {
+ test: (x, i) =>
+ /^\s#[^\s]+/.test(x) || (i == 0 && /^#[^\s]+/.test(x))
+ ,
+ parse: text => {
+ const isHead = text[0] == '#';
+ const hashtag = text.match(/^\s?#[^\s]+/)[0];
+ const res = !isHead ? [{
+ type: 'text',
+ content: text[0]
+ }] : [];
+ res.push({
+ type: 'hashtag',
+ content: isHead ? hashtag : hashtag.substr(1),
+ hashtag: isHead ? hashtag.substr(1) : hashtag.substr(2)
+ });
+ return res;
+ }
+};
diff --git a/src/common/text/elements/mention.js b/src/common/text/elements/mention.js
new file mode 100644
index 0000000000..b58786fd1e
--- /dev/null
+++ b/src/common/text/elements/mention.js
@@ -0,0 +1,17 @@
+/**
+ * Mention
+ */
+
+const regexp = /@[a-zA-Z0-9\-]+/;
+
+module.exports = {
+ test: x => new RegExp('^' + regexp.source).test(x),
+ parse: text => {
+ const mention = text.match(new RegExp('^' + regexp.source))[0];
+ return {
+ type: 'mention',
+ content: mention,
+ username: mention.substr(1)
+ };
+ }
+};
diff --git a/src/common/text/elements/url.js b/src/common/text/elements/url.js
new file mode 100644
index 0000000000..d02aef0800
--- /dev/null
+++ b/src/common/text/elements/url.js
@@ -0,0 +1,16 @@
+/**
+ * URL
+ */
+
+const regexp = /https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.=\+\-]+/;
+
+module.exports = {
+ test: x => new RegExp('^' + regexp.source).test(x),
+ parse: text => {
+ const link = text.match(new RegExp('^' + regexp.source))[0];
+ return {
+ type: 'link',
+ content: link
+ };
+ }
+};
diff --git a/src/common/text/index.js b/src/common/text/index.js
new file mode 100644
index 0000000000..973e7c5236
--- /dev/null
+++ b/src/common/text/index.js
@@ -0,0 +1,67 @@
+/**
+ * Misskey Text Analyzer
+ */
+
+const elements = [
+ require('./elements/bold'),
+ require('./elements/url'),
+ require('./elements/mention'),
+ require('./elements/hashtag')
+];
+
+function analyze(source) {
+
+ if (source == '') {
+ return null;
+ }
+
+ const tokens = [];
+
+ function push(token) {
+ if (token != null) {
+ tokens.push(token);
+ source = source.substr(token.content.length);
+ }
+ }
+
+ let i = 0;
+
+ // パース
+ while (source != '') {
+ const parsed = elements.some(el => {
+ if (el.test(source, i)) {
+ let tokens = el.parse(source);
+ if (!Array.isArray(tokens)) {
+ tokens = [tokens];
+ }
+ tokens.forEach(push);
+ return true;
+ }
+ });
+
+ if (!parsed) {
+ push({
+ type: 'text',
+ content: source[0]
+ });
+ }
+
+ i++;
+ }
+
+ // テキストを纏める
+ tokens[0] = [tokens[0]];
+ return tokens.reduce((a, b) => {
+ if (a[a.length - 1].type == 'text' && b.type == 'text') {
+ const tail = a.pop();
+ return a.concat({
+ type: 'text',
+ content: tail.content + b.content
+ });
+ } else {
+ return a.concat(b);
+ }
+ });
+}
+
+module.exports = analyze;
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000000..2369b709fc
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,95 @@
+/**
+ * Config loader
+ */
+
+import * as fs from 'fs';
+import * as yaml from 'js-yaml';
+
+/**
+ * ユーザーが設定する必要のある情報
+ */
+interface ISource {
+ maintainer: string;
+ url: string;
+ secondary_url: string;
+ port: number;
+ https: {
+ enable: boolean;
+ key: string;
+ cert: string;
+ ca: string;
+ };
+ mongodb: {
+ host: string;
+ port: number;
+ db: string;
+ user_id: string;
+ pass: string;
+ };
+ redis: {
+ host: string;
+ port: number;
+ pass: string;
+ };
+ elasticsearch: {
+ enable: boolean;
+ host: string;
+ port: number;
+ pass: string;
+ };
+ recaptcha: {
+ siteKey: string;
+ secretKey: string;
+ };
+}
+
+/**
+ * Misskeyが自動的に(ユーザーが設定した情報から推論して)設定する情報
+ */
+interface Mixin {
+ themeColor: string;
+ themeColorForeground: string;
+ host: string;
+ scheme: string;
+ secondary_host: string;
+ secondary_scheme: string;
+ api_url: string;
+ auth_url: string;
+ dev_url: string;
+ drive_url: string;
+ proxy_url: string;
+}
+
+export type IConfig = ISource & Mixin;
+
+/**
+ * 設定を取得します
+ * @param {string} path 設定ファイルのパス
+ * @return {IConfig} 設定
+ */
+export default (path: string) => {
+ const config = yaml.safeLoad(fs.readFileSync(path, 'utf8')) as ISource;
+
+ const mixin: Mixin = {} as Mixin;
+
+ config.url = normalizeUrl(config.url);
+ config.secondary_url = normalizeUrl(config.secondary_url);
+
+ mixin.themeColor = '#f76d6c';
+ mixin.themeColorForeground = '#fff';
+ mixin.host = config.url.substr(config.url.indexOf('://') + 3);
+ mixin.scheme = config.url.substr(0, config.url.indexOf('://'));
+ mixin.secondary_host = config.secondary_url.substr(config.secondary_url.indexOf('://') + 3);
+ 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.dev_url = `${mixin.scheme}://dev.${mixin.host}`;
+ mixin.drive_url = `${mixin.secondary_scheme}://file.${mixin.secondary_host}`;
+ mixin.proxy_url = `${mixin.secondary_scheme}://proxy.${mixin.secondary_host}`;
+
+ return Object.assign(config || {}, mixin) as IConfig;
+};
+
+function normalizeUrl(url: string): string {
+ return url[url.length - 1] === '/' ? url.substr(0, url.length - 1) : url;
+}
diff --git a/src/db/elasticsearch.ts b/src/db/elasticsearch.ts
new file mode 100644
index 0000000000..27040d102a
--- /dev/null
+++ b/src/db/elasticsearch.ts
@@ -0,0 +1,21 @@
+import * as elasticsearch from 'elasticsearch';
+
+// Init ElasticSearch connection
+const client = new elasticsearch.Client({
+ host: `${config.elasticsearch.host}:${config.elasticsearch.port}`
+});
+
+// Send a HEAD request
+client.ping({
+ // Ping usually has a 3000ms timeout
+ requestTimeout: Infinity,
+
+ // Undocumented params are appended to the query string
+ hello: 'elasticsearch!'
+}, error => {
+ if (error) {
+ console.error('elasticsearch is down!');
+ }
+});
+
+export default client;
diff --git a/src/db/mongodb.ts b/src/db/mongodb.ts
new file mode 100644
index 0000000000..e2b2479b49
--- /dev/null
+++ b/src/db/mongodb.ts
@@ -0,0 +1,8 @@
+import * as mongodb from 'mongodb';
+
+export default async function(): Promise<mongodb.Db> {
+ 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}`;
+ return await mongodb.MongoClient.connect(uri);
+};
diff --git a/src/db/redis.ts b/src/db/redis.ts
new file mode 100644
index 0000000000..ba4d647811
--- /dev/null
+++ b/src/db/redis.ts
@@ -0,0 +1,9 @@
+import * as redis from 'redis';
+
+export default redis.createClient(
+ config.redis.port,
+ config.redis.host,
+ {
+ auth_pass: config.redis.pass
+ }
+);
diff --git a/src/file/resources/avatar.jpg b/src/file/resources/avatar.jpg
new file mode 100644
index 0000000000..3c803f568e
--- /dev/null
+++ b/src/file/resources/avatar.jpg
Binary files differ
diff --git a/src/file/resources/bad-egg.png b/src/file/resources/bad-egg.png
new file mode 100644
index 0000000000..a7c5930bd4
--- /dev/null
+++ b/src/file/resources/bad-egg.png
Binary files differ
diff --git a/src/file/resources/dummy.png b/src/file/resources/dummy.png
new file mode 100644
index 0000000000..39332b0c1b
--- /dev/null
+++ b/src/file/resources/dummy.png
Binary files differ
diff --git a/src/file/server.ts b/src/file/server.ts
new file mode 100644
index 0000000000..0f269c4424
--- /dev/null
+++ b/src/file/server.ts
@@ -0,0 +1,115 @@
+/**
+ * File Server
+ */
+
+import * as fs from 'fs';
+import * as express from 'express';
+import * as bodyParser from 'body-parser';
+import * as cors from 'cors';
+import * as mongodb from 'mongodb';
+import * as gm from 'gm';
+
+import File from '../api/models/drive-file';
+
+/**
+ * Init app
+ */
+const app = express();
+
+app.disable('x-powered-by');
+app.locals.cache = true;
+app.use(bodyParser.urlencoded({ extended: true }));
+app.use(cors());
+
+/**
+ * Statics
+ */
+app.use('/resources', express.static(__dirname + '/resources', {
+ maxAge: 1000 * 60 * 60 * 24 * 365 // 一年
+}));
+
+app.get('/', (req, res) => {
+ res.send('yee haw');
+});
+
+app.get('/default-avatar.jpg', (req, res) => {
+ const file = fs.readFileSync(__dirname + '/resources/avatar.jpg');
+ send(file, 'image/jpeg', req, res);
+});
+
+app.get('/app-default.jpg', (req, res) => {
+ const file = fs.readFileSync(__dirname + '/resources/dummy.png');
+ send(file, 'image/png', req, res);
+});
+
+async function raw(data: Buffer, type: string, download: boolean, res: express.Response): Promise<any> {
+ res.header('Content-Type', type);
+
+ if (download) {
+ res.header('Content-Disposition', 'attachment');
+ }
+
+ res.send(data);
+}
+
+async function thumbnail(data: Buffer, type: string, resize: number, res: express.Response): Promise<any> {
+ if (!/^image\/.*$/.test(type)) {
+ data = fs.readFileSync(__dirname + '/resources/dummy.png');
+ }
+
+ let g = gm(data);
+
+ if (resize) {
+ g = g.resize(resize, resize);
+ }
+
+ g
+ .compress('jpeg')
+ .quality(80)
+ .toBuffer('jpeg', (err, img) => {
+ if (err !== undefined && err !== null) {
+ console.error(err);
+ res.sendStatus(500);
+ return;
+ }
+
+ res.header('Content-Type', 'image/jpeg');
+ res.send(img);
+ });
+}
+
+function send(data: Buffer, type: string, req: express.Request, res: express.Response): void {
+ if (req.query.thumbnail !== undefined) {
+ thumbnail(data, type, req.query.size, res);
+ } else {
+ raw(data, type, req.query.download !== undefined, res);
+ }
+}
+
+/**
+ * Routing
+ */
+
+app.get('/:id', async (req, res): Promise<void> => {
+ const file = await File.findOne({_id: new mongodb.ObjectID(req.params.id)});
+
+ if (file === null) {
+ res.status(404).sendFile(__dirname + '/resources/dummy.png');
+ return;
+ }
+
+ send(file.data.buffer, file.type, req, res);
+});
+
+app.get('/:id/:name', async (req, res): Promise<void> => {
+ const file = await File.findOne({_id: new mongodb.ObjectID(req.params.id)});
+
+ if (file === null) {
+ res.status(404).sendFile(__dirname + '/resources/dummy.png');
+ return;
+ }
+
+ send(file.data.buffer, file.type, req, res);
+});
+
+module.exports = app;
diff --git a/src/himasaku/resources/himasaku.png b/src/himasaku/resources/himasaku.png
new file mode 100644
index 0000000000..25cd91e954
--- /dev/null
+++ b/src/himasaku/resources/himasaku.png
Binary files differ
diff --git a/src/himasaku/resources/index.html b/src/himasaku/resources/index.html
new file mode 100644
index 0000000000..f9e45d7a74
--- /dev/null
+++ b/src/himasaku/resources/index.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">
+ <meta name="description" content="ひまさく">
+ <meta name="keywords" content="ひまさく, さくひま, 向日葵, 櫻子">
+ <title>ひまさく</title>
+ <style>
+ html {
+ height: 100%;
+ font-size: 0;
+ }
+
+ body {
+ margin: 0;
+ height: 100%;
+ overflow: hidden;
+ }
+
+ img {
+ display: block;
+ position: absolute;
+ max-width: 100%;
+ margin: auto;
+ top: 0; right: 0; bottom: 0; left: 0;
+ pointer-events: none;
+ user-select: none;
+ }
+ </style>
+ </head>
+ <body>
+ <img src="/himasaku.png" alt="ひまさく">
+ </body>
+</html>
diff --git a/src/himasaku/server.ts b/src/himasaku/server.ts
new file mode 100644
index 0000000000..e4fb0ef177
--- /dev/null
+++ b/src/himasaku/server.ts
@@ -0,0 +1,23 @@
+/**
+ * Himasaku Server
+ */
+
+import * as express from 'express';
+
+/**
+ * Init app
+ */
+const app = express();
+
+app.disable('x-powered-by');
+app.locals.cache = true;
+
+app.get('/himasaku.png', (req, res) => {
+ res.sendFile(__dirname + '/resources/himasaku.png');
+});
+
+app.get('*', (req, res) => {
+ res.sendFile(__dirname + '/resources/index.html');
+});
+
+module.exports = app;
diff --git a/src/index.d.ts b/src/index.d.ts
new file mode 100644
index 0000000000..0af8362122
--- /dev/null
+++ b/src/index.d.ts
@@ -0,0 +1,11 @@
+import * as mongodb from 'mongodb';
+import { IConfig } from './config';
+
+declare var config: IConfig;
+
+declare module NodeJS {
+ interface Global {
+ config: IConfig;
+ db: mongodb.Db;
+ }
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000000..b80b2da576
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,223 @@
+/**
+ * Misskey Core Entory Point!
+ */
+
+Error.stackTraceLimit = Infinity;
+
+/**
+ * Module dependencies
+ */
+import * as fs from 'fs';
+import * as os from 'os';
+import * as cluster from 'cluster';
+const prominence = require('prominence');
+import { logInfo, logDone, logWarn, logFailed } from 'log-cool';
+import * as chalk from 'chalk';
+const git = require('git-last-commit');
+const portUsed = require('tcp-port-used');
+import ProgressBar from './utils/cli/progressbar';
+import initdb from './db/mongodb';
+import checkDependencies from './utils/check-dependencies';
+
+// Init babel
+require('babel-core/register');
+require('babel-polyfill');
+
+const env = process.env.NODE_ENV;
+const IS_PRODUCTION = env === 'production';
+const IS_DEBUG = !IS_PRODUCTION;
+
+global.config = require('./config').default(`${__dirname}/../.config/config.yml`);
+
+/**
+ * Initialize state
+ */
+enum State {
+ success,
+ warn,
+ failed
+}
+
+// Set process title
+process.title = 'Misskey';
+
+// Start app
+main();
+
+/**
+ * Init proccess
+ */
+function main(): void {
+ // Master
+ if (cluster.isMaster) {
+ master();
+ } else { // Workers
+ worker();
+ }
+}
+
+/**
+ * Init master proccess
+ */
+async function master(): Promise<void> {
+ let state: State;
+
+ try {
+ // initialize app
+ state = await init();
+ } catch (e) {
+ console.error(e);
+ process.exit(1);
+ }
+
+ const res = (t: string, c: string) =>
+ console.log(chalk.bold(`--> ${(chalk as any)[c](t)}\n`));
+
+ switch (state) {
+ case State.failed:
+ res('Fatal error occurred :(', 'red');
+ process.exit();
+ return;
+ case State.warn:
+ res('Some problem(s) :|', 'yellow');
+ break;
+ case State.success:
+ res('OK :)', 'green');
+ break;
+ }
+
+ // Spawn workers
+ spawn(() => {
+ console.log(chalk.bold.green(`\nMisskey Core is now running. [port:${config.port}]`));
+
+ // Listen new workers
+ cluster.on('fork', worker => {
+ console.log(`Process forked: [${worker.id}]`);
+ });
+
+ // Listen online workers
+ cluster.on('online', worker => {
+ console.log(`Process is now online: [${worker.id}]`);
+ });
+
+ // Listen for dying workers
+ cluster.on('exit', worker => {
+ // Replace the dead worker,
+ // we're not sentimental
+ console.log(chalk.red(`[${worker.id}] died :(`));
+ cluster.fork();
+ });
+ });
+}
+
+/**
+ * Init worker proccess
+ */
+function worker(): void {
+ // Register config
+ global.config = config;
+
+ // Init mongo
+ initdb().then(db => {
+ global.db = db;
+
+ // start server
+ require('./server');
+ }, err => {
+ console.error(err);
+ process.exit(0);
+ });
+}
+
+/**
+ * Init app
+ */
+async function init(): Promise<State> {
+ console.log('Welcome to Misskey!\n');
+
+ console.log(chalk.bold('Misskey Core <aoi>'));
+
+ let warn = false;
+
+ // Get commit info
+ try {
+ const commit = await prominence(git).getLastCommit();
+ console.log(`commit: ${commit.shortHash} ${commit.author.name} <${commit.author.email}>`);
+ console.log(` ${new Date(parseInt(commit.committedOn, 10) * 1000)}`);
+ } catch (e) {
+ // noop
+ }
+
+ console.log('\nInitializing...\n');
+
+ if (IS_DEBUG) {
+ logWarn('It is not in the Production mode. Do not use in the Production environment.');
+ }
+
+ logInfo(`environment: ${env}`);
+
+ // Get machine info
+ const totalmem = (os.totalmem() / 1024 / 1024 / 1024).toFixed(1);
+ const freemem = (os.freemem() / 1024 / 1024 / 1024).toFixed(1);
+ logInfo(`MACHINE: ${os.hostname()}`);
+ logInfo(`MACHINE: CPU: ${os.cpus().length}core`);
+ logInfo(`MACHINE: MEM: ${totalmem}GB (available: ${freemem}GB)`);
+
+ if (!fs.existsSync(`${__dirname}/../.config/config.yml`)) {
+ logFailed('Configuration not found');
+ return State.failed;
+ }
+
+ logDone('Success to load configuration');
+ logInfo(`maintainer: ${config.maintainer}`);
+
+ checkDependencies();
+
+ // Check if a port is being used
+ if (await portUsed.check(config.port)) {
+ logFailed(`Port: ${config.port} is already used!`);
+ return State.failed;
+ }
+
+ // Try to connect to MongoDB
+ try {
+ const db = await initdb(config);
+ logDone('Success to connect to MongoDB');
+ db.close();
+ } catch (e) {
+ logFailed(`MongoDB: ${e}`);
+ return State.failed;
+ }
+
+ return warn ? State.warn : State.success;
+}
+
+/**
+ * Spawn workers
+ */
+function spawn(callback: any): void {
+ // Count the machine's CPUs
+ const cpuCount = os.cpus().length;
+
+ const progress = new ProgressBar(cpuCount, 'Starting workers');
+
+ // Create a worker for each CPU
+ for (let i = 0; i < cpuCount; i++) {
+ const worker = cluster.fork();
+ worker.on('message', message => {
+ if (message === 'ready') {
+ progress.increment();
+ }
+ });
+ }
+
+ // On all workers started
+ progress.on('complete', () => {
+ callback();
+ });
+}
+
+// Dying away...
+process.on('exit', () => {
+ console.log('Bye.');
+});
diff --git a/src/server.ts b/src/server.ts
new file mode 100644
index 0000000000..a327504b26
--- /dev/null
+++ b/src/server.ts
@@ -0,0 +1,49 @@
+/**
+ * Core Server
+ */
+
+import * as fs from 'fs';
+import * as http from 'http';
+import * as https from 'https';
+
+import * as express from 'express';
+const vhost = require('vhost');
+
+/**
+ * Init app
+ */
+const app = express();
+app.disable('x-powered-by');
+
+/**
+ * Register modules
+ */
+app.use(vhost(`api.${config.host}`, require('./api/server')));
+app.use(vhost(config.secondary_host, require('./himasaku/server')));
+app.use(vhost(`file.${config.secondary_host}`, require('./file/server')));
+app.use(vhost(`proxy.${config.secondary_host}`, require('./web/service/proxy/server')));
+app.use(require('./web/server'));
+
+/**
+ * Create server
+ */
+const server = config.https.enable ?
+ https.createServer({
+ key: fs.readFileSync(config.https.key),
+ cert: fs.readFileSync(config.https.cert),
+ ca: fs.readFileSync(config.https.ca)
+ }, app) :
+ http.createServer(app);
+
+/**
+ * Steaming
+ */
+require('./api/streaming')(server);
+
+/**
+ * Server listen
+ */
+server.listen(config.port, () => {
+ // Send a 'ready' message to parent process
+ process.send('ready');
+});
diff --git a/src/utils/check-dependencies.ts b/src/utils/check-dependencies.ts
new file mode 100644
index 0000000000..7bcb87a68f
--- /dev/null
+++ b/src/utils/check-dependencies.ts
@@ -0,0 +1,23 @@
+import {logInfo, logDone, logWarn} from 'log-cool';
+import {exec} from 'shelljs';
+
+export default function(): void {
+ checkDependency('Node.js', 'node -v', x => x.match(/^v(.*)\r?\n$/)[1]);
+ checkDependency('npm', 'npm -v', x => x.match(/^(.*)\r?\n$/)[1]);
+ checkDependency('MongoDB', 'mongo --version', x => x.match(/^MongoDB shell version: (.*)\r?\n$/)[1]);
+ checkDependency('Redis', 'redis-server --version', x => x.match(/v=([0-9\.]*)/)[1]);
+ logDone('Successfully checked external dependencies');
+}
+
+function checkDependency(serviceName: string, command: string, transform: (x: string) => string): void {
+ const code = {
+ success: 0,
+ notFound: 127
+ };
+ const x = exec(command, { silent: true }) as any;
+ if (x.code === code.success) {
+ logInfo(`DEPS: ${serviceName} ${transform(x.stdout)}`);
+ } else if (x.code === code.notFound) {
+ logWarn(`Unable to find ${serviceName}`);
+ }
+}
diff --git a/src/utils/cli/indicator.ts b/src/utils/cli/indicator.ts
new file mode 100644
index 0000000000..3e23f9b274
--- /dev/null
+++ b/src/utils/cli/indicator.ts
@@ -0,0 +1,35 @@
+import * as readline from 'readline';
+
+/**
+ * Indicator
+ */
+export default class {
+ private clock: NodeJS.Timer;
+
+ constructor(text: string) {
+ let i = 0; // Dots counter
+
+ draw();
+ this.clock = setInterval(draw, 300);
+
+ function draw(): void {
+ cll();
+ i = (i + 1) % 4;
+ const dots = new Array(i + 1).join('.');
+ process.stdout.write(text + dots); // Write text
+ }
+ }
+
+ public end(): void {
+ clearInterval(this.clock);
+ cll();
+ }
+}
+
+/**
+ * Clear current line
+ */
+function cll(): void {
+ readline.clearLine(process.stdout, 0); // Clear current text
+ readline.cursorTo(process.stdout, 0, null); // Move cursor to the head of line
+}
diff --git a/src/utils/cli/progressbar.ts b/src/utils/cli/progressbar.ts
new file mode 100644
index 0000000000..19852b3ea3
--- /dev/null
+++ b/src/utils/cli/progressbar.ts
@@ -0,0 +1,87 @@
+import * as ev from 'events';
+import * as readline from 'readline';
+import * as chalk from 'chalk';
+
+/**
+ * Progress bar
+ */
+class ProgressBar extends ev.EventEmitter {
+ public max: number;
+ public value: number;
+ public text: string;
+ private indicator: number;
+
+ constructor(max: number, text: string = null) {
+ super();
+ this.max = max;
+ this.value = 0;
+ this.text = text;
+ this.indicator = 0;
+ this.draw();
+
+ const iclock = setInterval(() => {
+ this.indicator = (this.indicator + 1) % 4;
+ this.draw();
+ }, 200);
+
+ this.on('complete', () => {
+ clearInterval(iclock);
+ });
+ }
+
+ public increment(): void {
+ this.value++;
+ this.draw();
+
+ // Check if it is fulfilled
+ if (this.value === this.max) {
+ this.indicator = null;
+
+ cll();
+ process.stdout.write(`${this.render()} -> ${chalk.bold('Complete')}\n`);
+
+ this.emit('complete');
+ }
+ }
+
+ public draw(): void {
+ const str = this.render();
+ cll();
+ process.stdout.write(str);
+ }
+
+ private render(): string {
+ const width = 30;
+ const t = this.text ? this.text + ' ' : '';
+
+ const v = Math.floor((this.value / this.max) * width);
+ const vs = new Array(v + 1).join('*');
+
+ const p = width - v;
+ const ps = new Array(p + 1).join(' ');
+
+ const percentage = Math.floor((this.value / this.max) * 100);
+ const percentages = chalk.gray(`(${percentage}%)`);
+
+ let i: string;
+ switch (this.indicator) {
+ case 0: i = '-'; break;
+ case 1: i = '\\'; break;
+ case 2: i = '|'; break;
+ case 3: i = '/'; break;
+ case null: i = '+'; break;
+ }
+
+ return `${i} ${t}[${vs}${ps}] ${this.value}/${this.max} ${percentages}`;
+ }
+}
+
+export default ProgressBar;
+
+/**
+ * Clear current line
+ */
+function cll(): void {
+ readline.clearLine(process.stdout, 0); // Clear current text
+ readline.cursorTo(process.stdout, 0, null); // Move cursor to the head of line
+}
diff --git a/src/web/about/base.pug b/src/web/about/base.pug
new file mode 100644
index 0000000000..a026c03f28
--- /dev/null
+++ b/src/web/about/base.pug
@@ -0,0 +1,39 @@
+doctype html
+
+html(lang='ja', dir='ltr')
+
+ head
+ meta(charset='utf-8')
+ meta(name='application-name', content='Misskey')
+ meta(name='theme-color', content='#f76d6c')
+ meta(name='referrer', content='origin')
+ meta(name='viewport', content='width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no')
+ link(rel='stylesheet', href='/resources/style.css')
+ title
+ block title
+
+ body
+ nav
+ ul
+ li
+ p API
+ ul
+ li: a(href='/api/getting-started') Getting Started
+ li
+ p Entities
+ ul
+ li: a(href='/api/entities/post') Post
+ li: a(href='/api/entities/user') User
+ li: a(href='/api/library') ライブラリ
+ li: a(href='/license') ライセンス
+
+ main
+ article
+ block content
+ footer
+ p.contribution
+ | 間違いを見つけた、またはドキュメントに貢献したいですか?
+ a(href='https://github.com/syuilo/misskey/blob/master/src/web/about/pages/' + path + '.pug', target='_blank') Github 上でこのページを編集する
+ | か、
+ a(href='https://github.com/syuilo/misskey/fork', target='_blank') Github からこのサイトを Fork してプルリクエストしましょう!
+ p.copyright (c) syuilo 2016
diff --git a/src/web/about/pages/api/entities/post.pug b/src/web/about/pages/api/entities/post.pug
new file mode 100644
index 0000000000..ad53be9540
--- /dev/null
+++ b/src/web/about/pages/api/entities/post.pug
@@ -0,0 +1,149 @@
+extend ../../../base
+
+block title
+ | Entity: Post
+
+block content
+ h1 Post
+ p 投稿を表します。
+
+ section
+ h2 Properties
+ table.entity
+ thead: tr
+ td Name
+ td Type
+ td Description
+ tbody
+ tr.nullable.optional
+ td app
+ td: a(href='./app', target='_blank') App
+ td 投稿したアプリ
+ tr.nullable
+ td app_id
+ td ID
+ td 投稿したアプリのID
+ tr
+ td created_at
+ td Date
+ td 投稿日時
+ tr
+ td id
+ td ID
+ td 投稿ID
+ tr.optional
+ td is_liked
+ td Boolean
+ td いいね したかどうか
+ tr
+ td likes_count
+ td Number
+ td いいね数
+ tr.nullable.optional
+ td media_ids
+ td ID[]
+ td 添付されたメディアのIDの配列
+ tr.nullable.optional
+ td media
+ td: a(href='./drive-file', target='_blank') DriveFile[]
+ td 添付されたメディアの配列
+ tr
+ td replies_count
+ td Number
+ td 返信数
+ tr.optional
+ td reply_to
+ td: a(href='./post', target='_blank') Post
+ td 返信先の投稿
+ tr.nullable
+ td reply_to_id
+ td ID
+ td 返信先の投稿のID
+ tr.optional
+ td repost
+ td: a(href='./post', target='_blank') Post
+ td Repostした投稿
+ tr
+ td repost_count
+ td Number
+ td Repostされた数
+ tr.nullable
+ td repost_id
+ td ID
+ td Repostした投稿のID
+ tr.nullable
+ td text
+ td String
+ td 本文
+ tr.optional
+ td user
+ td: a(href='./user', target='_blank') User
+ td 投稿者
+ tr
+ td user_id
+ td ID
+ td 投稿者のID
+
+ section
+ h2 Example
+ pre: code.
+ {
+ "created_at": "2016-12-10T00:28:50.114Z",
+ "media_ids": null,
+ "reply_to_id": "584a16b15860fc52320137e3",
+ "repost_id": null,
+ "text": "小日向美穂だぞ!",
+ "user_id": "5848bf7764e572683f4402f8",
+ "app_id": null,
+ "likes_count": 1,
+ "replies_count": 1,
+ "id": "584b4c42d8e5186f8f755d0c",
+ "user": {
+ "birthday": null,
+ "created_at": "2016-12-08T02:03:35.332Z",
+ "bio": "女が嫌いです、女性は好きです",
+ "followers_count": 11,
+ "following_count": 11,
+ "links": null,
+ "location": "",
+ "name": "女が嫌い",
+ "posts_count": 26,
+ "likes_count": 2,
+ "liked_count": 20,
+ "username": "onnnagakirai",
+ "id": "5848bf7764e572683f4402f8",
+ "avatar_url": "https://file.himasaku.net/5848c0ec64e572683f4402fc",
+ "banner_url": "https://file.himasaku.net/5848c12864e572683f4402fd",
+ "is_following": true,
+ "is_followed": true
+ },
+ "reply_to": {
+ "created_at": "2016-12-09T02:28:01.563Z",
+ "media_ids": null,
+ "reply_to_id": "5849d35e547e4249be329884",
+ "repost_id": null,
+ "text": "アイコン小日向美穂?",
+ "user_id": "57d01a501fdf2d07be417afe",
+ "app_id": null,
+ "replies_count": 1,
+ "id": "584a16b15860fc52320137e3",
+ "user": {
+ "birthday": null,
+ "created_at": "2016-09-07T13:46:56.605Z",
+ "bio": "どうすれば君だけのために生きていけるの",
+ "followers_count": 51,
+ "following_count": 97,
+ "links": null,
+ "location": "川崎",
+ "name": "きな子",
+ "posts_count": 4813,
+ "username": "syuilo",
+ "likes_count": 3141,
+ "liked_count": 750,
+ "id": "57d01a501fdf2d07be417afe",
+ "avatar_url": "https://file.himasaku.net/583ddc6e64df272771f74c1a",
+ "banner_url": "https://file.himasaku.net/584bfc82d8e5186f8f755ec5"
+ }
+ },
+ "is_liked": true
+ }
diff --git a/src/web/about/pages/api/entities/user.pug b/src/web/about/pages/api/entities/user.pug
new file mode 100644
index 0000000000..eef973fd63
--- /dev/null
+++ b/src/web/about/pages/api/entities/user.pug
@@ -0,0 +1,118 @@
+extend ../../../base
+
+block title
+ | Entity: User
+
+block content
+ h1 User
+ p ユーザーを表します。
+
+ section
+ h2 Properties
+ table.entity
+ thead: tr
+ td Name
+ td Type
+ td Description
+ tbody
+ tr.nullable.optional
+ td avatar_id
+ td ID
+ td アバターに設定しているドライブのファイルのID
+ tr.nullable
+ td avatar_url
+ td String
+ td アバターURL
+ tr.nullable.optional
+ td banner_id
+ td ID
+ td バナーに設定しているドライブのファイルのID
+ tr.nullable
+ td banner_url
+ td String
+ td バナーURL
+ tr.nullable
+ td bio
+ td String
+ td プロフィール
+ tr.nullable
+ td birthday
+ td String
+ td 誕生日
+ tr
+ td created_at
+ td Date
+ td アカウント作成日時
+ tr.optional
+ td drive_capacity
+ td Number
+ td ドライブの最大容量(byte単位)
+ tr
+ td followers_count
+ td Number
+ td フォロワー数
+ tr
+ td following_count
+ td Number
+ td フォロー数
+ tr
+ td id
+ td ID
+ td ユーザーID
+ tr.optional
+ td is_followed
+ td Boolean
+ td フォローされているか
+ tr.optional
+ td is_following
+ td Boolean
+ td フォローしているか
+ tr
+ td liked_count
+ td Number
+ td 投稿にいいねされた数
+ tr
+ td likes_count
+ td Number
+ td 投稿にいいねした数
+ tr.nullable
+ td location
+ td String
+ td 場所
+ tr
+ td name
+ td String
+ td ニックネーム
+ tr
+ td posts_count
+ td Number
+ td 投稿数
+ tr
+ td username
+ td String
+ td ユーザー名
+
+ section
+ h2 Example
+ pre: code.
+ {
+ "avatar_id": "583ddc6e64df272771f74c1a",
+ "avatar_url": "https://file.himasaku.net/583ddc6e64df272771f74c1a",
+ "banner_id": "584bfc82d8e5186f8f755ec5",
+ "banner_url": "https://file.himasaku.net/584bfc82d8e5186f8f755ec5",
+ "bio": "どうすれば君だけのために生きていけるの",
+ "birthday": null,
+ "created_at": "2016-09-07T13:46:56.605Z",
+ "drive_capacity": 1073741824,
+ "email": null,
+ "followers_count": 51,
+ "following_count": 97,
+ "id": "57d01a501fdf2d07be417afe",
+ "liked_count": 750,
+ "likes_count": 3130,
+ "links": null,
+ "location": "川崎",
+ "name": "きな子",
+ "posts_count": 4811,
+ "username": "syuilo"
+ }
diff --git a/src/web/about/pages/api/getting-started.pug b/src/web/about/pages/api/getting-started.pug
new file mode 100644
index 0000000000..974964e3eb
--- /dev/null
+++ b/src/web/about/pages/api/getting-started.pug
@@ -0,0 +1,74 @@
+extend ../../base
+
+block title
+ | Getting Started
+
+block content
+ h1 Getting Started
+
+ p MisskeyはREST APIやStreaming APIを提供しており、プログラムからMisskeyの全ての機能を利用することができます。
+ p それらのAPIを利用するには、まずAPIを利用したいアカウントのアクセストークンを取得する必要があります:
+
+ section
+ h2 自分のアクセストークンを取得したい場合
+ p 自分自身のアクセストークンは、設定 > API で確認できます。
+ p.tip
+ | アカウントを乗っ取られてしまう可能性があるため、トークンは第三者に教えないでください(アプリなどにも入力しないでください)。
+ br
+ | 万が一トークンが漏れたりその可能性がある場合は トークンを再生成できます。(副作用として、ログインしているすべてのデバイスでログアウトが発生します)
+
+ section
+ h2 他人のアクセストークンを取得する
+ p
+ | 不特定多数のユーザーからAPIを利用したい場合、アプリケーションを作成します。
+ br
+ | アプリケーションを作成すると、ユーザーが連携を許可した時に、そのユーザーのアクセストークンを取得することができます。
+ p アプリケーションを作成しアクセストークンを取得するまでの流れを見ていきます。
+
+ section
+ h3 アプリケーションを作成する
+ p まずはあなたのアプリケーションを作成しましょう。
+ p
+ | <a href=#{config.dev_url} target="_blank">デベロッパーセンター</a>にアクセスし、アプリ > アプリ作成 に進みます。
+ br
+ | 次に、フォームに必要事項を記入します:
+ dl
+ dt アプリケーション名
+ dd あなたのアプリケーションの名前。
+ dt Named ID
+ dd アプリを識別する/a-z-/で構成されたID。
+ dt アプリの概要
+ dd アプリの簡単な説明を入力してください。
+ dt コールバックURL
+ dd あなたのアプリケーションがWebアプリケーションである場合、ユーザーが後述するフォームで認証を終えた際にリダイレクトするURLを設定できます。
+ dt 権限
+ dd アプリケーションが要求する権限。ここで要求した機能だけがAPIからアクセスできます。
+ p.tip
+ | 権限はアプリ作成後も変更できますが、新たな権限を付与する場合、その時点で関連付けられているユーザーはすべて無効になります。
+ p
+ | アプリケーションを作成すると、作ったアプリの管理ページに進みます。
+ br
+ | アプリのシークレットキー(App Secret)が表示されていますので、メモしておいてください。
+ p.tip
+ | アプリに成りすまされる可能性があるため、極力このシークレットキーは公開しないようにしてください。
+
+ section
+ h3 ユーザーに認証させる
+ p あなたのアプリを使ってもらうには、ユーザーにアカウントへアクセスすることを許可してもらい、Misskeyにそのユーザーのアクセストークンを発行してもらう必要があります。
+ p 認証セッションを開始するには、<code>#{config.api_url}/auth/session/generate</code>へパラメータに<code>app_secret</code>としてApp Secretを含めたリクエストを送信します。
+ p
+ | そうすると、レスポンスとして認証セッションのトークンや認証フォームのURLが取得できます。
+ br
+ | この認証フォームのURLをブラウザで表示し、ユーザーにフォームを表示してください。
+ section
+ h4 あなたのアプリがコールバックURLを設定している場合
+ p ユーザーがアプリの連携を許可すると設定しているコールバックURLに<code>token</code>という名前でセッションのトークンが含まれたクエリを付けてリダイレクトします。
+ section
+ h4 あなたのアプリがコールバックURLを設定していない場合
+ p ユーザーがアプリの連携を許可したことを(何らかの方法で(たとえばボタンを押させるなど))確認出来るようにしてください。
+ p
+ | 次に、<code>#{config.api_url}/auth/session/userkey</code>へ<code>app_secret</code>としてApp Secretを、<code>token</code>としてセッションのトークンをパラメータとして付与したリクエストを送信してください。
+ br
+ | 上手くいけば、認証したユーザーのアクセストークンがレスポンスとして取得できます。おめでとうございます!
+
+ p アクセストークンを取得できたら、あとは簡単です。REST APIなら、リクエストにアクセストークンを<code>_userkey</code>(「自分のアクセストークンを取得したい場合」の方法で取得したアクセストークンの場合は<code>i</code>)としてパラメータに含めるだけです。
diff --git a/src/web/about/pages/api/library.pug b/src/web/about/pages/api/library.pug
new file mode 100644
index 0000000000..b1ed16e71d
--- /dev/null
+++ b/src/web/about/pages/api/library.pug
@@ -0,0 +1,14 @@
+extend ../../base
+
+block title
+ | ライブラリ
+
+block content
+ h1 ライブラリ
+
+ p Misskey APIを便利に利用するためのライブラリ一覧です。
+
+ section
+ h2 .NET
+ ul
+ li: strong: a(href='https://github.com/syuilo/Misq') Misq (公式)
diff --git a/src/web/about/pages/license.pug b/src/web/about/pages/license.pug
new file mode 100644
index 0000000000..7adda0bff4
--- /dev/null
+++ b/src/web/about/pages/license.pug
@@ -0,0 +1,9 @@
+extend ../base
+
+block title
+ | ライセンス
+
+block content
+ h1 ライセンス
+
+ include ../../../../LICENSE
diff --git a/src/web/about/resources/style.css b/src/web/about/resources/style.css
new file mode 100644
index 0000000000..53d658fa6b
--- /dev/null
+++ b/src/web/about/resources/style.css
@@ -0,0 +1,199 @@
+html {
+ font-family: sans-serif;
+}
+
+body {
+ margin: 0;
+ color: #34495e;
+}
+
+nav {
+ display: block;
+ float: left;
+ width: 210px;
+}
+nav ul {
+ display: block;
+ margin: 0 0 16px 0;
+ padding: 0 0 0 16px;
+ list-style: none;
+}
+nav ul li {
+ margin: 0;
+ padding: 0;
+}
+nav ul li p {
+ margin: 16px 0 0 0;
+}
+@media screen and (max-width: 910px) {
+ nav {
+ display: none;
+ }
+}
+
+main {
+ float: left;
+ box-sizing: border-box;
+ padding: 32px;
+ width: 100%;
+ max-width: 700px;
+}
+@media screen and (max-width: 700px) {
+ main {
+ font-size: 8px;
+ }
+}
+
+footer {
+ padding: 32px 0 0 0;
+ margin: 32px 0 0 0;
+ border-top: solid 1px #eee;
+}
+
+footer .contribution {
+ margin: 0 0 16px 0;
+}
+
+footer .copyright {
+ margin: 16px 0 0 0;
+ color: #aaa;
+}
+
+a {
+ text-decoration: none;
+ color: #f76d6c;
+}
+ a:hover {
+ text-decoration: underline;
+ }
+
+section {
+ margin: 32px 0;
+}
+
+h1 {
+ margin: 0 0 24px 0;
+ padding: 16px 0;
+ font-size: 1.5em;
+ border-bottom: solid 2px #eee;
+}
+
+h2 {
+ margin: 0 0 24px 0;
+ padding: 0 0 16px 0;
+ font-size: 1.4em;
+ border-bottom: solid 1px #eee;
+}
+
+h3 {
+ margin: 0;
+ padding: 0;
+ font-size: 1.25em;
+}
+
+h4 {
+ margin: 0;
+}
+
+p {
+ margin: 1em 0;
+ line-height: 1.6em;
+}
+
+p.tip {
+ position: relative;
+ padding: 12px 24px 12px 30px;
+ margin: 1em 0;
+ font-size: 0.9em;
+ border-left: 4px solid #f66;
+ background-color: #f8f8f8;
+ border-bottom-right-radius: 2px;
+ border-top-right-radius: 2px;
+}
+ p.tip:before {
+ position: absolute;
+ top: 14px;
+ left: -12px;
+ background-color: #f66;
+ color: #fff;
+ content: "!";
+ width: 20px;
+ height: 20px;
+ border-radius: 100%;
+ text-align: center;
+ line-height: 20px;
+ font-weight: bold;
+ font-family: 'Dosis', 'Source Sans Pro', 'Helvetica Neue', Arial, sans-serif;
+ font-size: 14px;
+ }
+
+table {
+ width: 100%;
+ border-spacing: 0;
+ border-collapse: collapse;
+}
+
+table thead {
+ font-weight: bold;
+ border-bottom: solid 2px #eee;
+}
+
+table tbody tr {
+ border-bottom: dashed 1px #eee;
+}
+
+table th, table td {
+ padding: 8px 16px;
+}
+
+table.entity tbody tr td:nth-child(1) {
+ font-family: Consolas, 'Courier New', Courier, Monaco, monospace;
+}
+
+table.entity tbody tr td:nth-child(2) {
+ font-style: italic;
+}
+
+table.entity tr td:nth-child(3):after {
+ margin-left: 8px;
+ opacity: 0.7;
+}
+
+table.entity tr.nullable td:nth-child(2):after {
+ content: "?";
+ opacity: 0.7;
+}
+table.entity tr.nullable td:nth-child(3):after {
+ content: "(Null許容)";
+}
+
+table.entity tr.optional {
+ opacity: 0.7;
+}
+table.entity tr.optional td:nth-child(3):after {
+ content: "(省略可能)";
+}
+
+table.entity tr.nullable.optional td:nth-child(3):after {
+ content: "(Null許容かつ省略可能)";
+}
+
+pre, code, var, samp, kbd {
+ font-family: Consolas, 'Courier New', Courier, Monaco, monospace;
+}
+
+code {
+ display: inline-block;
+ margin: 0 4px;
+ padding: 0 8px;
+ color: #525252;
+ background: #f8f8f8;
+ border-radius: 2px;
+}
+
+pre code {
+ display: block;
+ overflow: auto;
+ margin: 0;
+ padding: 32px;
+}
diff --git a/src/web/app/auth/resources/logo.svg b/src/web/app/auth/resources/logo.svg
new file mode 100644
index 0000000000..19b8a2737e
--- /dev/null
+++ b/src/web/app/auth/resources/logo.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="1024px" height="512px" viewBox="0 256 1024 512" enable-background="new 0 256 1024 512" xml:space="preserve">
+<polyline opacity="0.5" fill="none" stroke="#000000" stroke-width="34" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" points="
+ 896.5,608.5 800.5,416.5 704.5,608.5 608.5,416.5 512.5,608.5 416.5,416.5 320.5,608.5 224.5,416.5 128.5,608.5 "/>
+</svg>
diff --git a/src/web/app/auth/script.js b/src/web/app/auth/script.js
new file mode 100644
index 0000000000..9743415b12
--- /dev/null
+++ b/src/web/app/auth/script.js
@@ -0,0 +1,19 @@
+/**
+ * Authorize Form
+ */
+
+const riot = require('riot');
+document.title = 'Misskey | アプリの連携';
+require('./tags.ls');
+const boot = require('../boot.ls');
+
+/**
+ * Boot
+ */
+boot(me => {
+ mount(document.createElement('mk-index'));
+});
+
+function mount(content) {
+ riot.mount(document.getElementById('app').appendChild(content));
+}
diff --git a/src/web/app/auth/style.styl b/src/web/app/auth/style.styl
new file mode 100644
index 0000000000..046a5ff6ee
--- /dev/null
+++ b/src/web/app/auth/style.styl
@@ -0,0 +1,14 @@
+@import "../base"
+
+html
+ background #eee
+
+ @media (max-width 600px)
+ background #fff
+
+body
+ margin 0
+ padding 32px 0
+
+ @media (max-width 600px)
+ padding 0
diff --git a/src/web/app/auth/tags.ls b/src/web/app/auth/tags.ls
new file mode 100644
index 0000000000..5f618aaadf
--- /dev/null
+++ b/src/web/app/auth/tags.ls
@@ -0,0 +1,2 @@
+require './tags/index.tag'
+require './tags/form.tag'
diff --git a/src/web/app/auth/tags/form.tag b/src/web/app/auth/tags/form.tag
new file mode 100644
index 0000000000..f5b1555554
--- /dev/null
+++ b/src/web/app/auth/tags/form.tag
@@ -0,0 +1,126 @@
+mk-form
+ header
+ h1
+ i { app.name }
+ | があなたの
+ b アカウント
+ | に
+ b アクセス
+ | することを
+ b 許可
+ | しますか?
+ img(src={ app.icon_url + '?thumbnail&size=64' })
+ div.app
+ section
+ h2 { app.name }
+ p.nid { app.name_id }
+ p.description { app.description }
+ section
+ h2 このアプリは次の権限を要求しています:
+ ul
+ virtual(each={ p in app.permission })
+ li(if={ p == 'account-read' }) アカウントの情報を見る。
+ li(if={ p == 'account-write' }) アカウントの情報を操作する。
+ li(if={ p == 'post-write' }) 投稿する。
+ li(if={ p == 'like-write' }) いいねしたりいいね解除する。
+ li(if={ p == 'following-write' }) フォローしたりフォロー解除する。
+ li(if={ p == 'drive-read' }) ドライブを見る。
+ li(if={ p == 'drive-write' }) ドライブを操作する。
+ li(if={ p == 'notification-read' }) 通知を見る。
+ li(if={ p == 'notification-write' }) 通知を操作する。
+
+ div.action
+ button(onclick={ cancel }) キャンセル
+ button(onclick={ accept }) アクセスを許可
+
+style.
+ display block
+
+ > header
+ > h1
+ margin 0
+ padding 32px 32px 20px 32px
+ font-size 24px
+ font-weight normal
+ color #777
+
+ i
+ color #77aeca
+
+ &:before
+ content '「'
+
+ &:after
+ content '」'
+
+ b
+ color #666
+
+ > img
+ display block
+ z-index 1
+ width 84px
+ height 84px
+ margin 0 auto -38px auto
+ border solid 5px #fff
+ border-radius 100%
+ box-shadow 0 2px 2px rgba(0, 0, 0, 0.1)
+
+ > .app
+ padding 44px 16px 0 16px
+ color #555
+ background #eee
+ box-shadow 0 2px 2px rgba(0, 0, 0, 0.1) inset
+
+ &:after
+ content ''
+ display block
+ clear both
+
+ > section
+ float left
+ width 50%
+ padding 8px
+ text-align left
+
+ > h2
+ margin 0
+ font-size 16px
+ color #777
+
+ > .action
+ padding 16px
+
+ > button
+ margin 0 8px
+
+ @media (max-width 600px)
+ > header
+ > img
+ box-shadow none
+
+ > .app
+ box-shadow none
+
+ @media (max-width 500px)
+ > header
+ > h1
+ font-size 16px
+
+script.
+ @mixin \api
+
+ @session = @opts.session
+ @app = @session.app
+
+ @cancel = ~>
+ @api \auth/deny do
+ token: @session.token
+ .then ~>
+ @trigger \denied
+
+ @accept = ~>
+ @api \auth/accept do
+ token: @session.token
+ .then ~>
+ @trigger \accepted
diff --git a/src/web/app/auth/tags/index.tag b/src/web/app/auth/tags/index.tag
new file mode 100644
index 0000000000..b7017daec6
--- /dev/null
+++ b/src/web/app/auth/tags/index.tag
@@ -0,0 +1,129 @@
+mk-index
+ main(if={ SIGNIN })
+ p.fetching(if={ fetching })
+ | 読み込み中
+ mk-ellipsis
+ mk-form@form(if={ state == null && !fetching }, session={ session })
+ div.denied(if={ state == 'denied' })
+ h1 アプリケーションの連携をキャンセルしました。
+ p このアプリがあなたのアカウントにアクセスすることはありません。
+ div.accepted(if={ state == 'accepted' })
+ h1 { session.app.is_authorized ? 'このアプリは既に連携済みです' : 'アプリケーションの連携を許可しました'}
+ p(if={ session.app.callback_url })
+ | アプリケーションに戻っています
+ mk-ellipsis
+ p(if={ !session.app.callback_url }) アプリケーションに戻って、やっていってください。
+ div.error(if={ state == 'fetch-session-error' })
+ p セッションが存在しません。
+ main.signin(if={ !SIGNIN })
+ h1 サインインしてください
+ mk-signin
+ footer
+ img(src='/_/resources/auth/logo.svg', alt='Misskey')
+
+style.
+ display block
+
+ > main
+ width 100%
+ max-width 500px
+ margin 0 auto
+ text-align center
+ background #fff
+ box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2)
+
+ > .fetching
+ margin 0
+ padding 32px
+ color #555
+
+ > div
+ padding 64px
+
+ > h1
+ margin 0 0 8px 0
+ padding 0
+ font-size 20px
+ font-weight normal
+
+ > p
+ margin 0
+ color #555
+
+ &.denied > h1
+ color #e65050
+
+ &.accepted > h1
+ color #50bbe6
+
+ &.signin
+ padding 32px 32px 16px 32px
+
+ > h1
+ margin 0 0 22px 0
+ padding 0
+ font-size 20px
+ font-weight normal
+ color #555
+
+ @media (max-width 600px)
+ max-width none
+ box-shadow none
+
+ @media (max-width 500px)
+ > div
+ > h1
+ font-size 16px
+
+ > footer
+ > img
+ display block
+ width 64px
+ height 64px
+ margin 0 auto
+
+script.
+ @mixin \i
+ @mixin \api
+
+ @state = null
+ @fetching = true
+
+ @token = window.location.href.split \/ .pop!
+
+ @on \mount ~>
+ if not @SIGNIN then return
+
+ # Fetch session
+ @api \auth/session/show do
+ token: @token
+ .then (session) ~>
+ @session = session
+ @fetching = false
+
+ # 既に連携していた場合
+ if @session.app.is_authorized
+ @api \auth/accept do
+ token: @session.token
+ .then ~>
+ @accepted!
+ else
+ @update!
+
+ @refs.form.on \denied ~>
+ @state = \denied
+ @update!
+
+ @refs.form.on \accepted @accepted
+
+ .catch (error) ~>
+ @fetching = false
+ @state = \fetch-session-error
+ @update!
+
+ @accepted = ~>
+ @state = \accepted
+ @update!
+
+ if @session.app.callback_url
+ location.href = @session.app.callback_url + '?token=' + @session.token
diff --git a/src/web/app/auth/view.pug b/src/web/app/auth/view.pug
new file mode 100644
index 0000000000..a7b9f9263b
--- /dev/null
+++ b/src/web/app/auth/view.pug
@@ -0,0 +1,6 @@
+extends ../base
+
+block head
+ meta(name='viewport', content='width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no')
+ link(rel='stylesheet', href='/_/resources/auth/style.css')
+ script(src='/_/resources/auth/script.js', async, defer)
diff --git a/src/web/app/base.pug b/src/web/app/base.pug
new file mode 100644
index 0000000000..805feaee6c
--- /dev/null
+++ b/src/web/app/base.pug
@@ -0,0 +1,23 @@
+doctype html
+
+!= '\r\n<!-- Thank you for using Misskey! @syuilo -->\r\n'
+
+html(lang='ja', dir='ltr')
+
+ head
+ meta(charset='utf-8')
+ meta(name='application-name', content='Misskey')
+ meta(name='theme-color', content= themeColor)
+ meta(name='referrer', content='origin')
+ title Misskey
+ style
+ include ./../../../built/web/resources/init.css
+ script(src='https://use.fontawesome.com/22aba0df4f.js', async)
+ block head
+
+ body
+ noscript: div: p JavaScriptを有効にしてください
+ div#init: p
+ span .
+ span .
+ span .
diff --git a/src/web/app/base.styl b/src/web/app/base.styl
new file mode 100644
index 0000000000..5eab205480
--- /dev/null
+++ b/src/web/app/base.styl
@@ -0,0 +1,118 @@
+@charset 'utf-8'
+
+$theme-color = convert(themeColor)
+$theme-color-foreground = convert(themeColorForeground)
+
+@import './reset'
+
+/*
+ ::selection
+ background $theme-color
+ color #fff
+*/
+
+*
+ tap-highlight-color rgba($theme-color, 0.7)
+ -webkit-tap-highlight-color rgba($theme-color, 0.7)
+
+html, body
+ margin 0
+ padding 0
+ scroll-behavior smooth
+ text-size-adjust 100%
+ font-family sans-serif
+
+html
+ &.progress
+ &, *
+ cursor progress !important
+
+#error
+ position fixed
+ z-index 32768
+ top 0
+ left 0
+ width 100%
+ height 100%
+ background #00f
+ color #fff
+
+ > p
+ text-align center
+
+#nprogress
+ pointer-events none
+
+ position absolute
+ z-index 65536
+
+ .bar
+ background $theme-color
+
+ position fixed
+ z-index 65537
+ top 0
+ left 0
+
+ width 100%
+ height 2px
+
+ /* Fancy blur effect */
+ .peg
+ display block
+ position absolute
+ right 0px
+ width 100px
+ height 100%
+ box-shadow 0 0 10px $theme-color, 0 0 5px $theme-color
+ opacity 1
+
+ transform rotate(3deg) translate(0px, -4px)
+
+#wait
+ display block
+ position fixed
+ z-index 65537
+ top 15px
+ right 15px
+
+ &:before
+ content ""
+ display block
+ width 18px
+ height 18px
+ box-sizing border-box
+
+ border solid 2px transparent
+ border-top-color $theme-color
+ border-left-color $theme-color
+ border-radius 50%
+
+ animation progress-spinner 400ms linear infinite
+
+ @keyframes progress-spinner
+ 0%
+ transform rotate(0deg)
+ 100%
+ transform rotate(360deg)
+
+a
+ text-decoration none
+ color $theme-color
+ cursor pointer
+
+ &:hover
+ text-decoration underline
+
+ *
+ cursor pointer
+
+mk-locker
+ display block
+ position fixed
+ top 0
+ left 0
+ z-index 65536
+ width 100%
+ height 100%
+ cursor wait
diff --git a/src/web/app/boot.ls b/src/web/app/boot.ls
new file mode 100644
index 0000000000..d1230f8f0b
--- /dev/null
+++ b/src/web/app/boot.ls
@@ -0,0 +1,154 @@
+#================================
+# MISSKEY BOOT LOADER
+#
+# Misskeyを起動します。
+# 1. 初期化
+# 2. ユーザー取得(ログインしていれば)
+# 3. アプリケーションをマウント
+#================================
+
+# LOAD DEPENDENCIES
+#--------------------------------
+
+riot = require \riot
+require \velocity
+log = require './common/scripts/log.ls'
+api = require './common/scripts/api.ls'
+signout = require './common/scripts/signout.ls'
+generate-default-userdata = require './common/scripts/generate-default-userdata.ls'
+mixins = require './common/mixins.ls'
+check-for-update = require './common/scripts/check-for-update.ls'
+require './common/tags.ls'
+
+# MISSKEY ENTORY POINT
+#--------------------------------
+
+# for subdomains
+document.domain = CONFIG.host
+
+# ↓ iOS待ちPolyfill (SEE: http://caniuse.com/#feat=fetch)
+require \fetch
+
+# ↓ NodeList、HTMLCollectionで forEach を使えるようにする
+if NodeList.prototype.for-each == undefined
+ NodeList.prototype.for-each = Array.prototype.for-each
+if HTMLCollection.prototype.for-each == undefined
+ HTMLCollection.prototype.for-each = Array.prototype.for-each
+
+# ↓ iOSでプライベートモードだとlocalStorageが使えないので既存のメソッドを上書きする
+try
+ local-storage.set-item \kyoppie \yuppie
+catch e
+ Storage.prototype.set-item = ~> # noop
+
+# MAIN PROCESS
+#--------------------------------
+
+log "Misskey (aoi) v:#{VERSION}"
+
+# Check for Update
+check-for-update!
+
+# Get token from cookie
+i = ((document.cookie.match /i=(\w+)/) || [null null]).1
+
+if i? then log "ME: #{i}"
+
+# ユーザーをフェッチしてコールバックする
+module.exports = (callback) ~>
+ # Get cached account data
+ cached-me = JSON.parse local-storage.get-item \me
+
+ if cached-me?.data?.cache
+ fetched cached-me
+
+ # 後から新鮮なデータをフェッチ
+ fetchme i, true, (fresh-data) ~>
+ Object.assign cached-me, fresh-data
+ cached-me.trigger \updated
+ else
+ # キャッシュ無効なのにキャッシュが残ってたら掃除
+ if cached-me?
+ local-storage.remove-item \me
+
+ fetchme i, false, fetched
+
+ function fetched me
+
+ if me?
+ riot.observable me
+
+ if me.data.cache
+ local-storage.set-item \me JSON.stringify me
+
+ me.on \updated ~>
+ # キャッシュ更新
+ local-storage.set-item \me JSON.stringify me
+
+ log "Fetched! Hello #{me.username}."
+
+ # activate mixins
+ mixins me
+
+ # destroy loading screen
+ init = document.get-element-by-id \init
+ init.parent-node.remove-child init
+
+ # set main element
+ document.create-element \div
+ ..set-attribute \id \app
+ .. |> document.body.append-child
+
+ # Call main proccess
+ try
+ callback me
+ catch error
+ panic error
+
+# ユーザーをフェッチしてコールバックする
+function fetchme token, silent, cb
+ me = null
+
+ # Return when not signed in
+ if not token? then return done!
+
+ # Fetch user
+ fetch "#{CONFIG.api.url}/i" do
+ method: \POST
+ headers:
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8'
+ body: "i=#token"
+ .then (res) ~>
+ # When failed to authenticate user
+ if res.status != 200 then signout!
+
+ i <~ res.json!.then
+ me := i
+ me.token = token
+
+ # initialize it if user data is empty
+ if me.data? then done! else init!
+ .catch ~>
+ if not silent
+ info = document.create-element \mk-core-error
+ |> document.body.append-child
+ riot.mount info, do
+ retry: ~> fetchme token, false, cb
+ else
+ # noop
+
+ function done
+ if cb? then cb me
+
+ function init
+ data = generate-default-userdata!
+
+ api token, \i/appdata/set do
+ data: JSON.stringify data
+ .then ~>
+ me.data = data
+ done!
+
+function panic e
+ console.error e
+ document.body.innerHTML = '<div id="error"><p>致命的な問題が発生しました。</p></div>'
diff --git a/src/web/app/client/script.js b/src/web/app/client/script.js
new file mode 100644
index 0000000000..d8531e9cc8
--- /dev/null
+++ b/src/web/app/client/script.js
@@ -0,0 +1,40 @@
+const head = document.getElementsByTagName('head')[0];
+const ua = navigator.userAgent.toLowerCase();
+const isMobile = /mobile|iphone|ipad|android/.test(ua);
+
+if (isMobile) {
+ mountMobile();
+} else {
+ mountDesktop();
+}
+
+function mountDesktop() {
+ const style = document.createElement('link');
+ style.setAttribute('href', '/_/resources/desktop/style.css');
+ style.setAttribute('rel', 'stylesheet');
+ head.appendChild(style);
+
+ const script = document.createElement('script');
+ script.setAttribute('src', '/_/resources/desktop/script.js');
+ script.setAttribute('async', 'true');
+ script.setAttribute('defer', 'true');
+ head.appendChild(script);
+}
+
+function mountMobile() {
+ const meta = document.createElement('meta');
+ meta.setAttribute('name', 'viewport');
+ meta.setAttribute('content', 'width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no');
+ head.appendChild(meta);
+
+ const style = document.createElement('link');
+ style.setAttribute('href', '/_/resources/mobile/style.css');
+ style.setAttribute('rel', 'stylesheet');
+ head.appendChild(style);
+
+ const script = document.createElement('script');
+ script.setAttribute('src', '/_/resources/mobile/script.js');
+ script.setAttribute('async', 'true');
+ script.setAttribute('defer', 'true');
+ head.appendChild(script);
+}
diff --git a/src/web/app/client/view.pug b/src/web/app/client/view.pug
new file mode 100644
index 0000000000..6f0ff3cd5c
--- /dev/null
+++ b/src/web/app/client/view.pug
@@ -0,0 +1,5 @@
+extends ../base
+
+block head
+ script
+ include ./../../../../built/web/resources/client/script.js
diff --git a/src/web/app/common/mixins.ls b/src/web/app/common/mixins.ls
new file mode 100644
index 0000000000..1320cacd1e
--- /dev/null
+++ b/src/web/app/common/mixins.ls
@@ -0,0 +1,40 @@
+riot = require \riot
+
+module.exports = (me) ~>
+ i = if me? then me.token else null
+
+ (require './scripts/i.ls') me
+
+ riot.mixin \api do
+ api: (require './scripts/api.ls').bind null i
+
+ riot.mixin \cropper do
+ Cropper: require \cropper
+
+ riot.mixin \signout do
+ signout: require './scripts/signout.ls'
+
+ riot.mixin \messaging-stream do
+ MessagingStreamConnection: require './scripts/messaging-stream.ls'
+
+ riot.mixin \is-promise do
+ is-promise: require './scripts/is-promise.ls'
+
+ riot.mixin \get-post-summary do
+ get-post-summary: require './scripts/get-post-summary.ls'
+
+ riot.mixin \date-stringify do
+ date-stringify: require './scripts/date-stringify.ls'
+
+ riot.mixin \text do
+ analyze: require 'misskey-text'
+ compile: require './scripts/text-compiler.js'
+
+ riot.mixin \get-password-strength do
+ get-password-strength: require 'strength.js'
+
+ riot.mixin \ui-progress do
+ Progress: require './scripts/loading.ls'
+
+ riot.mixin \bytes-to-size do
+ bytes-to-size: require './scripts/bytes-to-size.js'
diff --git a/src/web/app/common/pages/about/base.pug b/src/web/app/common/pages/about/base.pug
new file mode 100644
index 0000000000..0bac19ee2b
--- /dev/null
+++ b/src/web/app/common/pages/about/base.pug
@@ -0,0 +1,13 @@
+extends ../../../base
+
+block head
+ link(rel='stylesheet', href='/_/resources/common/pages/about/style.css')
+ script(src='/_/resources/common/pages/about/script.js', async, defer)
+
+block body
+ article
+ header
+ h1
+ block header
+ div.body
+ block content
diff --git a/src/web/app/common/pages/about/pages/staff.pug b/src/web/app/common/pages/about/pages/staff.pug
new file mode 100644
index 0000000000..dfdf015a3d
--- /dev/null
+++ b/src/web/app/common/pages/about/pages/staff.pug
@@ -0,0 +1,13 @@
+extends ../base
+
+block title
+ | スタッフ | Misskey
+
+block header
+ | スタッフ
+
+block content
+ div.members
+ div.member
+ p しゅいろ
+ p 統括、設計、グラフィックデザイン、プログラム \ No newline at end of file
diff --git a/src/web/app/common/scripts/api.ls b/src/web/app/common/scripts/api.ls
new file mode 100644
index 0000000000..0656a56168
--- /dev/null
+++ b/src/web/app/common/scripts/api.ls
@@ -0,0 +1,67 @@
+riot = require \riot
+
+spinner = null
+pending = 0
+
+net = riot.observable!
+
+riot.mixin \net do
+ net: net
+
+log = (riot.mixin \log).log
+
+module.exports = (i, endpoint, data) ->
+ pending++
+
+ if i? and typeof i == \object then i = i.token
+
+ body = []
+
+ # append user token when signed in
+ if i? then body.push "i=#i"
+
+ for k, v of data
+ if v != undefined
+ v = encodeURIComponent v
+ body.push "#k=#v"
+
+ opts =
+ method: \POST
+ headers:
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8'
+ body: body.join \&
+
+ if endpoint == \signin
+ opts.credentials = \include
+
+ ep = if (endpoint.index-of '://') > -1
+ then endpoint
+ else "#{CONFIG.api.url}/#{endpoint}"
+
+ if pending == 1
+ spinner := document.create-element \div
+ ..set-attribute \id \wait
+ document.body.append-child spinner
+
+ new Promise (resolve, reject) ->
+ timer = set-timeout ->
+ net.trigger \detected-slow-network
+ , 5000ms
+
+ log "API: #{ep}"
+
+ fetch ep, opts
+ .then (res) ->
+ pending--
+ clear-timeout timer
+ if pending == 0
+ spinner.parent-node.remove-child spinner
+
+ if res.status == 200
+ res.json!.then resolve
+ else if res.status == 204
+ resolve!
+ else
+ res.json!.then (err) ->
+ reject err.error
+ .catch reject
diff --git a/src/web/app/common/scripts/bytes-to-size.js b/src/web/app/common/scripts/bytes-to-size.js
new file mode 100644
index 0000000000..717f9ad507
--- /dev/null
+++ b/src/web/app/common/scripts/bytes-to-size.js
@@ -0,0 +1,6 @@
+module.exports = function(bytes) {
+ var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ if (bytes == 0) return '0Byte';
+ var i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
+ return Math.round(bytes / Math.pow(1024, i), 2) + sizes[i];
+}
diff --git a/src/web/app/common/scripts/check-for-update.ls b/src/web/app/common/scripts/check-for-update.ls
new file mode 100644
index 0000000000..48e250a4c7
--- /dev/null
+++ b/src/web/app/common/scripts/check-for-update.ls
@@ -0,0 +1,9 @@
+module.exports = ->
+ fetch \/api:meta
+ .then (res) ~>
+ meta <~ res.json!.then
+ if meta.commit.hash != VERSION
+ if window.confirm '新しいMisskeyのバージョンがあります。更新しますか?\r\n(このメッセージが繰り返し表示される場合は、サーバーにデータがまだ届いていない可能性があるので、少し時間を置いてから再度お試しください)'
+ location.reload true
+ .catch ~>
+ # ignore
diff --git a/src/web/app/common/scripts/date-stringify.ls b/src/web/app/common/scripts/date-stringify.ls
new file mode 100644
index 0000000000..9aa8b3e6c5
--- /dev/null
+++ b/src/web/app/common/scripts/date-stringify.ls
@@ -0,0 +1,14 @@
+module.exports = (date) ->
+ if typeof date == \string then date = new Date date
+
+ text =
+ date.get-full-year! + \年 +
+ date.get-month! + \月 +
+ date.get-date! + \日 +
+ ' ' +
+ date.get-hours! + \時 +
+ date.get-minutes! + \分 +
+ ' ' +
+ "(#{[\日 \月 \火 \水 \木 \金 \土][date.get-day!]})"
+
+ return text
diff --git a/src/web/app/common/scripts/generate-default-userdata.ls b/src/web/app/common/scripts/generate-default-userdata.ls
new file mode 100644
index 0000000000..de03e96151
--- /dev/null
+++ b/src/web/app/common/scripts/generate-default-userdata.ls
@@ -0,0 +1,27 @@
+uuid = require './uuid.js'
+
+home =
+ left: [ \profile \calendar \rss-reader \photo-stream ]
+ right: [ \broadcast \notifications \user-recommendation \donation \nav \tips ]
+
+module.exports = ~>
+ home-data = []
+
+ home.left.for-each (widget) ~>
+ home-data.push do
+ name: widget
+ id: uuid!
+ place: \left
+
+ home.right.for-each (widget) ~>
+ home-data.push do
+ name: widget
+ id: uuid!
+ place: \right
+
+ data =
+ cache: true
+ debug: false
+ home: home-data
+
+ return data
diff --git a/src/web/app/common/scripts/get-post-summary.ls b/src/web/app/common/scripts/get-post-summary.ls
new file mode 100644
index 0000000000..0150d53004
--- /dev/null
+++ b/src/web/app/common/scripts/get-post-summary.ls
@@ -0,0 +1,26 @@
+get-post-summary = (post) ~>
+ summary = if post.text? then post.text else ''
+
+ # メディアが添付されているとき
+ if post.media?
+ summary += " (#{post.media.length}枚の画像)"
+
+ # 返信のとき
+ if post.reply_to_id?
+ if post.reply_to?
+ reply-summary = get-post-summary post.reply_to
+ summary += " RE: #{reply-summary}"
+ else
+ summary += " RE: ..."
+
+ # Repostのとき
+ if post.repost_id?
+ if post.repost?
+ repost-summary = get-post-summary post.repost
+ summary += " RP: #{repost-summary}"
+ else
+ summary += " RP: ..."
+
+ return summary.trim!
+
+module.exports = get-post-summary
diff --git a/src/web/app/common/scripts/i.ls b/src/web/app/common/scripts/i.ls
new file mode 100644
index 0000000000..5f3c016f8a
--- /dev/null
+++ b/src/web/app/common/scripts/i.ls
@@ -0,0 +1,16 @@
+riot = require \riot
+
+module.exports = (me) ->
+ riot.mixin \i do
+ init: ->
+ @I = me
+ @SIGNIN = me?
+
+ if @SIGNIN
+ @on \mount ~> me.on \updated @update
+ @on \unmount ~> me.off \updated @update
+
+ update-i: (data) ->
+ if data?
+ Object.assign me, data
+ me.trigger \updated
diff --git a/src/web/app/common/scripts/is-promise.ls b/src/web/app/common/scripts/is-promise.ls
new file mode 100644
index 0000000000..e3c7adff85
--- /dev/null
+++ b/src/web/app/common/scripts/is-promise.ls
@@ -0,0 +1 @@
+module.exports = (x) -> typeof x.then == \function
diff --git a/src/web/app/common/scripts/loading.ls b/src/web/app/common/scripts/loading.ls
new file mode 100644
index 0000000000..ed791b21ac
--- /dev/null
+++ b/src/web/app/common/scripts/loading.ls
@@ -0,0 +1,16 @@
+NProgress = require 'NProgress'
+NProgress.configure do
+ trickle-speed: 500ms
+ show-spinner: false
+
+root = document.get-elements-by-tag-name \html .0
+
+module.exports =
+ start: ~>
+ root.class-list.add \progress
+ NProgress.start!
+ done: ~>
+ root.class-list.remove \progress
+ NProgress.done!
+ set: (val) ~>
+ NProgress.set val
diff --git a/src/web/app/common/scripts/log.ls b/src/web/app/common/scripts/log.ls
new file mode 100644
index 0000000000..6e1e3735d8
--- /dev/null
+++ b/src/web/app/common/scripts/log.ls
@@ -0,0 +1,18 @@
+riot = require \riot
+
+logs = []
+
+ev = riot.observable!
+
+function log(msg)
+ logs.push do
+ date: new Date!
+ message: msg
+ ev.trigger \log
+
+riot.mixin \log do
+ logs: logs
+ log: log
+ log-event: ev
+
+module.exports = log
diff --git a/src/web/app/common/scripts/messaging-stream.ls b/src/web/app/common/scripts/messaging-stream.ls
new file mode 100644
index 0000000000..298285dc93
--- /dev/null
+++ b/src/web/app/common/scripts/messaging-stream.ls
@@ -0,0 +1,34 @@
+# Stream
+#================================
+
+ReconnectingWebSocket = require 'reconnecting-websocket'
+riot = require 'riot'
+
+class Connection
+ (me, otherparty) ~>
+ @event = riot.observable!
+ @me = me
+ host = CONFIG.api.url.replace \http \ws
+ @socket = new ReconnectingWebSocket "#{host}/messaging?otherparty=#{otherparty}"
+
+ @socket.add-event-listener \open @on-open
+ @socket.add-event-listener \message @on-message
+
+ on-open: ~>
+ @socket.send JSON.stringify do
+ i: @me.token
+
+ on-message: (message) ~>
+ try
+ message = JSON.parse message.data
+ if message.type?
+ @event.trigger message.type, message.body
+ catch
+ # ignore
+
+ close: ~>
+ @socket.remove-event-listener \open @on-open
+ @socket.remove-event-listener \message @on-message
+ @socket.close!
+
+module.exports = Connection
diff --git a/src/web/app/common/scripts/signout.ls b/src/web/app/common/scripts/signout.ls
new file mode 100644
index 0000000000..a647922678
--- /dev/null
+++ b/src/web/app/common/scripts/signout.ls
@@ -0,0 +1,4 @@
+module.exports = ->
+ local-storage.remove-item \me
+ document.cookie = "i=; domain=.#{CONFIG.host}; expires=Thu, 01 Jan 1970 00:00:01 GMT;"
+ location.href = \/
diff --git a/src/web/app/common/scripts/stream.ls b/src/web/app/common/scripts/stream.ls
new file mode 100644
index 0000000000..534048248f
--- /dev/null
+++ b/src/web/app/common/scripts/stream.ls
@@ -0,0 +1,42 @@
+# Stream
+#================================
+
+ReconnectingWebSocket = require \reconnecting-websocket
+riot = require \riot
+
+module.exports = (me) ~>
+ state = \initializing
+ state-ev = riot.observable!
+ event = riot.observable!
+
+ socket = new ReconnectingWebSocket CONFIG.api.url.replace \http \ws
+
+ socket.onopen = ~>
+ state := \connected
+ state-ev.trigger \connected
+ socket.send JSON.stringify do
+ i: me.token
+
+ socket.onclose = ~>
+ state := \reconnecting
+ state-ev.trigger \closed
+
+ socket.onmessage = (message) ~>
+ try
+ message = JSON.parse message.data
+ if message.type?
+ event.trigger message.type, message.body
+ catch
+ # ignore
+
+ get-state = ~> state
+
+ event.on \i_updated (data) ~>
+ Object.assign me, data
+ me.trigger \updated
+
+ {
+ state-ev
+ get-state
+ event
+ }
diff --git a/src/web/app/common/scripts/text-compiler.js b/src/web/app/common/scripts/text-compiler.js
new file mode 100644
index 0000000000..9915e3335f
--- /dev/null
+++ b/src/web/app/common/scripts/text-compiler.js
@@ -0,0 +1,30 @@
+module.exports = function(tokens, canBreak, escape) {
+ if (canBreak == null) {
+ canBreak = true;
+ }
+ if (escape == null) {
+ escape = true;
+ }
+ return tokens.map(function(token) {
+ switch (token.type) {
+ case 'text':
+ if (escape) {
+ return token.content
+ .replace(/>/g, '&gt;')
+ .replace(/</g, '&lt;')
+ .replace(/(\r\n|\n|\r)/g, canBreak ? '<br>' : ' ');
+ } else {
+ return token.content
+ .replace(/(\r\n|\n|\r)/g, canBreak ? '<br>' : ' ');
+ }
+ case 'bold':
+ return '<strong>' + token.bold + '</strong>';
+ case 'link':
+ return '<mk-url href="' + token.content + '" target="_blank"></mk-url>';
+ case 'mention':
+ return '<a href="' + CONFIG.url + '/' + token.username + '" target="_blank" data-user-preview="' + token.content + '">' + token.content + '</a>';
+ case 'hashtag': // TODO
+ return '<a>' + token.content + '</a>';
+ }
+ }).join('');
+}
diff --git a/src/web/app/common/scripts/uuid.js b/src/web/app/common/scripts/uuid.js
new file mode 100644
index 0000000000..6161190d63
--- /dev/null
+++ b/src/web/app/common/scripts/uuid.js
@@ -0,0 +1,12 @@
+module.exports = function () {
+ var uuid = '', i, random;
+ for (i = 0; i < 32; i++) {
+ random = Math.random() * 16 | 0;
+
+ if (i == 8 || i == 12 || i == 16 || i == 20) {
+ uuid += '-'
+ }
+ uuid += (i == 12 ? 4 : (i == 16 ? (random & 3 | 8) : random)).toString(16);
+ }
+ return uuid;
+}
diff --git a/src/web/app/common/tags.ls b/src/web/app/common/tags.ls
new file mode 100644
index 0000000000..fe71a7bb37
--- /dev/null
+++ b/src/web/app/common/tags.ls
@@ -0,0 +1,16 @@
+require './tags/core-error.tag'
+require './tags/url.tag'
+require './tags/url-preview.tag'
+require './tags/ripple-string.tag'
+require './tags/time.tag'
+require './tags/file-type-icon.tag'
+require './tags/uploader.tag'
+require './tags/ellipsis.tag'
+require './tags/raw.tag'
+require './tags/number.tag'
+require './tags/special-message.tag'
+require './tags/signin.tag'
+require './tags/signup.tag'
+require './tags/forkit.tag'
+require './tags/introduction.tag'
+require './tags/copyright.tag'
diff --git a/src/web/app/common/tags/copyright.tag b/src/web/app/common/tags/copyright.tag
new file mode 100644
index 0000000000..74acae4df7
--- /dev/null
+++ b/src/web/app/common/tags/copyright.tag
@@ -0,0 +1,5 @@
+mk-copyright
+ span (c) syuilo 2014-2016
+
+style.
+ display block
diff --git a/src/web/app/common/tags/core-error.tag b/src/web/app/common/tags/core-error.tag
new file mode 100644
index 0000000000..19ef68bea6
--- /dev/null
+++ b/src/web/app/common/tags/core-error.tag
@@ -0,0 +1,63 @@
+mk-core-error
+ //i: i.fa.fa-times-circle
+ img(src='/_/resources/error.jpg', alt='')
+ h1: mk-ripple-string サーバーに接続できません
+ p.text
+ | インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから
+ a(onclick={ retry }) 再度お試し
+ | ください。
+ p.thanks いつもMisskeyをご利用いただきありがとうございます。
+
+style.
+ position fixed
+ z-index 16385
+ top 0
+ left 0
+ width 100%
+ height 100%
+ text-align center
+ background #f8f8f8
+
+ > i
+ display block
+ margin-top 64px
+ font-size 5em
+ color #6998a0
+
+ > img
+ display block
+ height 200px
+ margin 64px auto 0 auto
+ pointer-events none
+ -ms-user-select none
+ -moz-user-select none
+ -webkit-user-select none
+ user-select none
+
+ > h1
+ display block
+ margin 32px auto 16px auto
+ font-size 1.5em
+ color #555
+
+ > .text
+ display block
+ margin 0 auto
+ max-width 600px
+ font-size 1em
+ color #666
+
+ > .thanks
+ display block
+ margin 32px auto 0 auto
+ padding 32px 0 32px 0
+ max-width 600px
+ font-size 0.9em
+ font-style oblique
+ color #aaa
+ border-top solid 1px #eee
+
+script.
+ @retry = ~>
+ @unmount!
+ @opts.retry!
diff --git a/src/web/app/common/tags/ellipsis.tag b/src/web/app/common/tags/ellipsis.tag
new file mode 100644
index 0000000000..47eca62acd
--- /dev/null
+++ b/src/web/app/common/tags/ellipsis.tag
@@ -0,0 +1,25 @@
+mk-ellipsis
+ span .
+ span .
+ span .
+
+style.
+ display inline
+
+ > span
+ animation ellipsis 1.4s infinite ease-in-out both
+
+ &:nth-child(1)
+ animation-delay 0s
+
+ &:nth-child(2)
+ animation-delay 0.16s
+
+ &:nth-child(3)
+ animation-delay 0.32s
+
+ @keyframes ellipsis
+ 0%, 80%, 100%
+ opacity 1
+ 40%
+ opacity 0
diff --git a/src/web/app/common/tags/file-type-icon.tag b/src/web/app/common/tags/file-type-icon.tag
new file mode 100644
index 0000000000..68b8f95ad7
--- /dev/null
+++ b/src/web/app/common/tags/file-type-icon.tag
@@ -0,0 +1,9 @@
+mk-file-type-icon
+ i.fa.fa-file-image-o(if={ kind == 'image' })
+
+style.
+ display inline
+
+script.
+ @file = @opts.file
+ @kind = @file.type.split \/ .0
diff --git a/src/web/app/common/tags/forkit.tag b/src/web/app/common/tags/forkit.tag
new file mode 100644
index 0000000000..7205fbe76b
--- /dev/null
+++ b/src/web/app/common/tags/forkit.tag
@@ -0,0 +1,37 @@
+mk-forkit
+ a(href='https://github.com/syuilo/misskey', target='_blank', title='View source on Github', aria-label='View source on Github')
+ svg(width='80', height='80', viewBox='0 0 250 250', aria-hidden)
+ path(d='M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z')
+ path.octo-arm(d='M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2', fill='currentColor')
+ path(d='M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z', fill='currentColor')
+
+style.
+ display block
+ position absolute
+ top 0
+ right 0
+
+ > a
+ display block
+
+ > svg
+ display block
+ //fill #151513
+ //color #fff
+ fill $theme-color
+ color $theme-color-foreground
+
+ .octo-arm
+ transform-origin 130px 106px
+
+ &:hover
+ .octo-arm
+ animation octocat-wave 560ms ease-in-out
+
+ @keyframes octocat-wave
+ 0%, 100%
+ transform rotate(0)
+ 20%, 60%
+ transform rotate(-25deg)
+ 40%, 80%
+ transform rotate(10deg)
diff --git a/src/web/app/common/tags/introduction.tag b/src/web/app/common/tags/introduction.tag
new file mode 100644
index 0000000000..962f195cca
--- /dev/null
+++ b/src/web/app/common/tags/introduction.tag
@@ -0,0 +1,22 @@
+mk-introduction
+ article
+ h1 Misskeyとは?
+ <p><ruby>Misskey<rt>みすきー</rt></ruby>は、<a href="http://syuilo.com" target="_blank">syuilo</a>が2014年くらいから<a href="https://github.com/syuilo" target="_blank">オープンソースで</a>開発・運営を行っている、ミニブログベースのSNSです。</p>
+ <p>Twitter, Facebook, LINE, Google+ などを<del>パクって</del><i>参考にして</i>います。</p>
+ <p>無料で誰でも利用でき、広告なども一切ありません。</p>
+ <p><a href={ CONFIG.urls.about } target="_blank">もっと知りたい方はこちら</a></p>
+
+style.
+ display block
+
+ h1
+ margin 0
+ text-align center
+ font-size 1.2em
+
+ p
+ margin 16px 0
+
+ &:last-child
+ margin 0
+ text-align center
diff --git a/src/web/app/common/tags/number.tag b/src/web/app/common/tags/number.tag
new file mode 100644
index 0000000000..589c747b35
--- /dev/null
+++ b/src/web/app/common/tags/number.tag
@@ -0,0 +1,15 @@
+mk-number
+
+style.
+ display inline
+
+script.
+ @on \mount ~>
+ # バグ? https://github.com/riot/riot/issues/2103
+ #value = @opts.value
+ value = @opts.riot-value
+ max = @opts.max
+
+ if max? then if value > max then value = max
+
+ @root.innerHTML = value.to-locale-string!
diff --git a/src/web/app/common/tags/raw.tag b/src/web/app/common/tags/raw.tag
new file mode 100644
index 0000000000..131826e597
--- /dev/null
+++ b/src/web/app/common/tags/raw.tag
@@ -0,0 +1,7 @@
+mk-raw
+
+style.
+ display inline
+
+script.
+ @root.innerHTML = @opts.content
diff --git a/src/web/app/common/tags/ripple-string.tag b/src/web/app/common/tags/ripple-string.tag
new file mode 100644
index 0000000000..3be6903369
--- /dev/null
+++ b/src/web/app/common/tags/ripple-string.tag
@@ -0,0 +1,24 @@
+mk-ripple-string
+ <yield/>
+
+style.
+ display inline
+
+ > span
+ animation ripple-string 5s infinite ease-in-out both
+
+ @keyframes ripple-string
+ 0%, 50%, 100%
+ opacity 1
+ 25%
+ opacity 0.5
+
+script.
+ @on \mount ~>
+ text = @root.innerHTML
+ @root.innerHTML = ''
+ (text.split '').for-each (c, i) ~>
+ ce = document.create-element \span
+ ce.innerHTML = c
+ ce.style.animation-delay = (i / 10) + 's'
+ @root.append-child ce
diff --git a/src/web/app/common/tags/signin.tag b/src/web/app/common/tags/signin.tag
new file mode 100644
index 0000000000..6f4013b1cb
--- /dev/null
+++ b/src/web/app/common/tags/signin.tag
@@ -0,0 +1,136 @@
+mk-signin
+ form(onsubmit={ onsubmit }, class={ signing: signing })
+ label.user-name
+ input@username(
+ type='text'
+ pattern='^[a-zA-Z0-9\-]+$'
+ placeholder='ユーザー名'
+ autofocus
+ required
+ oninput={ oninput })
+ i.fa.fa-at
+ label.password
+ input@password(
+ type='password'
+ placeholder='パスワード'
+ required)
+ i.fa.fa-lock
+ button(type='submit', disabled={ signing }) { signing ? 'やっています...' : 'サインイン' }
+
+style.
+ display block
+
+ > form
+ display block
+ z-index 2
+
+ &.signing
+ &, *
+ cursor wait !important
+
+ label
+ display block
+ margin 12px 0
+
+ i
+ display block
+ pointer-events none
+ position absolute
+ bottom 0
+ top 0
+ left 0
+ z-index 1
+ margin auto
+ padding 0 16px
+ height 1em
+ color #898786
+
+ input[type=text]
+ input[type=password]
+ user-select text
+ display inline-block
+ cursor auto
+ padding 0 0 0 38px
+ margin 0
+ width 100%
+ line-height 44px
+ font-size 1em
+ color rgba(0, 0, 0, 0.7)
+ background #fff
+ outline none
+ border solid 1px #eee
+ border-radius 4px
+
+ &:hover
+ background rgba(255, 255, 255, 0.7)
+ border-color #ddd
+
+ & + i
+ color #797776
+
+ &:focus
+ background #fff
+ border-color #ccc
+
+ & + i
+ color #797776
+
+ [type=submit]
+ cursor pointer
+ padding 16px
+ margin -6px 0 0 0
+ width 100%
+ font-size 1.2em
+ color rgba(0, 0, 0, 0.5)
+ outline none
+ border none
+ border-radius 0
+ background transparent
+ transition all .5s ease
+
+ &:hover
+ color $theme-color
+ transition all .2s ease
+
+ &:focus
+ color $theme-color
+ transition all .2s ease
+
+ &:active
+ color darken($theme-color, 30%)
+ transition all .2s ease
+
+ &:disabled
+ opacity 0.7
+
+script.
+ @mixin \api
+
+ @user = null
+ @signing = false
+
+ @oninput = ~>
+ @api \users/show do
+ username: @refs.username.value
+ .then (user) ~>
+ @user = user
+ @trigger \user user
+ @update!
+
+ @onsubmit = (e) ~>
+ e.prevent-default!
+
+ @signing = true
+ @update!
+
+ @api \signin do
+ username: @refs.username.value
+ password: @refs.password.value
+ .then ~>
+ location.reload!
+ .catch ~>
+ alert 'something happened'
+ @signing = false
+ @update!
+
+ false
diff --git a/src/web/app/common/tags/signup.tag b/src/web/app/common/tags/signup.tag
new file mode 100644
index 0000000000..730f00fb4e
--- /dev/null
+++ b/src/web/app/common/tags/signup.tag
@@ -0,0 +1,352 @@
+mk-signup
+ form(onsubmit={ onsubmit }, autocomplete='off')
+ label.username
+ p.caption
+ i.fa.fa-at
+ | ユーザー名
+ input@username(
+ type='text'
+ pattern='^[a-zA-Z0-9\-]{3,20}$'
+ placeholder='a~z、A~Z、0~9、-'
+ autocomplete='off'
+ required
+ onkeyup={ on-change-username })
+
+ p.profile-page-url-preview(if={ refs.username.value != '' && username-state != 'invalid-format' && username-state != 'min-range' && username-state != 'max-range' }) { CONFIG.url + '/' + refs.username.value }
+
+ p.info(if={ username-state == 'wait' }, style='color:#999')
+ i.fa.fa-fw.fa-spinner.fa-pulse
+ | 確認しています...
+ p.info(if={ username-state == 'ok' }, style='color:#3CB7B5')
+ i.fa.fa-fw.fa-check
+ | 利用できます
+ p.info(if={ username-state == 'unavailable' }, style='color:#FF1161')
+ i.fa.fa-fw.fa-exclamation-triangle
+ | 既に利用されています
+ p.info(if={ username-state == 'error' }, style='color:#FF1161')
+ i.fa.fa-fw.fa-exclamation-triangle
+ | 通信エラー
+ p.info(if={ username-state == 'invalid-format' }, style='color:#FF1161')
+ i.fa.fa-fw.fa-exclamation-triangle
+ | a~z、A~Z、0~9、-(ハイフン)が使えます
+ p.info(if={ username-state == 'min-range' }, style='color:#FF1161')
+ i.fa.fa-fw.fa-exclamation-triangle
+ | 3文字以上でお願いします!
+ p.info(if={ username-state == 'max-range' }, style='color:#FF1161')
+ i.fa.fa-fw.fa-exclamation-triangle
+ | 20文字以内でお願いします
+
+ label.password
+ p.caption
+ i.fa.fa-lock
+ | パスワード
+ input@password(
+ type='password'
+ placeholder='8文字以上を推奨します'
+ autocomplete='off'
+ required
+ onkeyup={ on-change-password })
+
+ div.meter(if={ password-strength != '' }, data-strength={ password-strength })
+ div.value@password-metar
+
+ p.info(if={ password-strength == 'low' }, style='color:#FF1161')
+ i.fa.fa-fw.fa-exclamation-triangle
+ | 弱いパスワード
+ p.info(if={ password-strength == 'medium' }, style='color:#3CB7B5')
+ i.fa.fa-fw.fa-check
+ | まあまあのパスワード
+ p.info(if={ password-strength == 'high' }, style='color:#3CB7B5')
+ i.fa.fa-fw.fa-check
+ | 強いパスワード
+
+ label.retype-password
+ p.caption
+ i.fa.fa-lock
+ | パスワード(再入力)
+ input@password-retype(
+ type='password'
+ placeholder='確認のため再入力してください'
+ autocomplete='off'
+ required
+ onkeyup={ on-change-password-retype })
+
+ p.info(if={ password-retype-state == 'match' }, style='color:#3CB7B5')
+ i.fa.fa-fw.fa-check
+ | 確認されました
+ p.info(if={ password-retype-state == 'not-match' }, style='color:#FF1161')
+ i.fa.fa-fw.fa-exclamation-triangle
+ | 一致していません
+
+ label.recaptcha
+ p.caption
+ i.fa.fa-toggle-on(if={ recaptchaed })
+ i.fa.fa-toggle-off(if={ !recaptchaed })
+ | 認証
+ div.g-recaptcha(
+ data-callback='onRecaptchaed'
+ data-expired-callback='onRecaptchaExpired'
+ data-sitekey={ CONFIG.recaptcha.site-key })
+
+ label.agree-tou
+ input(
+ name='agree-tou',
+ type='checkbox',
+ autocomplete='off',
+ required)
+ p
+ a() 利用規約
+ | に同意する
+
+ button(onclick={ onsubmit })
+ | アカウント作成
+
+style.
+ display block
+ min-width 302px
+ overflow hidden
+
+ > form
+
+ label
+ display block
+ margin 16px 0
+
+ > .caption
+ margin 0 0 4px 0
+ color #828888
+ font-size 0.95em
+
+ > i
+ margin-right 0.25em
+ color #96adac
+
+ > .info
+ display block
+ margin 4px 0
+ font-size 0.8em
+
+ > i
+ margin-right 0.3em
+
+ &.username
+ .profile-page-url-preview
+ display block
+ margin 4px 8px 0 4px
+ font-size 0.8em
+ color #888
+
+ &:empty
+ display none
+
+ &:not(:empty) + .info
+ margin-top 0
+
+ &.password
+ .meter
+ display block
+ margin-top 8px
+ width 100%
+ height 8px
+
+ &[data-strength='']
+ display none
+
+ &[data-strength='low']
+ > .value
+ background #d73612
+
+ &[data-strength='medium']
+ > .value
+ background #d7ca12
+
+ &[data-strength='high']
+ > .value
+ background #61bb22
+
+ > .value
+ display block
+ width 0%
+ height 100%
+ background transparent
+ border-radius 4px
+ transition all 0.1s ease
+
+ [type=text], [type=password]
+ user-select text
+ display inline-block
+ cursor auto
+ padding 0 12px
+ margin 0
+ width 100%
+ line-height 44px
+ font-size 1em
+ color #333 !important
+ background #fff !important
+ outline none
+ border solid 1px rgba(0, 0, 0, 0.1)
+ border-radius 4px
+ box-shadow 0 0 0 114514px #fff inset
+ transition all .3s ease
+
+ &:hover
+ border-color rgba(0, 0, 0, 0.2)
+ transition all .1s ease
+
+ &:focus
+ color $theme-color !important
+ border-color $theme-color
+ box-shadow 0 0 0 1024px #fff inset, 0 0 0 4px rgba($theme-color, 10%)
+ transition all 0s ease
+
+ &:disabled
+ opacity 0.5
+
+ .agree-tou
+ padding 4px
+ border-radius 4px
+
+ &:hover
+ background #f4f4f4
+
+ &:active
+ background #eee
+
+ &, *
+ cursor pointer
+
+ p
+ display inline
+ color #555
+
+ button
+ margin 0 0 32px 0
+ padding 16px
+ width 100%
+ font-size 1em
+ color #fff
+ background $theme-color
+ border-radius 3px
+
+ &:hover
+ background lighten($theme-color, 5%)
+
+ &:active
+ background darken($theme-color, 5%)
+
+script.
+ @mixin \api
+ @mixin \get-password-strength
+
+ @username-state = null
+ @password-strength = ''
+ @password-retype-state = null
+ @recaptchaed = false
+
+ window.on-recaptchaed = ~>
+ @recaptchaed = true
+ @update!
+
+ window.on-recaptcha-expired = ~>
+ @recaptchaed = false
+ @update!
+
+ @on \mount ~>
+ head = (document.get-elements-by-tag-name \head).0
+ script = document.create-element \script
+ ..set-attribute \src \https://www.google.com/recaptcha/api.js
+ head.append-child script
+
+ @on-change-username = ~>
+ username = @refs.username.value
+
+ if username == ''
+ @username-state = null
+ @update!
+ return
+
+ err = switch
+ | not username.match /^[a-zA-Z0-9\-]+$/ => \invalid-format
+ | username.length < 3chars => \min-range
+ | username.length > 20chars => \max-range
+ | _ => null
+
+ if err?
+ @username-state = err
+ @update!
+ else
+ @username-state = \wait
+ @update!
+
+ @api \username/available do
+ username: username
+ .then (result) ~>
+ if result.available
+ @username-state = \ok
+ else
+ @username-state = \unavailable
+ @update!
+ .catch (err) ~>
+ @username-state = \error
+ @update!
+
+ @on-change-password = ~>
+ password = @refs.password.value
+
+ if password == ''
+ @password-strength = ''
+ return
+
+ strength = @get-password-strength password
+
+ if strength > 0.3
+ @password-strength = \medium
+ if strength > 0.7
+ @password-strength = \high
+ else
+ @password-strength = \low
+
+ @update!
+
+ @refs.password-metar.style.width = (strength * 100) + \%
+
+ @on-change-password-retype = ~>
+ password = @refs.password.value
+ retyped-password = @refs.password-retype.value
+
+ if retyped-password == ''
+ @password-retype-state = null
+ return
+
+ if password == retyped-password
+ @password-retype-state = \match
+ else
+ @password-retype-state = \not-match
+
+ @onsubmit = (e) ~>
+ e.prevent-default!
+
+ username = @refs.username.value
+ password = @refs.password.value
+
+ locker = document.body.append-child document.create-element \mk-locker
+
+ @api \signup do
+ username: username
+ password: password
+ 'g-recaptcha-response': grecaptcha.get-response!
+ .then ~>
+ @api \signin do
+ username: username
+ password: password
+ .then ~>
+ location.href = CONFIG.url
+ .catch ~>
+ alert '何らかの原因によりアカウントの作成に失敗しました。再度お試しください。'
+
+ grecaptcha.reset!
+ @recaptchaed = false
+
+ locker.parent-node.remove-child locker
+
+ false
diff --git a/src/web/app/common/tags/special-message.tag b/src/web/app/common/tags/special-message.tag
new file mode 100644
index 0000000000..5a6d5787ea
--- /dev/null
+++ b/src/web/app/common/tags/special-message.tag
@@ -0,0 +1,24 @@
+mk-special-message
+ p(if={ m == 1 && d == 1 }) Happy New Year!
+ p(if={ m == 12 && d == 25 }) Merry Christmas!
+
+style.
+ display block
+
+ &:empty
+ display none
+
+ > p
+ margin 0
+ padding 4px
+ text-align center
+ font-size 14px
+ font-weight bold
+ text-transform uppercase
+ color #fff
+ background #ff1036
+
+script.
+ now = new Date!
+ @d = now.get-date!
+ @m = now.get-month! + 1
diff --git a/src/web/app/common/tags/time.tag b/src/web/app/common/tags/time.tag
new file mode 100644
index 0000000000..56c3b8ecc3
--- /dev/null
+++ b/src/web/app/common/tags/time.tag
@@ -0,0 +1,43 @@
+mk-time
+ time(datetime={ opts.time })
+ span(if={ mode == 'relative' }) { relative }
+ span(if={ mode == 'absolute' }) { absolute }
+ span(if={ mode == 'detail' }) { absolute } ({ relative })
+
+script.
+ @time = new Date @opts.time
+ @mode = @opts.mode || \relative
+ @tickid = null
+
+ @absolute =
+ @time.get-full-year! + \年 +
+ @time.get-month! + \月 +
+ @time.get-date! + \日 +
+ ' ' +
+ @time.get-hours! + \時 +
+ @time.get-minutes! + \分
+
+ @on \mount ~>
+ if @mode == \relative or @mode == \detail
+ @tick!
+ @tickid = set-interval @tick, 1000ms
+
+ @on \unmount ~>
+ if @mode == \relative or @mode == \detail
+ clear-interval @tickid
+
+ @tick = ~>
+ now = new Date!
+ ago = (now - @time) / 1000ms
+ @relative = switch
+ | ago >= 31536000s => ~~(ago / 31536000s) + '年前'
+ | ago >= 2592000s => ~~(ago / 2592000s) + 'ヶ月前'
+ | ago >= 604800s => ~~(ago / 604800s) + '週間前'
+ | ago >= 86400s => ~~(ago / 86400s) + '日前'
+ | ago >= 3600s => ~~(ago / 3600s) + '時間前'
+ | ago >= 60s => ~~(ago / 60s) + '分前'
+ | ago >= 10s => ~~(ago % 60s) + '秒前'
+ | ago >= 0s => 'たった今'
+ | ago < 0s => '未来'
+ | _ => 'なぞのじかん'
+ @update!
diff --git a/src/web/app/common/tags/uploader.tag b/src/web/app/common/tags/uploader.tag
new file mode 100644
index 0000000000..6d4e9b6363
--- /dev/null
+++ b/src/web/app/common/tags/uploader.tag
@@ -0,0 +1,201 @@
+mk-uploader
+ ol(if={ uploads.length > 0 })
+ li(each={ uploads })
+ div.img(style='background-image: url({ img })')
+ p.name
+ i.fa.fa-spinner.fa-pulse
+ | { name }
+ p.status
+ span.initing(if={ progress == undefined })
+ | 待機中
+ mk-ellipsis
+ span.kb(if={ progress != undefined })
+ | { String(Math.floor(progress.value / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }
+ i KB
+ = ' / '
+ | { String(Math.floor(progress.max / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }
+ i KB
+ span.percentage(if={ progress != undefined }) { Math.floor((progress.value / progress.max) * 100) }
+ progress(if={ progress != undefined && progress.value != progress.max }, value={ progress.value }, max={ progress.max })
+ div.progress.initing(if={ progress == undefined })
+ div.progress.waiting(if={ progress != undefined && progress.value == progress.max })
+
+style.
+ display block
+ overflow auto
+
+ &:empty
+ display none
+
+ > ol
+ display block
+ margin 0
+ padding 0
+ list-style none
+
+ > li
+ display block
+ margin 8px 0 0 0
+ padding 0
+ height 36px
+ box-shadow 0 -1px 0 rgba($theme-color, 0.1)
+ border-top solid 8px transparent
+
+ &:first-child
+ margin 0
+ box-shadow none
+ border-top none
+
+ > .img
+ display block
+ position absolute
+ top 0
+ left 0
+ width 36px
+ height 36px
+ background-size cover
+ background-position center center
+
+ > .name
+ display block
+ position absolute
+ top 0
+ left 44px
+ margin 0
+ padding 0
+ max-width 256px
+ font-size 0.8em
+ color rgba($theme-color, 0.7)
+ white-space nowrap
+ text-overflow ellipsis
+ overflow hidden
+
+ > i
+ margin-right 4px
+
+ > .status
+ display block
+ position absolute
+ top 0
+ right 0
+ margin 0
+ padding 0
+ font-size 0.8em
+
+ > .initing
+ color rgba($theme-color, 0.5)
+
+ > .kb
+ color rgba($theme-color, 0.5)
+
+ > .percentage
+ display inline-block
+ width 48px
+ text-align right
+
+ color rgba($theme-color, 0.7)
+
+ &:after
+ content '%'
+
+ > progress
+ display block
+ position absolute
+ bottom 0
+ right 0
+ margin 0
+ width calc(100% - 44px)
+ height 8px
+ background transparent
+ border none
+ border-radius 4px
+ overflow hidden
+
+ &::-webkit-progress-value
+ background $theme-color
+
+ &::-webkit-progress-bar
+ background rgba($theme-color, 0.1)
+
+ > .progress
+ display block
+ position absolute
+ bottom 0
+ right 0
+ margin 0
+ width calc(100% - 44px)
+ height 8px
+ border none
+ border-radius 4px
+ background linear-gradient(
+ 45deg,
+ lighten($theme-color, 30%) 25%,
+ $theme-color 25%,
+ $theme-color 50%,
+ lighten($theme-color, 30%) 50%,
+ lighten($theme-color, 30%) 75%,
+ $theme-color 75%,
+ $theme-color
+ )
+ background-size 32px 32px
+ animation bg 1.5s linear infinite
+
+ &.initing
+ opacity 0.3
+
+ @keyframes bg
+ from {background-position: 0 0;}
+ to {background-position: -64px 32px;}
+
+script.
+ @mixin \i
+
+ @uploads = []
+
+
+ @upload = (file, folder) ~>
+ id = Math.random!
+
+ ctx =
+ id: id
+ name: file.name || \untitled
+ progress: undefined
+
+ @uploads.push ctx
+ @trigger \change-uploads @uploads
+ @update!
+
+ reader = new FileReader!
+ reader.onload = (e) ~>
+ ctx.img = e.target.result
+ @update!
+ reader.read-as-data-URL file
+
+ data = new FormData!
+ data.append \i @I.token
+ data.append \file file
+
+ if folder?
+ data.append \folder_id folder
+
+ xhr = new XMLHttpRequest!
+ xhr.open \POST CONFIG.api.url + '/drive/files/create' true
+ xhr.onload = (e) ~>
+ drive-file = JSON.parse e.target.response
+
+ @trigger \uploaded drive-file
+
+ @uploads = @uploads.filter (x) -> x.id != id
+ @trigger \change-uploads @uploads
+
+ @update!
+
+ xhr.upload.onprogress = (e) ~>
+ if e.length-computable
+ if ctx.progress == undefined
+ ctx.progress = {}
+ ctx.progress.max = e.total
+ ctx.progress.value = e.loaded
+ @update!
+
+ xhr.send data
diff --git a/src/web/app/common/tags/url-preview.tag b/src/web/app/common/tags/url-preview.tag
new file mode 100644
index 0000000000..605d26bc67
--- /dev/null
+++ b/src/web/app/common/tags/url-preview.tag
@@ -0,0 +1,105 @@
+mk-url-preview
+ a(href={ url }, target='_blank', title={ url }, if={ !loading })
+ div.thumbnail(if={ thumbnail }, style={ 'background-image: url(' + thumbnail + ')' })
+ article
+ header: h1 { title }
+ p { description }
+ footer
+ img.icon(if={ icon }, src={ icon })
+ p { sitename }
+
+style.
+ display block
+ font-size 16px
+
+ > a
+ display block
+ border solid 1px #eee
+ border-radius 4px
+ overflow hidden
+
+ &:hover
+ text-decoration none
+ border-color #ddd
+
+ > article > header > h1
+ text-decoration underline
+
+ > .thumbnail
+ position absolute
+ width 100px
+ height 100%
+ background-position center
+ background-size cover
+
+ & + article
+ left 100px
+ width calc(100% - 100px)
+
+ > article
+ padding 16px
+
+ > header
+ margin-bottom 8px
+
+ > h1
+ margin 0
+ font-size 1em
+ color #555
+
+ > p
+ margin 0
+ color #777
+ font-size 0.8em
+
+ > footer
+ margin-top 8px
+
+ > img
+ display inline-block
+ width 16px
+ heigth 16px
+ margin-right 4px
+ vertical-align bottom
+
+ > p
+ display inline-block
+ margin 0
+ color #666
+ font-size 0.8em
+ line-height 16px
+
+ @media (max-width 500px)
+ font-size 8px
+
+ > a
+ border none
+
+ > .thumbnail
+ width 70px
+
+ & + article
+ left 70px
+ width calc(100% - 70px)
+
+ > article
+ padding 8px
+
+script.
+ @mixin \api
+
+ @url = @opts.url
+ @loading = true
+
+ @on \mount ~>
+ fetch CONFIG.url + '/api:url?url=' + @url
+ .then (res) ~>
+ info <~ res.json!.then
+ @title = info.title
+ @description = info.description
+ @thumbnail = info.thumbnail
+ @icon = info.icon
+ @sitename = info.sitename
+
+ @loading = false
+ @update!
diff --git a/src/web/app/common/tags/url.tag b/src/web/app/common/tags/url.tag
new file mode 100644
index 0000000000..18892e8108
--- /dev/null
+++ b/src/web/app/common/tags/url.tag
@@ -0,0 +1,50 @@
+mk-url
+ a(href={ url }, target={ opts.target })
+ span.schema { schema }//
+ span.hostname { hostname }
+ span.port(if={ port != '' }) :{ port }
+ span.pathname(if={ pathname != '' }) { pathname }
+ span.query { query }
+ span.hash { hash }
+
+style.
+ > a
+ &:after
+ content "\f14c"
+ display inline-block
+ padding-left 2px
+ font-family FontAwesome
+ font-size .9em
+ font-weight 400
+ font-style normal
+
+ > .schema
+ opacity 0.5
+
+ > .hostname
+ font-weight bold
+
+ > .pathname
+ opacity 0.8
+
+ > .query
+ opacity 0.5
+
+ > .hash
+ font-style italic
+
+script.
+ @url = @opts.href
+
+ @on \before-mount ~>
+ parser = document.create-element \a
+ parser.href = @url
+
+ @schema = parser.protocol
+ @hostname = parser.hostname
+ @port = parser.port
+ @pathname = parser.pathname
+ @query = parser.search
+ @hash = parser.hash
+
+ @update!
diff --git a/src/web/app/desktop/mixins.ls b/src/web/app/desktop/mixins.ls
new file mode 100644
index 0000000000..debd89fbd4
--- /dev/null
+++ b/src/web/app/desktop/mixins.ls
@@ -0,0 +1,47 @@
+riot = require \riot
+
+module.exports = (me) ~>
+ riot.mixin \sortable do
+ Sortable: require \Sortable
+
+ if me?
+ (require './scripts/stream.ls') me
+
+ require './scripts/user-preview.ls'
+
+ require './scripts/open-window.ls'
+
+ riot.mixin \notify do
+ notify: require './scripts/notify.ls'
+
+ dialog = require './scripts/dialog.ls'
+
+ riot.mixin \dialog do
+ dialog: dialog
+
+ riot.mixin \NotImplementedException do
+ NotImplementedException: ~>
+ dialog do
+ '<i class="fa fa-exclamation-triangle"></i>Not implemented yet'
+ '要求された操作は実装されていません。<br>→<a href="https://github.com/syuilo/misskey" target="_blank">Misskeyの開発に参加する</a>'
+ [
+ text: \OK
+ ]
+
+ riot.mixin \input-dialog do
+ input-dialog: require './scripts/input-dialog.ls'
+
+ riot.mixin \update-avatar do
+ update-avatar: require './scripts/update-avatar.ls'
+
+ riot.mixin \update-banner do
+ update-banner: require './scripts/update-banner.ls'
+
+ riot.mixin \update-wallpaper do
+ update-wallpaper: require './scripts/update-wallpaper.ls'
+
+ riot.mixin \autocomplete do
+ Autocomplete: require './scripts/autocomplete.ls'
+
+ riot.mixin \follow-scroll do
+ Follower: require './scripts/follow-scroll.ls'
diff --git a/src/web/app/desktop/resources/header-logo.svg b/src/web/app/desktop/resources/header-logo.svg
new file mode 100644
index 0000000000..19b8a2737e
--- /dev/null
+++ b/src/web/app/desktop/resources/header-logo.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="1024px" height="512px" viewBox="0 256 1024 512" enable-background="new 0 256 1024 512" xml:space="preserve">
+<polyline opacity="0.5" fill="none" stroke="#000000" stroke-width="34" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" points="
+ 896.5,608.5 800.5,416.5 704.5,608.5 608.5,416.5 512.5,608.5 416.5,416.5 320.5,608.5 224.5,416.5 128.5,608.5 "/>
+</svg>
diff --git a/src/web/app/desktop/resources/remove.png b/src/web/app/desktop/resources/remove.png
new file mode 100644
index 0000000000..8b1f4c06c9
--- /dev/null
+++ b/src/web/app/desktop/resources/remove.png
Binary files differ
diff --git a/src/web/app/desktop/router.ls b/src/web/app/desktop/router.ls
new file mode 100644
index 0000000000..02a7e11816
--- /dev/null
+++ b/src/web/app/desktop/router.ls
@@ -0,0 +1,77 @@
+# Router
+#================================
+
+riot = require \riot
+route = require \page
+page = null
+
+module.exports = (me) ~>
+
+ # Routing
+ #--------------------------------
+
+ 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 \* not-found
+
+ # Handlers
+ #--------------------------------
+
+ function index
+ if me? then home! else entrance!
+
+ function home
+ mount document.create-element \mk-home-page
+
+ function entrance
+ mount document.create-element \mk-entrance
+ document.document-element.set-attribute \data-page \entrance
+
+ function mentions
+ document.create-element \mk-home-page
+ ..set-attribute \mode \mentions
+ .. |> mount
+
+ function search ctx
+ document.create-element \mk-search-page
+ ..set-attribute \query ctx.params.query
+ .. |> mount
+
+ function user page, ctx
+ document.create-element \mk-user-page
+ ..set-attribute \user ctx.params.user
+ ..set-attribute \page page
+ .. |> mount
+
+ function post ctx
+ document.create-element \mk-post-page
+ ..set-attribute \post ctx.params.post
+ .. |> mount
+
+ function not-found
+ mount document.create-element \mk-not-found
+
+ # Register mixin
+ #--------------------------------
+
+ riot.mixin \page do
+ page: route
+
+ # Exec
+ #--------------------------------
+
+ route!
+
+# Mount
+#================================
+
+function mount content
+ document.document-element.remove-attribute \data-page
+ if page? then page.unmount!
+ body = document.get-element-by-id \app
+ page := riot.mount body.append-child content .0
diff --git a/src/web/app/desktop/script.js b/src/web/app/desktop/script.js
new file mode 100644
index 0000000000..473797334f
--- /dev/null
+++ b/src/web/app/desktop/script.js
@@ -0,0 +1,42 @@
+/**
+ * Desktop Client
+ */
+
+require('chart.js');
+require('./tags.ls');
+const riot = require('riot');
+const boot = require('../boot.ls');
+const mixins = require('./mixins.ls');
+const route = require('./router.ls');
+const fuckAdBlock = require('./scripts/fuck-ad-block.ls');
+
+/**
+ * Boot
+ */
+boot(me => {
+ /**
+ * Fuck AD Block
+ */
+ fuckAdBlock();
+
+ /**
+ * Init Notification
+ */
+ if ('Notification' in window) {
+ // 許可を得ていなかったらリクエスト
+ if (Notification.permission == 'default') {
+ Notification.requestPermission();
+ }
+ }
+
+ // Register mixins
+ mixins(me);
+
+ // Debug
+ if (me != null && me.data.debug) {
+ riot.mount(document.body.appendChild(document.createElement('mk-log-window')));
+ }
+
+ // Start routing
+ route(me);
+});
diff --git a/src/web/app/desktop/scripts/autocomplete.ls b/src/web/app/desktop/scripts/autocomplete.ls
new file mode 100644
index 0000000000..636bb7f277
--- /dev/null
+++ b/src/web/app/desktop/scripts/autocomplete.ls
@@ -0,0 +1,108 @@
+# Autocomplete
+#================================
+
+get-caret-coordinates = require 'textarea-caret-position'
+riot = require 'riot'
+
+# オートコンプリートを管理するクラスです。
+class Autocomplete
+
+ @textarea = null
+ @suggestion = null
+
+ # 対象のテキストエリアを与えてインスタンスを初期化します。
+ (textarea) ~>
+ @textarea = textarea
+
+ # このインスタンスにあるテキストエリアの入力のキャプチャを開始します。
+ attach: ~>
+ @textarea.add-event-listener \input @on-input
+
+ # このインスタンスにあるテキストエリアの入力のキャプチャを解除します。
+ detach: ~>
+ @textarea.remove-event-listener \input @on-input
+ @close!
+
+ # テキスト入力時
+ on-input: ~>
+ @close!
+
+ caret = @textarea.selection-start
+ text = @textarea.value.substr 0 caret
+
+ mention-index = text.last-index-of \@
+
+ if mention-index == -1
+ return
+
+ username = text.substr mention-index + 1
+
+ if not username.match /^[a-zA-Z0-9-]+$/
+ return
+
+ @open \user username
+
+ # サジェストを提示します。
+ open: (type, q) ~>
+ # 既に開いているサジェストは閉じる
+ @close!
+
+ # サジェスト要素作成
+ suggestion = document.create-element \mk-autocomplete-suggestion
+
+ # ~ サジェストを表示すべき位置を計算 ~
+
+ caret-position = get-caret-coordinates @textarea, @textarea.selection-start
+
+ rect = @textarea.get-bounding-client-rect!
+
+ x = rect.left + window.page-x-offset + caret-position.left
+ y = rect.top + window.page-y-offset + caret-position.top
+
+ suggestion.style.left = x + \px
+ suggestion.style.top = y + \px
+
+ # 要素追加
+ el = document.body.append-child suggestion
+
+ # マウント
+ mounted = riot.mount el, do
+ textarea: @textarea
+ complete: @complete
+ close: @close
+ type: type
+ q: q
+
+ @suggestion = mounted.0
+
+ # サジェストを閉じます。
+ close: ~>
+ if !@suggestion?
+ return
+
+ @suggestion.unmount!
+ @suggestion = null
+
+ @textarea.focus!
+
+ # オートコンプリートする
+ complete: (user) ~>
+ @close!
+ value = user.username
+
+ caret = @textarea.selection-start
+ source = @textarea.value
+
+ before = source.substr 0 caret
+ trimed-before = before.substring 0 before.last-index-of \@
+ after = source.substr caret
+
+ # 結果を挿入する
+ @textarea.value = trimed-before + \@ + value + ' ' + after
+
+ # キャレットを戻す
+ @textarea.focus!
+ pos = caret + value.length
+ @textarea.set-selection-range pos, pos
+
+module.exports = Autocomplete
diff --git a/src/web/app/desktop/scripts/dialog.ls b/src/web/app/desktop/scripts/dialog.ls
new file mode 100644
index 0000000000..f3dd6cea1b
--- /dev/null
+++ b/src/web/app/desktop/scripts/dialog.ls
@@ -0,0 +1,17 @@
+# Dialog
+#================================
+
+riot = require 'riot'
+
+module.exports = (title, text, buttons, can-through, on-through) ~>
+ dialog = document.body.append-child document.create-element \mk-dialog
+ controller = riot.observable!
+ riot.mount dialog, do
+ controller: controller
+ title: title
+ text: text
+ buttons: buttons
+ can-through: can-through
+ on-through: on-through
+ controller.trigger \open
+ return controller
diff --git a/src/web/app/desktop/scripts/follow-scroll.ls b/src/web/app/desktop/scripts/follow-scroll.ls
new file mode 100644
index 0000000000..5072e9c583
--- /dev/null
+++ b/src/web/app/desktop/scripts/follow-scroll.ls
@@ -0,0 +1,56 @@
+class Follower
+ (el) ->
+ @follower = el
+ @last-scroll-top = window.scroll-y
+ @initial-follower-top = @follower.get-bounding-client-rect!.top
+ @page-top = 48
+
+ follow: ->
+ window-height = window.inner-height
+ follower-height = @follower.offset-height
+
+ scroll-top = window.scroll-y
+ scroll-bottom = scroll-top + window-height
+
+ follower-top = @follower.get-bounding-client-rect!.top + scroll-top
+ follower-bottom = follower-top + follower-height
+
+ height-delta = Math.abs window-height - follower-height
+ scroll-delta = @last-scroll-top - scroll-top
+
+ is-scrolling-down = (scroll-top > @last-scroll-top)
+ is-window-larger = (window-height > follower-height)
+
+ console.log @initial-follower-top
+
+ if (is-window-larger && scroll-top > @initial-follower-top) || (!is-window-larger && scroll-top > @initial-follower-top + height-delta)
+ @follower.class-list.add \fixed
+ else if !is-scrolling-down && scroll-top + @page-top <= @initial-follower-top
+ @follower.class-list.remove \fixed
+ @follower.style.top = 0
+ return
+
+ drag-bottom-down = (follower-bottom <= scroll-bottom && is-scrolling-down)
+ drag-top-up = (follower-top >= scroll-top + @page-top && !is-scrolling-down)
+
+ if drag-bottom-down
+ console.log \down
+ @follower.style.top = if is-window-larger then 0 else -height-delta + \px
+ else if drag-top-up
+ console.log \up
+ @follower.style.top = @page-top + \px
+ else if @follower.class-list.contains \fixed
+ console.log \-
+ current-top = parse-int @follower.style.top, 10
+
+ min-top = -height-delta
+ scrolled-top = current-top + scroll-delta
+
+ is-page-at-bottom = (scroll-top + window-height >= document.body.offset-height)
+ new-top = if is-page-at-bottom then min-top else scrolled-top
+
+ @follower.style.top = new-top + \px
+
+ @last-scroll-top = scroll-top
+
+module.exports = Follower
diff --git a/src/web/app/desktop/scripts/fuck-ad-block.ls b/src/web/app/desktop/scripts/fuck-ad-block.ls
new file mode 100644
index 0000000000..55431fcd00
--- /dev/null
+++ b/src/web/app/desktop/scripts/fuck-ad-block.ls
@@ -0,0 +1,19 @@
+# FUCK AD BLOCK
+#================================
+
+require 'fuck-adblock'
+dialog = require './dialog.ls'
+
+module.exports = ~>
+ if fuck-ad-block == undefined
+ ad-block-detected!
+ else
+ fuck-ad-block.on-detected ad-block-detected
+
+function ad-block-detected
+ dialog do
+ '<i class="fa fa-exclamation-triangle"></i>広告ブロッカーを無効にしてください'
+ '<strong>Misskeyは広告を掲載していません</strong>が、広告をブロックする機能が有効だと一部の機能が利用できなかったり、不具合が発生する場合があります。'
+ [
+ text: \OK
+ ]
diff --git a/src/web/app/desktop/scripts/input-dialog.ls b/src/web/app/desktop/scripts/input-dialog.ls
new file mode 100644
index 0000000000..f75b12dd01
--- /dev/null
+++ b/src/web/app/desktop/scripts/input-dialog.ls
@@ -0,0 +1,13 @@
+# Input Dialog
+#================================
+
+riot = require 'riot'
+
+module.exports = (title, placeholder, default-value, on-ok, on-cancel) ~>
+ dialog = document.body.append-child document.create-element \mk-input-dialog
+ riot.mount dialog, do
+ title: title
+ placeholder: placeholder
+ default: default-value
+ on-ok: on-ok
+ on-cancel: on-cancel
diff --git a/src/web/app/desktop/scripts/notify.ls b/src/web/app/desktop/scripts/notify.ls
new file mode 100644
index 0000000000..919bbc3dcf
--- /dev/null
+++ b/src/web/app/desktop/scripts/notify.ls
@@ -0,0 +1,6 @@
+riot = require \riot
+
+module.exports = (message) ~>
+ notification = document.body.append-child document.create-element \mk-ui-notification
+ riot.mount notification, do
+ message: message
diff --git a/src/web/app/desktop/scripts/open-window.ls b/src/web/app/desktop/scripts/open-window.ls
new file mode 100644
index 0000000000..4388272ecf
--- /dev/null
+++ b/src/web/app/desktop/scripts/open-window.ls
@@ -0,0 +1,8 @@
+riot = require \riot
+
+function open(name, opts)
+ window = document.body.append-child document.create-element name
+ riot.mount window, opts
+
+riot.mixin \open-window do
+ open-window: open
diff --git a/src/web/app/desktop/scripts/stream.ls b/src/web/app/desktop/scripts/stream.ls
new file mode 100644
index 0000000000..f84d6097a7
--- /dev/null
+++ b/src/web/app/desktop/scripts/stream.ls
@@ -0,0 +1,38 @@
+# Stream
+#================================
+
+stream = require '../../common/scripts/stream.ls'
+get-post-summary = require '../../common/scripts/get-post-summary.ls'
+riot = require \riot
+
+module.exports = (me) ~>
+ s = stream me
+
+ s.event.on \drive_file_created (file) ~>
+ n = new Notification 'ファイルがアップロードされました' do
+ body: file.name
+ icon: file.url + '?thumbnail&size=64'
+ set-timeout (n.close.bind n), 5000ms
+
+ s.event.on \mention (post) ~>
+ n = new Notification "#{post.user.name}さんから:" do
+ body: get-post-summary post
+ icon: post.user.avatar_url + '?thumbnail&size=64'
+ set-timeout (n.close.bind n), 6000ms
+
+ s.event.on \reply (post) ~>
+ n = new Notification "#{post.user.name}さんから返信:" do
+ body: get-post-summary post
+ icon: post.user.avatar_url + '?thumbnail&size=64'
+ set-timeout (n.close.bind n), 6000ms
+
+ s.event.on \quote (post) ~>
+ n = new Notification "#{post.user.name}さんが引用:" do
+ body: get-post-summary post
+ icon: post.user.avatar_url + '?thumbnail&size=64'
+ set-timeout (n.close.bind n), 6000ms
+
+ riot.mixin \stream do
+ stream: s.event
+ get-stream-state: s.get-state
+ stream-state-ev: s.state-ev
diff --git a/src/web/app/desktop/scripts/update-avatar.ls b/src/web/app/desktop/scripts/update-avatar.ls
new file mode 100644
index 0000000000..513a59074c
--- /dev/null
+++ b/src/web/app/desktop/scripts/update-avatar.ls
@@ -0,0 +1,81 @@
+# Update Avatar
+#================================
+
+riot = require 'riot'
+dialog = require './dialog.ls'
+api = require '../../common/scripts/api.ls'
+
+module.exports = (I, cb, file = null) ~>
+
+ @file-selected = (file) ~>
+ cropper = document.body.append-child document.create-element \mk-crop-window
+ cropper = riot.mount cropper, do
+ file: file
+ title: 'アバターとして表示する部分を選択'
+ aspect-ratio: 1 / 1
+ .0
+ cropper.on \cropped (blob) ~>
+ data = new FormData!
+ data.append \i I.token
+ data.append \file blob, file.name + '.cropped.png'
+ api I, \drive/folders/find do
+ name: 'アイコン'
+ .then (icon-folder) ~>
+ if icon-folder.length == 0
+ api I, \drive/folders/create do
+ name: 'アイコン'
+ .then (icon-folder) ~>
+ @uplaod data, icon-folder
+ else
+ @uplaod data, icon-folder.0
+ cropper.on \skiped ~>
+ @set file
+
+ @uplaod = (data, folder) ~>
+
+ progress = document.body.append-child document.create-element \mk-progress-dialog
+ progress = riot.mount progress, do
+ title: '新しいアバターをアップロードしています'
+ .0
+
+ if folder?
+ data.append \folder_id folder.id
+
+ xhr = new XMLHttpRequest!
+ xhr.open \POST CONFIG.api.url + \/drive/files/create true
+ xhr.onload = (e) ~>
+ file = JSON.parse e.target.response
+ progress.close!
+ @set file
+
+ xhr.upload.onprogress = (e) ~>
+ if e.length-computable
+ progress.update-progress e.loaded, e.total
+
+ xhr.send data
+
+ @set = (file) ~>
+ api I, \i/update do
+ avatar_id: file.id
+ .then (i) ~>
+ dialog do
+ '<i class="fa fa-info-circle"></i>アバターを更新しました'
+ '新しいアバターが反映されるまで時間がかかる場合があります。'
+ [
+ text: \わかった
+ ]
+ if cb? then cb i
+ .catch (err) ~>
+ console.error err
+ #@opts.ui.trigger \notification 'Error!'
+
+ if file?
+ @file-selected file
+ else
+ browser = document.body.append-child document.create-element \mk-select-file-from-drive-window
+ browser = riot.mount browser, do
+ multiple: false
+ title: '<i class="fa fa-picture-o"></i>アバターにする画像を選択'
+ .0
+ browser.one \selected (file) ~>
+ @file-selected file
diff --git a/src/web/app/desktop/scripts/update-banner.ls b/src/web/app/desktop/scripts/update-banner.ls
new file mode 100644
index 0000000000..5754cdcdb1
--- /dev/null
+++ b/src/web/app/desktop/scripts/update-banner.ls
@@ -0,0 +1,81 @@
+# Update Banner
+#================================
+
+riot = require 'riot'
+dialog = require './dialog.ls'
+api = require '../../common/scripts/api.ls'
+
+module.exports = (I, cb, file = null) ~>
+
+ @file-selected = (file) ~>
+ cropper = document.body.append-child document.create-element \mk-crop-window
+ cropper = riot.mount cropper, do
+ file: file
+ title: 'バナーとして表示する部分を選択'
+ aspect-ratio: 16 / 9
+ .0
+ cropper.on \cropped (blob) ~>
+ data = new FormData!
+ data.append \i I.token
+ data.append \file blob, file.name + '.cropped.png'
+ api I, \drive/folders/find do
+ name: 'バナー'
+ .then (banner-folder) ~>
+ if banner-folder.length == 0
+ api I, \drive/folders/create do
+ name: 'バナー'
+ .then (banner-folder) ~>
+ @uplaod data, banner-folder
+ else
+ @uplaod data, banner-folder.0
+ cropper.on \skiped ~>
+ @set file
+
+ @uplaod = (data, folder) ~>
+
+ progress = document.body.append-child document.create-element \mk-progress-dialog
+ progress = riot.mount progress, do
+ title: '新しいバナーをアップロードしています'
+ .0
+
+ if folder?
+ data.append \folder_id folder.id
+
+ xhr = new XMLHttpRequest!
+ xhr.open \POST CONFIG.api.url + \/drive/files/create true
+ xhr.onload = (e) ~>
+ file = JSON.parse e.target.response
+ progress.close!
+ @set file
+
+ xhr.upload.onprogress = (e) ~>
+ if e.length-computable
+ progress.update-progress e.loaded, e.total
+
+ xhr.send data
+
+ @set = (file) ~>
+ api I, \i/update do
+ banner_id: file.id
+ .then (i) ~>
+ dialog do
+ '<i class="fa fa-info-circle"></i>バナーを更新しました'
+ '新しいバナーが反映されるまで時間がかかる場合があります。'
+ [
+ text: \わかりました。
+ ]
+ if cb? then cb i
+ .catch (err) ~>
+ console.error err
+ #@opts.ui.trigger \notification 'Error!'
+
+ if file?
+ @file-selected file
+ else
+ browser = document.body.append-child document.create-element \mk-select-file-from-drive-window
+ browser = riot.mount browser, do
+ multiple: false
+ title: '<i class="fa fa-picture-o"></i>バナーにする画像を選択'
+ .0
+ browser.one \selected (file) ~>
+ @file-selected file
diff --git a/src/web/app/desktop/scripts/update-wallpaper.ls b/src/web/app/desktop/scripts/update-wallpaper.ls
new file mode 100644
index 0000000000..49632400c0
--- /dev/null
+++ b/src/web/app/desktop/scripts/update-wallpaper.ls
@@ -0,0 +1,35 @@
+# Update Wallpaper
+#================================
+
+riot = require 'riot'
+dialog = require './dialog.ls'
+api = require '../../common/scripts/api.ls'
+
+module.exports = (I, cb, file = null) ~>
+
+ @set = (file) ~>
+ api I, \i/appdata/set do
+ data: JSON.stringify do
+ wallpaper: file.id
+ .then (i) ~>
+ dialog do
+ '<i class="fa fa-info-circle"></i>壁紙を更新しました'
+ '新しい壁紙が反映されるまで時間がかかる場合があります。'
+ [
+ text: \はい
+ ]
+ if cb? then cb i
+ .catch (err) ~>
+ console.error err
+ #@opts.ui.trigger \notification 'Error!'
+
+ if file?
+ @set file
+ else
+ browser = document.body.append-child document.create-element \mk-select-file-from-drive-window
+ browser = riot.mount browser, do
+ multiple: false
+ title: '<i class="fa fa-picture-o"></i>壁紙にする画像を選択'
+ .0
+ browser.one \selected (file) ~>
+ @set file
diff --git a/src/web/app/desktop/scripts/user-preview.ls b/src/web/app/desktop/scripts/user-preview.ls
new file mode 100644
index 0000000000..0c5a67aedb
--- /dev/null
+++ b/src/web/app/desktop/scripts/user-preview.ls
@@ -0,0 +1,74 @@
+# User Preview
+#================================
+
+riot = require \riot
+
+riot.mixin \user-preview do
+ init: ->
+ @on \mount ~>
+ scan.call @
+ @on \updated ~>
+ scan.call @
+
+ function scan
+ elems = @root.query-selector-all '[data-user-preview]:not([data-user-preview-attached])'
+ elems.for-each attach.bind @
+
+function attach el
+ el.set-attribute \data-user-preview-attached true
+ user = el.get-attribute \data-user-preview
+
+ tag = null
+
+ show-timer = null
+ hide-timer = null
+
+ el.add-event-listener \mouseover ~>
+ clear-timeout show-timer
+ clear-timeout hide-timer
+ show-timer := set-timeout ~>
+ show!
+ , 500ms
+
+ el.add-event-listener \mouseleave ~>
+ clear-timeout show-timer
+ clear-timeout hide-timer
+ hide-timer := set-timeout ~>
+ close!
+ , 500ms
+
+ @on \unmount ~>
+ clear-timeout show-timer
+ clear-timeout hide-timer
+ close!
+
+ function show
+ if tag?
+ return
+
+ preview = document.create-element \mk-user-preview
+
+ rect = el.get-bounding-client-rect!
+ x = rect.left + el.offset-width + window.page-x-offset
+ y = rect.top + window.page-y-offset
+
+ preview.style.top = y + \px
+ preview.style.left = x + \px
+
+ preview.add-event-listener \mouseover ~>
+ clear-timeout hide-timer
+
+ preview.add-event-listener \mouseleave ~>
+ clear-timeout show-timer
+ hide-timer := set-timeout ~>
+ close!
+ , 500ms
+
+ tag := riot.mount (document.body.append-child preview), do
+ user: user
+ .0
+
+ function close
+ if tag?
+ tag.close!
+ tag := null
diff --git a/src/web/app/desktop/style.styl b/src/web/app/desktop/style.styl
new file mode 100644
index 0000000000..fa50f6ce31
--- /dev/null
+++ b/src/web/app/desktop/style.styl
@@ -0,0 +1,114 @@
+@import "../base"
+@import "../../../../node_modules/cropperjs/dist/cropper.css"
+
+*::input-placeholder
+ color #D8CBC5
+
+*
+ &:focus
+ outline none
+
+ &::scrollbar
+ width 5px
+ background transparent
+
+ &:horizontal
+ height 5px
+
+ &::scrollbar-button
+ width 0
+ height 0
+ background rgba(0, 0, 0, 0.2)
+
+ &::scrollbar-piece
+ background transparent
+
+ &:start
+ background transparent
+
+ &::scrollbar-thumb
+ background rgba(0, 0, 0, 0.2)
+
+ &:hover
+ background rgba(0, 0, 0, 0.4)
+
+ &:active
+ background $theme-color
+
+ &::scrollbar-corner
+ background rgba(0, 0, 0, 0.2)
+
+html
+ background #fdfdfd
+
+ // ↓ workaround of https://github.com/riot/riot/issues/2134
+ &[data-page='entrance']
+ #wait
+ right auto
+ left 15px
+
+html[theme='dark']
+ background #100f0f
+
+button
+ font-family sans-serif
+
+ *
+ pointer-events none
+
+ &.style-normal
+ &.style-primary
+ display block
+ cursor pointer
+ padding 0 16px
+ margin 0
+ min-width 100px
+ 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
+
+ &.style-normal
+ 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-primary
+ 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
diff --git a/src/web/app/desktop/tags.ls b/src/web/app/desktop/tags.ls
new file mode 100644
index 0000000000..f78d367341
--- /dev/null
+++ b/src/web/app/desktop/tags.ls
@@ -0,0 +1,103 @@
+require './tags/contextmenu.tag'
+require './tags/dialog.tag'
+require './tags/window.tag'
+require './tags/input-dialog.tag'
+require './tags/follow-button.tag'
+require './tags/drive/base-contextmenu.tag'
+require './tags/drive/file-contextmenu.tag'
+require './tags/drive/folder-contextmenu.tag'
+require './tags/drive/file.tag'
+require './tags/drive/folder.tag'
+require './tags/drive/nav-folder.tag'
+require './tags/drive/browser-window.tag'
+require './tags/drive/browser.tag'
+require './tags/select-file-from-drive-window.tag'
+require './tags/crop-window.tag'
+require './tags/settings.tag'
+require './tags/settings-window.tag'
+require './tags/analog-clock.tag'
+require './tags/go-top.tag'
+require './tags/ui-header.tag'
+require './tags/ui-header-account.tag'
+require './tags/ui-header-notifications.tag'
+require './tags/ui-header-clock.tag'
+require './tags/ui-header-nav.tag'
+require './tags/ui-header-post-button.tag'
+require './tags/ui-header-search.tag'
+require './tags/notifications.tag'
+require './tags/post-form-window.tag'
+require './tags/post-form.tag'
+require './tags/timeline-post.tag'
+require './tags/post-preview.tag'
+require './tags/repost-form-window.tag'
+require './tags/home-widgets/user-recommendation.tag'
+require './tags/home-widgets/timeline.tag'
+require './tags/home-widgets/mentions.tag'
+require './tags/home-widgets/calendar.tag'
+require './tags/home-widgets/donation.tag'
+require './tags/home-widgets/tips.tag'
+require './tags/home-widgets/nav.tag'
+require './tags/home-widgets/profile.tag'
+require './tags/home-widgets/notifications.tag'
+require './tags/home-widgets/rss-reader.tag'
+require './tags/home-widgets/photo-stream.tag'
+require './tags/home-widgets/broadcast.tag'
+require './tags/stream-indicator.tag'
+require './tags/timeline.tag'
+require './tags/messaging/window.tag'
+require './tags/messaging/room.tag'
+require './tags/messaging/room-window.tag'
+require './tags/messaging/message.tag'
+require './tags/messaging/index.tag'
+require './tags/messaging/form.tag'
+require './tags/following-setuper.tag'
+require './tags/ellipsis-icon.tag'
+require './tags/ui.tag'
+require './tags/home.tag'
+require './tags/detect-slow-internet-connection-notice.tag'
+require './tags/user-header.tag'
+require './tags/user-profile.tag'
+require './tags/user-timeline.tag'
+require './tags/user.tag'
+require './tags/user-home.tag'
+require './tags/user-graphs.tag'
+require './tags/user-photos.tag'
+require './tags/big-follow-button.tag'
+require './tags/pages/entrance.tag'
+require './tags/pages/entrance/signin.tag'
+require './tags/pages/entrance/signup.tag'
+require './tags/pages/home.tag'
+require './tags/pages/user.tag'
+require './tags/pages/post.tag'
+require './tags/pages/search.tag'
+require './tags/pages/not-found.tag'
+require './tags/autocomplete-suggestion.tag'
+require './tags/progress-dialog.tag'
+require './tags/user-preview.tag'
+require './tags/post-detail.tag'
+require './tags/post-detail-sub.tag'
+require './tags/search.tag'
+require './tags/search-posts.tag'
+require './tags/set-avatar-suggestion.tag'
+require './tags/set-banner-suggestion.tag'
+require './tags/repost-form.tag'
+require './tags/timeline-post-sub.tag'
+require './tags/sub-post-content.tag'
+require './tags/images-viewer.tag'
+require './tags/image-dialog.tag'
+require './tags/donation.tag'
+require './tags/user-posts-graph.tag'
+require './tags/user-friends-graph.tag'
+require './tags/user-likes-graph.tag'
+require './tags/post-status-graph.tag'
+require './tags/debugger.tag'
+require './tags/users-list.tag'
+require './tags/user-following.tag'
+require './tags/user-followers.tag'
+require './tags/user-following-window.tag'
+require './tags/user-followers-window.tag'
+require './tags/list-user.tag'
+require './tags/ui-notification.tag'
+require './tags/signin-history.tag'
+require './tags/log.tag'
+require './tags/log-window.tag'
diff --git a/src/web/app/desktop/tags/analog-clock.tag b/src/web/app/desktop/tags/analog-clock.tag
new file mode 100644
index 0000000000..a4cfe5726c
--- /dev/null
+++ b/src/web/app/desktop/tags/analog-clock.tag
@@ -0,0 +1,102 @@
+mk-analog-clock
+ canvas@canvas(width='256', height='256')
+
+style.
+ > canvas
+ display block
+ width 256px
+ height 256px
+
+script.
+ @on \mount ~>
+ @draw!
+ @clock = set-interval @draw, 1000ms
+
+ @on \unmount ~>
+ clear-interval @clock
+
+ @draw = ~>
+ now = new Date!
+ s = now.get-seconds!
+ m = now.get-minutes!
+ h = now.get-hours!
+
+ vec2 = (x, y) ->
+ @x = x
+ @y = y
+
+ ctx = @refs.canvas.get-context \2d
+ canv-w = @refs.canvas.width
+ canv-h = @refs.canvas.height
+ ctx.clear-rect 0, 0, canv-w, canv-h
+
+ # 背景
+ center = (Math.min (canv-w / 2), (canv-h / 2))
+ line-start = center * 0.90
+ line-end-short = center * 0.87
+ line-end-long = center * 0.84
+ for i from 0 to 59 by 1
+ angle = Math.PI * i / 30
+ uv = new vec2 (Math.sin angle), (-Math.cos angle)
+ ctx.begin-path!
+ ctx.line-width = 1
+ ctx.move-to do
+ (canv-w / 2) + uv.x * line-start
+ (canv-h / 2) + uv.y * line-start
+ if i % 5 == 0
+ ctx.stroke-style = 'rgba(255, 255, 255, 0.2)'
+ ctx.line-to do
+ (canv-w / 2) + uv.x * line-end-long
+ (canv-h / 2) + uv.y * line-end-long
+ else
+ ctx.stroke-style = 'rgba(255, 255, 255, 0.1)'
+ ctx.line-to do
+ (canv-w / 2) + uv.x * line-end-short
+ (canv-h / 2) + uv.y * line-end-short
+ ctx.stroke!
+
+ # 分
+ angle = Math.PI * (m + s / 60) / 30
+ length = (Math.min canv-w, canv-h) / 2.6
+ uv = new vec2 (Math.sin angle), (-Math.cos angle)
+ ctx.begin-path!
+ ctx.stroke-style = \#ffffff
+ ctx.line-width = 2
+ ctx.move-to do
+ (canv-w / 2) - uv.x * length / 5
+ (canv-h / 2) - uv.y * length / 5
+ ctx.line-to do
+ (canv-w / 2) + uv.x * length
+ (canv-h / 2) + uv.y * length
+ ctx.stroke!
+
+ # 時
+ angle = Math.PI * (h % 12 + m / 60) / 6
+ length = (Math.min canv-w, canv-h) / 4
+ uv = new vec2 (Math.sin angle), (-Math.cos angle)
+ ctx.begin-path!
+ #ctx.stroke-style = \#ffffff
+ ctx.stroke-style = CONFIG.theme-color
+ ctx.line-width = 2
+ ctx.move-to do
+ (canv-w / 2) - uv.x * length / 5
+ (canv-h / 2) - uv.y * length / 5
+ ctx.line-to do
+ (canv-w / 2) + uv.x * length
+ (canv-h / 2) + uv.y * length
+ ctx.stroke!
+
+ # 秒
+ angle = Math.PI * s / 30
+ length = (Math.min canv-w, canv-h) / 2.6
+ uv = new vec2 (Math.sin angle), (-Math.cos angle)
+ ctx.begin-path!
+ ctx.stroke-style = 'rgba(255, 255, 255, 0.5)'
+ ctx.line-width = 1
+ ctx.move-to do
+ (canv-w / 2) - uv.x * length / 5
+ (canv-h / 2) - uv.y * length / 5
+ ctx.line-to do
+ (canv-w / 2) + uv.x * length
+ (canv-h / 2) + uv.y * length
+ ctx.stroke!
diff --git a/src/web/app/desktop/tags/autocomplete-suggestion.tag b/src/web/app/desktop/tags/autocomplete-suggestion.tag
new file mode 100644
index 0000000000..13d9df6914
--- /dev/null
+++ b/src/web/app/desktop/tags/autocomplete-suggestion.tag
@@ -0,0 +1,182 @@
+mk-autocomplete-suggestion
+ ol.users@users(if={ users.length > 0 })
+ li(each={ users }, onclick={ parent.on-click }, onkeydown={ parent.on-keydown }, tabindex='-1')
+ img.avatar(src={ avatar_url + '?thumbnail&size=32' }, alt='')
+ span.name { name }
+ span.username @{ username }
+
+style.
+ display block
+ position absolute
+ z-index 65535
+ margin-top calc(1em + 8px)
+ overflow hidden
+ background #fff
+ border solid 1px rgba(0, 0, 0, 0.1)
+ border-radius 4px
+
+ > .users
+ display block
+ margin 0
+ padding 4px 0
+ max-height 190px
+ max-width 500px
+ overflow auto
+ list-style none
+
+ > li
+ display block
+ padding 4px 12px
+ white-space nowrap
+ overflow hidden
+ font-size 0.9em
+ color rgba(0, 0, 0, 0.8)
+ cursor default
+
+ &, *
+ user-select none
+
+ &:hover
+ &[data-selected='true']
+ color #fff
+ background $theme-color
+
+ .name
+ color #fff
+
+ .username
+ color #fff
+
+ &:active
+ color #fff
+ background darken($theme-color, 10%)
+
+ .name
+ color #fff
+
+ .username
+ color #fff
+
+ .avatar
+ vertical-align middle
+ min-width 28px
+ min-height 28px
+ max-width 28px
+ max-height 28px
+ margin 0 8px 0 0
+ border-radius 100%
+
+ .name
+ margin 0 8px 0 0
+ /*font-weight bold*/
+ font-weight normal
+ color rgba(0, 0, 0, 0.8)
+
+ .username
+ font-weight normal
+ color rgba(0, 0, 0, 0.3)
+
+script.
+ @mixin \api
+
+ @q = @opts.q
+ @textarea = @opts.textarea
+ @loading = true
+ @users = []
+ @select = -1
+
+ @on \mount ~>
+ @textarea.add-event-listener \keydown @on-keydown
+
+ all = document.query-selector-all 'body *'
+ Array.prototype.for-each.call all, (el) ~>
+ el.add-event-listener \mousedown @mousedown
+
+ @api \users/search_by_username do
+ query: @q
+ limit: 30users
+ .then (users) ~>
+ @users = users
+ @loading = false
+ @update!
+ .catch (err) ~>
+ console.error err
+
+ @on \unmount ~>
+ @textarea.remove-event-listener \keydown @on-keydown
+
+ all = document.query-selector-all 'body *'
+ Array.prototype.for-each.call all, (el) ~>
+ el.remove-event-listener \mousedown @mousedown
+
+ @mousedown = (e) ~>
+ if (!contains @root, e.target) and (@root != e.target)
+ @close!
+
+ @on-click = (e) ~>
+ @complete e.item
+
+ @on-keydown = (e) ~>
+ key = e.which
+ switch (key)
+ | 10, 13 => # Key[ENTER]
+ if @select != -1
+ e.prevent-default!
+ e.stop-propagation!
+ @complete @users[@select]
+ else
+ @close!
+ | 27 => # Key[ESC]
+ e.prevent-default!
+ e.stop-propagation!
+ @close!
+ | 38 => # Key[↑]
+ if @select != -1
+ e.prevent-default!
+ e.stop-propagation!
+ @select-prev!
+ else
+ @close!
+ | 9, 40 => # Key[TAB] or Key[↓]
+ e.prevent-default!
+ e.stop-propagation!
+ @select-next!
+ | _ =>
+ @close!
+
+ @select-next = ~>
+ @select++
+
+ if @select >= @users.length
+ @select = 0
+
+ @apply-select!
+
+ @select-prev = ~>
+ @select--
+
+ if @select < 0
+ @select = @users.length - 1
+
+ @apply-select!
+
+ @apply-select = ~>
+ @refs.users.children.for-each (el) ~>
+ el.remove-attribute \data-selected
+
+ @refs.users.children[@select].set-attribute \data-selected \true
+ @refs.users.children[@select].focus!
+
+ @complete = (user) ~>
+ @opts.complete user
+
+ @close = ~>
+ @opts.close!
+
+ function contains(parent, child)
+ node = child.parent-node
+ while node?
+ if node == parent
+ return true
+ node = node.parent-node
+ return false
diff --git a/src/web/app/desktop/tags/big-follow-button.tag b/src/web/app/desktop/tags/big-follow-button.tag
new file mode 100644
index 0000000000..636853407c
--- /dev/null
+++ b/src/web/app/desktop/tags/big-follow-button.tag
@@ -0,0 +1,134 @@
+mk-big-follow-button
+ button(if={ !init }, class={ wait: wait, follow: !user.is_following, unfollow: user.is_following },
+ onclick={ onclick },
+ disabled={ wait },
+ title={ user.is_following ? 'フォロー解除' : 'フォローする' })
+ span(if={ !wait && user.is_following })
+ i.fa.fa-minus
+ | フォロー解除
+ span(if={ !wait && !user.is_following })
+ i.fa.fa-plus
+ | フォロー
+ i.fa.fa-spinner.fa-pulse.fa-fw(if={ wait })
+ div.init(if={ init }): i.fa.fa-spinner.fa-pulse.fa-fw
+
+style.
+ display block
+
+ > button
+ > .init
+ display block
+ cursor pointer
+ padding 0
+ margin 0
+ width 100%
+ line-height 38px
+ font-size 1em
+ outline none
+ border-radius 4px
+
+ *
+ pointer-events none
+
+ i
+ margin-right 8px
+
+ &: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
+
+ &.follow
+ 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
+
+ &.unfollow
+ 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
+
+ &.wait
+ cursor wait !important
+ opacity 0.7
+
+script.
+ @mixin \api
+ @mixin \is-promise
+ @mixin \stream
+
+ @user = null
+ @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user
+ @init = true
+ @wait = false
+
+ @on \mount ~>
+ @user-promise.then (user) ~>
+ @user = user
+ @init = false
+ @update!
+ @stream.on \follow @on-stream-follow
+ @stream.on \unfollow @on-stream-unfollow
+
+ @on \unmount ~>
+ @stream.off \follow @on-stream-follow
+ @stream.off \unfollow @on-stream-unfollow
+
+ @on-stream-follow = (user) ~>
+ if user.id == @user.id
+ @user = user
+ @update!
+
+ @on-stream-unfollow = (user) ~>
+ if user.id == @user.id
+ @user = user
+ @update!
+
+ @onclick = ~>
+ @wait = true
+ if @user.is_following
+ @api \following/delete do
+ user_id: @user.id
+ .then ~>
+ @user.is_following = false
+ .catch (err) ->
+ console.error err
+ .then ~>
+ @wait = false
+ @update!
+ else
+ @api \following/create do
+ user_id: @user.id
+ .then ~>
+ @user.is_following = true
+ .catch (err) ->
+ console.error err
+ .then ~>
+ @wait = false
+ @update!
diff --git a/src/web/app/desktop/tags/contextmenu.tag b/src/web/app/desktop/tags/contextmenu.tag
new file mode 100644
index 0000000000..7c3c7b8a24
--- /dev/null
+++ b/src/web/app/desktop/tags/contextmenu.tag
@@ -0,0 +1,138 @@
+mk-contextmenu
+ | <yield />
+
+style.
+ $width = 240px
+ $item-height = 38px
+ $padding = 10px
+
+ display none
+ position fixed
+ top 0
+ left 0
+ z-index 4096
+ width $width
+ font-size 0.8em
+ background #fff
+ border-radius 0 4px 4px 4px
+ box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2)
+
+ ul
+ display block
+ margin 0
+ padding $padding 0
+ list-style none
+
+ li
+ display block
+
+ &.separator
+ margin-top $padding
+ padding-top $padding
+ border-top solid 1px #eee
+
+ &.has-child
+ > p
+ cursor default
+
+ > i:last-child
+ position absolute
+ top 0
+ right 8px
+ line-height $item-height
+
+ &:hover > ul
+ visibility visible
+
+ &:active
+ > p, a
+ background $theme-color
+
+ > p, a
+ display block
+ z-index 1
+ margin 0
+ padding 0 32px 0 38px
+ line-height $item-height
+ color #868C8C
+ text-decoration none
+ cursor pointer
+
+ &:hover
+ text-decoration none
+
+ *
+ pointer-events none
+
+ > i
+ width 28px
+ margin-left -28px
+ text-align center
+
+ &:hover
+ > p, a
+ text-decoration none
+ background $theme-color
+ color $theme-color-foreground
+
+ &:active
+ > p, a
+ text-decoration none
+ background darken($theme-color, 10%)
+ color $theme-color-foreground
+
+ li > ul
+ visibility hidden
+ position absolute
+ top 0
+ left $width
+ margin-top -($padding)
+ width $width
+ background #fff
+ border-radius 0 4px 4px 4px
+ box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2)
+ transition visibility 0s linear 0.2s
+
+script.
+
+ @root.add-event-listener \contextmenu (e) ~>
+ e.prevent-default!
+
+ @mousedown = (e) ~>
+ e.prevent-default!
+ if (!contains @root, e.target) and (@root != e.target)
+ @close!
+ return false
+
+ @open = (pos) ~>
+ all = document.query-selector-all 'body *'
+ Array.prototype.for-each.call all, (el) ~>
+ el.add-event-listener \mousedown @mousedown
+ @root.style.display = \block
+ @root.style.left = pos.x + \px
+ @root.style.top = pos.y + \px
+
+ Velocity @root, \finish true
+ Velocity @root, { opacity: 0 } 0ms
+ Velocity @root, {
+ opacity: 1
+ } {
+ queue: false
+ duration: 100ms
+ easing: \linear
+ }
+
+ @close = ~>
+ all = document.query-selector-all 'body *'
+ Array.prototype.for-each.call all, (el) ~>
+ el.remove-event-listener \mousedown @mousedown
+ @trigger \closed
+ @unmount!
+
+ function contains(parent, child)
+ node = child.parent-node
+ while (node != null)
+ if (node == parent)
+ return true
+ node = node.parent-node
+ return false
diff --git a/src/web/app/desktop/tags/crop-window.tag b/src/web/app/desktop/tags/crop-window.tag
new file mode 100644
index 0000000000..16e1a72b3a
--- /dev/null
+++ b/src/web/app/desktop/tags/crop-window.tag
@@ -0,0 +1,189 @@
+mk-crop-window
+ mk-window@window(is-modal={ true }, width={ '800px' })
+ <yield to="header">
+ i.fa.fa-crop
+ | { parent.title }
+ </yield>
+ <yield to="content">
+ div.body
+ img@img(src={ parent.image.url + '?thumbnail&quality=80' }, alt='')
+ div.action
+ button.skip(onclick={ parent.skip }) クロップをスキップ
+ button.cancel(onclick={ parent.cancel }) キャンセル
+ button.ok(onclick={ parent.ok }) 決定
+ </yield>
+
+style.
+ display block
+
+ > mk-window
+ [data-yield='header']
+ > i
+ margin-right 4px
+
+ [data-yield='content']
+
+ > .body
+ > img
+ width 100%
+ max-height 400px
+
+ .cropper-modal {
+ opacity: 0.8;
+ }
+
+ .cropper-view-box {
+ outline-color: $theme-color;
+ }
+
+ .cropper-line, .cropper-point {
+ background-color: $theme-color;
+ }
+
+ .cropper-bg {
+ animation: cropper-bg 0.5s linear infinite;
+ }
+
+ @-webkit-keyframes cropper-bg {
+ 0% {
+ background-position: 0 0;
+ }
+
+ 100% {
+ background-position: -8px -8px;
+ }
+ }
+
+ @-moz-keyframes cropper-bg {
+ 0% {
+ background-position: 0 0;
+ }
+
+ 100% {
+ background-position: -8px -8px;
+ }
+ }
+
+ @-ms-keyframes cropper-bg {
+ 0% {
+ background-position: 0 0;
+ }
+
+ 100% {
+ background-position: -8px -8px;
+ }
+ }
+
+ @keyframes cropper-bg {
+ 0% {
+ background-position: 0 0;
+ }
+
+ 100% {
+ background-position: -8px -8px;
+ }
+ }
+
+ > .action
+ height 72px
+ background lighten($theme-color, 95%)
+
+ .ok
+ .cancel
+ .skip
+ display block
+ position absolute
+ bottom 16px
+ cursor pointer
+ padding 0
+ margin 0
+ 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
+ .cancel
+ width 120px
+
+ .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
+ .skip
+ 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
+
+ .cancel
+ right 148px
+
+ .skip
+ left 16px
+ width 150px
+
+script.
+ @mixin \cropper
+
+ @image = @opts.file
+ @title = @opts.title
+ @aspect-ratio = @opts.aspect-ratio
+ @cropper = null
+
+ @on \mount ~>
+ @img = @refs.window.refs.img
+ @cropper = new @Cropper @img, do
+ aspect-ratio: @aspect-ratio
+ highlight: no
+ view-mode: 1
+
+ @ok = ~>
+ @cropper.get-cropped-canvas!.to-blob (blob) ~>
+ @trigger \cropped blob
+ @refs.window.close!
+
+ @skip = ~>
+ @trigger \skiped
+ @refs.window.close!
+
+ @cancel = ~>
+ @trigger \canceled
+ @refs.window.close!
diff --git a/src/web/app/desktop/tags/debugger.tag b/src/web/app/desktop/tags/debugger.tag
new file mode 100644
index 0000000000..e2b522cb00
--- /dev/null
+++ b/src/web/app/desktop/tags/debugger.tag
@@ -0,0 +1,87 @@
+mk-debugger
+ mk-window@window(is-modal={ false }, width={ '700px' }, height={ '550px' })
+ <yield to="header">
+ i.fa.fa-wrench
+ | Debugger
+ </yield>
+ <yield to="content">
+ section.progress-dialog
+ h1 progress-dialog
+ button.style-normal(onclick={ parent.progress-dialog }): i.fa.fa-play
+ button.style-normal(onclick={ parent.progress-dialog-destroy }): i.fa.fa-stop
+ label
+ p TITLE:
+ input@progress-title(value='Title')
+ label
+ p VAL:
+ input@progress-value(type='number', oninput={ parent.progress-change }, value=0)
+ label
+ p MAX:
+ input@progress-max(type='number', oninput={ parent.progress-change }, value=100)
+ </yield>
+
+style.
+ > mk-window
+ [data-yield='header']
+ > i
+ margin-right 4px
+
+ [data-yield='content']
+ overflow auto
+
+ > section
+ padding 32px
+
+ // & + section
+ // margin-top 16px
+
+ > h1
+ display block
+ margin 0
+ padding 0 0 8px 0
+ font-size 1em
+ color #555
+ border-bottom solid 1px #eee
+
+ > label
+ display block
+
+ > p
+ display inline
+ margin 0
+
+ > .progress-dialog
+ button
+ display inline-block
+ margin 8px
+
+script.
+ @mixin \open-window
+
+ @on \mount ~>
+ @progress-title = @tags['mk-window'].progress-title
+ @progress-value = @tags['mk-window'].progress-value
+ @progress-max = @tags['mk-window'].progress-max
+
+ @refs.window.on \closed ~>
+ @unmount!
+
+ ################################
+
+ @progress-controller = riot.observable!
+
+ @progress-dialog = ~>
+ @open-window \mk-progress-dialog do
+ title: @progress-title.value
+ value: @progress-value.value
+ max: @progress-max.value
+ controller: @progress-controller
+
+ @progress-change = ~>
+ @progress-controller.trigger do
+ \update
+ @progress-value.value
+ @progress-max.value
+
+ @progress-dialog-destroy = ~>
+ @progress-controller.trigger \close
diff --git a/src/web/app/desktop/tags/detect-slow-internet-connection-notice.tag b/src/web/app/desktop/tags/detect-slow-internet-connection-notice.tag
new file mode 100644
index 0000000000..f11a0c0857
--- /dev/null
+++ b/src/web/app/desktop/tags/detect-slow-internet-connection-notice.tag
@@ -0,0 +1,56 @@
+mk-detect-slow-internet-connection-notice
+ i: i.fa.fa-exclamation
+ div: p インターネット回線が遅いようです。
+
+style.
+ display block
+ pointer-events none
+ position fixed
+ z-index 16384
+ top 64px
+ right 16px
+ margin 0
+ padding 0
+ width 298px
+ font-size 0.9em
+ background #fff
+ box-shadow 0 1px 4px rgba(0, 0, 0, 0.25)
+ opacity 0
+
+ > i
+ display block
+ width 48px
+ line-height 48px
+ margin-right 0.25em
+ text-align center
+ color $theme-color-foreground
+ font-size 1.5em
+ background $theme-color
+
+ > div
+ display block
+ position absolute
+ top 0
+ left 48px
+ margin 0
+ width 250px
+ height 48px
+ color #666
+
+ > p
+ display block
+ margin 0
+ padding 8px
+
+script.
+ @mixin \net
+
+ @net.on \detected-slow-network ~>
+ Velocity @root, {
+ opacity: 1
+ } 200ms \linear
+ set-timeout ~>
+ Velocity @root, {
+ opacity: 0
+ } 200ms \linear
+ , 10000ms
diff --git a/src/web/app/desktop/tags/dialog.tag b/src/web/app/desktop/tags/dialog.tag
new file mode 100644
index 0000000000..88a461db84
--- /dev/null
+++ b/src/web/app/desktop/tags/dialog.tag
@@ -0,0 +1,141 @@
+mk-dialog
+ div.bg@bg(onclick={ bg-click })
+ div.main@main
+ header@header
+ div.body@body
+ div.buttons
+ virtual(each={ opts.buttons })
+ button(onclick={ _onclick }) { text }
+
+style.
+ display block
+
+ > .bg
+ display block
+ position fixed
+ z-index 8192
+ top 0
+ left 0
+ width 100%
+ height 100%
+ background rgba(0, 0, 0, 0.7)
+ opacity 0
+ pointer-events none
+
+ > .main
+ display block
+ position fixed
+ z-index 8192
+ top 20%
+ left 0
+ right 0
+ margin 0 auto 0 auto
+ padding 32px 42px
+ width 480px
+ background #fff
+
+ > header
+ margin 1em 0
+ color $theme-color
+ // color #43A4EC
+ font-weight bold
+
+ > i
+ margin-right 0.5em
+
+ > .body
+ margin 1em 0
+ color #888
+
+ > .buttons
+ > button
+ display inline-block
+ float right
+ margin 0
+ padding 10px 10px
+ font-size 1.1em
+ font-weight normal
+ text-decoration none
+ color #888
+ background transparent
+ outline none
+ border none
+ border-radius 0
+ cursor pointer
+ transition color 0.1s ease
+
+ i
+ margin 0 0.375em
+
+ &:hover
+ color $theme-color
+
+ &:active
+ color darken($theme-color, 10%)
+ transition color 0s ease
+
+script.
+ @can-through = if opts.can-through? then opts.can-through else true
+ @opts.buttons.for-each (button) ~>
+ button._onclick = ~>
+ if button.onclick?
+ button.onclick!
+ @close!
+
+ @on \mount ~>
+ @refs.header.innerHTML = @opts.title
+ @refs.body.innerHTML = @opts.text
+
+ @refs.bg.style.pointer-events = \auto
+ Velocity @refs.bg, \finish true
+ Velocity @refs.bg, {
+ opacity: 1
+ } {
+ queue: false
+ duration: 100ms
+ easing: \linear
+ }
+
+ Velocity @refs.main, {
+ opacity: 0
+ scale: 1.2
+ } {
+ duration: 0
+ }
+ Velocity @refs.main, {
+ opacity: 1
+ scale: 1
+ } {
+ duration: 300ms
+ easing: [ 0, 0.5, 0.5, 1 ]
+ }
+
+ @close = ~>
+ @refs.bg.style.pointer-events = \none
+ Velocity @refs.bg, \finish true
+ Velocity @refs.bg, {
+ opacity: 0
+ } {
+ queue: false
+ duration: 300ms
+ easing: \linear
+ }
+
+ @refs.main.style.pointer-events = \none
+ Velocity @refs.main, \finish true
+ Velocity @refs.main, {
+ opacity: 0
+ scale: 0.8
+ } {
+ queue: false
+ duration: 300ms
+ easing: [ 0.5, -0.5, 1, 0.5 ]
+ complete: ~>
+ @unmount!
+ }
+
+ @bg-click = ~>
+ if @can-through
+ if @opts.on-through?
+ @opts.on-through!
+ @close!
diff --git a/src/web/app/desktop/tags/donation.tag b/src/web/app/desktop/tags/donation.tag
new file mode 100644
index 0000000000..9f8a1a6729
--- /dev/null
+++ b/src/web/app/desktop/tags/donation.tag
@@ -0,0 +1,63 @@
+mk-donation
+ button.close(onclick={ close }) 閉じる x
+ div.message
+ p 利用者の皆さま、
+ p
+ | 今日は、日本の皆さまにお知らせがあります。
+ | Misskeyの援助をお願いいたします。
+ | 私は独立性を守るため、一切の広告を掲載いたしません。
+ | 平均で約¥1,500の寄付をいただき、運営しております。
+ | 援助をしてくださる利用者はほんの少数です。
+ | お願いいたします。
+ | 今日、利用者の皆さまが¥300ご援助くだされば、募金活動を一時間で終了することができます。
+ | コーヒー1杯ほどの金額です。
+ | Misskeyを活用しておられるのでしたら、広告を掲載せずにもう1年活動できるよう、どうか1分だけお時間をください。
+ | 私は小さな非営利個人ですが、サーバー、プログラム、人件費など、世界でトップクラスのウェブサイト同等のコストがかかります。
+ | 利用者は何億人といますが、他の大きなサイトに比べてほんの少額の費用で運営しているのです。
+ | 人間の可能性、自由、そして機会。知識こそ、これらの基盤を成すものです。
+ | 私は、誰もが無料かつ制限なく知識に触れられるべきだと信じています。
+ | 募金活動を終了し、Misskeyの改善に戻れるようご援助ください。
+ | よろしくお願いいたします。
+
+style.
+ display block
+ color #fff
+ background #03072C
+
+ > .close
+ position absolute
+ top 16px
+ right 16px
+ z-index 1
+
+ > .message
+ padding 32px
+ font-size 1.4em
+ font-family serif
+
+ > p
+ display block
+ margin 0 auto
+ max-width 1200px
+
+ > p:first-child
+ margin-bottom 16px
+
+script.
+ @mixin \api
+ @mixin \i
+
+ @close = (e) ~>
+ e.prevent-default!
+ e.stop-propagation!
+
+ @I.data.no_donation = true
+ @api \i/appdata/set do
+ data: JSON.stringify do
+ no_donation: @I.data.no_donation
+ .then ~>
+ @update-i!
+
+ @unmount!
+
+ @parent.parent.set-root-layout!
diff --git a/src/web/app/desktop/tags/drive/base-contextmenu.tag b/src/web/app/desktop/tags/drive/base-contextmenu.tag
new file mode 100644
index 0000000000..c8b51009ea
--- /dev/null
+++ b/src/web/app/desktop/tags/drive/base-contextmenu.tag
@@ -0,0 +1,28 @@
+mk-drive-browser-base-contextmenu
+ mk-contextmenu@ctx
+ ul
+ li(onclick={ parent.create-folder }): p
+ i.fa.fa-folder-o
+ | フォルダーを作成
+ li(onclick={ parent.upload }): p
+ i.fa.fa-upload
+ | ファイルをアップロード
+
+script.
+ @browser = @opts.browser
+
+ @on \mount ~>
+ @refs.ctx.on \closed ~>
+ @trigger \closed
+ @unmount!
+
+ @open = (pos) ~>
+ @refs.ctx.open pos
+
+ @create-folder = ~>
+ @browser.create-folder!
+ @refs.ctx.close!
+
+ @upload = ~>
+ @browser.select-local-file!
+ @refs.ctx.close!
diff --git a/src/web/app/desktop/tags/drive/browser-window.tag b/src/web/app/desktop/tags/drive/browser-window.tag
new file mode 100644
index 0000000000..b3a5fc9a47
--- /dev/null
+++ b/src/web/app/desktop/tags/drive/browser-window.tag
@@ -0,0 +1,29 @@
+mk-drive-browser-window
+ mk-window@window(is-modal={ false }, width={ '800px' }, height={ '500px' })
+ <yield to="header">
+ i.fa.fa-cloud
+ | ドライブ
+ </yield>
+ <yield to="content">
+ mk-drive-browser(multiple={ true }, folder={ parent.folder })
+ </yield>
+
+style.
+ > mk-window
+ [data-yield='header']
+ > i
+ margin-right 4px
+
+ [data-yield='content']
+ > mk-drive-browser
+ height 100%
+
+script.
+ @folder = if @opts.folder? then @opts.folder else null
+
+ @on \mount ~>
+ @refs.window.on \closed ~>
+ @unmount!
+
+ @close = ~>
+ @refs.window.close!
diff --git a/src/web/app/desktop/tags/drive/browser.tag b/src/web/app/desktop/tags/drive/browser.tag
new file mode 100644
index 0000000000..62e6425fe5
--- /dev/null
+++ b/src/web/app/desktop/tags/drive/browser.tag
@@ -0,0 +1,634 @@
+mk-drive-browser
+ nav
+ div.path(oncontextmenu={ path-oncontextmenu })
+ mk-drive-browser-nav-folder(class={ current: folder == null }, folder={ null })
+ virtual(each={ folder in hierarchy-folders })
+ span.separator: i.fa.fa-angle-right
+ mk-drive-browser-nav-folder(folder={ folder })
+ span.separator(if={ folder != null }): i.fa.fa-angle-right
+ span.folder.current(if={ folder != null })
+ | { folder.name }
+ input.search(type='search', placeholder!='&#xf002; 検索')
+ div.main@main(class={ uploading: uploads.length > 0, loading: loading }, onmousedown={ onmousedown }, ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop }, oncontextmenu={ oncontextmenu })
+ div.selection@selection
+ div.contents@contents
+ div.folders@folders-container(if={ folders.length > 0 })
+ virtual(each={ folder in folders })
+ mk-drive-browser-folder.folder(folder={ folder })
+ button(if={ more-folders })
+ | もっと読み込む
+ div.files@files-container(if={ files.length > 0 })
+ virtual(each={ file in files })
+ mk-drive-browser-file.file(file={ file })
+ button(if={ more-files })
+ | もっと読み込む
+ div.empty(if={ files.length == 0 && folders.length == 0 && !loading })
+ p(if={ draghover })
+ | ドロップですか?いいですよ、ボクはカワイイですからね
+ p(if={ !draghover && folder == null })
+ strong ドライブには何もありません。
+ br
+ | 右クリックして「ファイルをアップロード」を選んだり、ファイルをドラッグ&ドロップすることでもアップロードできます。
+ p(if={ !draghover && folder != null })
+ | このフォルダーは空です
+ div.loading(if={ loading }).
+ <div class="spinner">
+ <div class="dot1"></div>
+ <div class="dot2"></div>
+ </div>
+ div.dropzone(if={ draghover })
+ mk-uploader@uploader
+ input@file-input(type='file', accept='*/*', multiple, tabindex='-1', onchange={ change-file-input })
+
+style.
+ display block
+
+ > nav
+ display block
+ z-index 2
+ width 100%
+ overflow auto
+ font-size 0.9em
+ color #555
+ background #fff
+ //border-bottom 1px solid #dfdfdf
+ box-shadow 0 1px 0 rgba(0, 0, 0, 0.05)
+
+ &, *
+ user-select none
+
+ > .path
+ display inline-block
+ vertical-align bottom
+ margin 0
+ padding 0 8px
+ width calc(100% - 200px)
+ line-height 38px
+ white-space nowrap
+
+ > *
+ display inline-block
+ margin 0
+ padding 0 8px
+ line-height 38px
+ cursor pointer
+
+ i
+ margin-right 4px
+
+ *
+ pointer-events none
+
+ &:hover
+ text-decoration underline
+
+ &.current
+ font-weight bold
+ cursor default
+
+ &:hover
+ text-decoration none
+
+ &.separator
+ margin 0
+ padding 0
+ opacity 0.5
+ cursor default
+
+ > i
+ margin 0
+
+ > .search
+ display inline-block
+ vertical-align bottom
+ user-select text
+ cursor auto
+ margin 0
+ padding 0 18px
+ width 200px
+ font-size 1em
+ line-height 38px
+ background transparent
+ outline none
+ //border solid 1px #ddd
+ border none
+ border-radius 0
+ box-shadow none
+ transition color 0.5s ease, border 0.5s ease
+ font-family FontAwesome, sans-serif
+
+ &[data-active='true']
+ background #fff
+
+ &::-webkit-input-placeholder,
+ &:-ms-input-placeholder,
+ &:-moz-placeholder
+ color $ui-controll-foreground-color
+
+ > .main
+ padding 8px
+ height calc(100% - 38px)
+ overflow auto
+
+ &, *
+ user-select none
+
+ &.loading
+ cursor wait !important
+
+ *
+ pointer-events none
+
+ > .contents
+ opacity 0.5
+
+ &.uploading
+ height calc(100% - 38px - 100px)
+
+ > .selection
+ display none
+ position absolute
+ z-index 128
+ top 0
+ left 0
+ border solid 1px $theme-color
+ background rgba($theme-color, 0.5)
+ pointer-events none
+
+ > .contents
+
+ > .folders
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .folder
+ float left
+
+ > .files
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .file
+ float left
+
+ > .empty
+ padding 16px
+ text-align center
+ color #999
+ pointer-events none
+
+ > p
+ margin 0
+
+ > .loading
+ .spinner
+ margin 100px auto
+ width 40px
+ height 40px
+ text-align center
+
+ animation sk-rotate 2.0s infinite linear
+
+ .dot1, .dot2
+ width 60%
+ height 60%
+ display inline-block
+ position absolute
+ top 0
+ background-color rgba(0, 0, 0, 0.3)
+ border-radius 100%
+
+ animation sk-bounce 2.0s infinite ease-in-out
+
+ .dot2
+ top auto
+ bottom 0
+ animation-delay -1.0s
+
+ @keyframes sk-rotate { 100% { transform: rotate(360deg); }}
+
+ @keyframes sk-bounce {
+ 0%, 100% {
+ transform: scale(0.0);
+ } 50% {
+ transform: scale(1.0);
+ }
+ }
+
+ > .dropzone
+ position absolute
+ left 0
+ top 38px
+ width 100%
+ height calc(100% - 38px)
+ border dashed 2px rgba($theme-color, 0.5)
+ pointer-events none
+
+ > mk-uploader
+ height 100px
+ padding 16px
+ background #fff
+
+ > input
+ display none
+
+script.
+ @mixin \api
+ @mixin \dialog
+ @mixin \input-dialog
+ @mixin \stream
+
+ @files = []
+ @folders = []
+ @hierarchy-folders = []
+
+ @uploads = []
+
+ # 現在の階層(フォルダ)
+ # * null でルートを表す
+ @folder = null
+
+ @multiple = if @opts.multiple? then @opts.multiple else false
+
+ # ドロップされようとしているか
+ @draghover = false
+
+ # 自信の所有するアイテムがドラッグをスタートさせたか
+ # (自分自身の階層にドロップできないようにするためのフラグ)
+ @is-drag-source = false
+
+ @on \mount ~>
+ @refs.uploader.on \uploaded (file) ~>
+ @add-file file, true
+
+ @refs.uploader.on \change-uploads (uploads) ~>
+ @uploads = uploads
+ @update!
+
+ @stream.on \drive_file_created @on-stream-drive-file-created
+ @stream.on \drive_file_updated @on-stream-drive-file-updated
+ @stream.on \drive_folder_created @on-stream-drive-folder-created
+ @stream.on \drive_folder_updated @on-stream-drive-folder-updated
+
+ # Riotのバグでnullを渡しても""になる
+ # https://github.com/riot/riot/issues/2080
+ #if @opts.folder?
+ if @opts.folder? and @opts.folder != ''
+ @move @opts.folder
+ else
+ @load!
+
+ @on \unmount ~>
+ @stream.off \drive_file_created @on-stream-drive-file-created
+ @stream.off \drive_file_updated @on-stream-drive-file-updated
+ @stream.off \drive_folder_created @on-stream-drive-folder-created
+ @stream.off \drive_folder_updated @on-stream-drive-folder-updated
+
+ @on-stream-drive-file-created = (file) ~>
+ @add-file file, true
+
+ @on-stream-drive-file-updated = (file) ~>
+ current = if @folder? then @folder.id else null
+ if current != file.folder_id
+ @remove-file file
+ else
+ @add-file file, true
+
+ @on-stream-drive-folder-created = (folder) ~>
+ @add-folder folder, true
+
+ @on-stream-drive-folder-updated = (folder) ~>
+ current = if @folder? then @folder.id else null
+ if current != folder.parent_id
+ @remove-folder folder
+ else
+ @add-folder folder, true
+
+ @onmousedown = (e) ~>
+ if (contains @refs.folders-container, e.target) or (contains @refs.files-container, e.target)
+ return true
+
+ rect = @refs.main.get-bounding-client-rect!
+
+ left = e.page-x + @refs.main.scroll-left - rect.left - window.page-x-offset
+ top = e.page-y + @refs.main.scroll-top - rect.top - window.page-y-offset
+
+ move = (e) ~>
+ @refs.selection.style.display = \block
+
+ cursor-x = e.page-x + @refs.main.scroll-left - rect.left - window.page-x-offset
+ cursor-y = e.page-y + @refs.main.scroll-top - rect.top - window.page-y-offset
+ w = cursor-x - left
+ h = cursor-y - top
+
+ if w > 0
+ @refs.selection.style.width = w + \px
+ @refs.selection.style.left = left + \px
+ else
+ @refs.selection.style.width = -w + \px
+ @refs.selection.style.left = cursor-x + \px
+
+ if h > 0
+ @refs.selection.style.height = h + \px
+ @refs.selection.style.top = top + \px
+ else
+ @refs.selection.style.height = -h + \px
+ @refs.selection.style.top = cursor-y + \px
+
+ up = (e) ~>
+ document.document-element.remove-event-listener \mousemove move
+ document.document-element.remove-event-listener \mouseup up
+
+ @refs.selection.style.display = \none
+
+ document.document-element.add-event-listener \mousemove move
+ document.document-element.add-event-listener \mouseup up
+
+ @path-oncontextmenu = (e) ~>
+ e.prevent-default!
+ e.stop-immediate-propagation!
+ return false
+
+ @ondragover = (e) ~>
+ e.prevent-default!
+ e.stop-propagation!
+
+ # ドラッグ元が自分自身の所有するアイテムかどうか
+ if !@is-drag-source
+ # ドラッグされてきたものがファイルだったら
+ if e.data-transfer.effect-allowed == \all
+ e.data-transfer.drop-effect = \copy
+ else
+ e.data-transfer.drop-effect = \move
+ @draghover = true
+ else
+ # 自分自身にはドロップさせない
+ e.data-transfer.drop-effect = \none
+ return false
+
+ @ondragenter = (e) ~>
+ e.prevent-default!
+ if !@is-drag-source
+ @draghover = true
+
+ @ondragleave = (e) ~>
+ @draghover = false
+
+ @ondrop = (e) ~>
+ e.prevent-default!
+ e.stop-propagation!
+
+ @draghover = false
+
+ # ドロップされてきたものがファイルだったら
+ if e.data-transfer.files.length > 0
+ Array.prototype.for-each.call e.data-transfer.files, (file) ~>
+ @upload file, @folder
+ return false
+
+ # データ取得
+ data = e.data-transfer.get-data 'text'
+ if !data?
+ return false
+
+ # パース
+ obj = JSON.parse data
+
+ # (ドライブの)ファイルだったら
+ if obj.type == \file
+ file = obj.id
+ if (@files.some (f) ~> f.id == file)
+ return false
+ @remove-file file
+ @api \drive/files/update do
+ file_id: file
+ folder_id: if @folder? then @folder.id else \null
+ .then ~>
+ # something
+ .catch (err, text-status) ~>
+ console.error err
+
+ # (ドライブの)フォルダーだったら
+ else if obj.type == \folder
+ folder = obj.id
+ # 移動先が自分自身ならreject
+ if @folder? and folder == @folder.id
+ return false
+ if (@folders.some (f) ~> f.id == folder)
+ return false
+ @remove-folder folder
+ @api \drive/folders/update do
+ folder_id: folder
+ parent_id: if @folder? then @folder.id else \null
+ .then ~>
+ # something
+ .catch (err) ~>
+ if err == 'detected-circular-definition'
+ @dialog do
+ '<i class="fa fa-exclamation-triangle"></i>操作を完了できません'
+ '移動先のフォルダーは、移動するフォルダーのサブフォルダーです。'
+ [
+ text: \OK
+ ]
+
+ return false
+
+ @oncontextmenu = (e) ~>
+ e.prevent-default!
+ e.stop-immediate-propagation!
+
+ ctx = document.body.append-child document.create-element \mk-drive-browser-base-contextmenu
+ ctx = riot.mount ctx, do
+ browser: @
+ ctx = ctx.0
+ ctx.open do
+ x: e.page-x - window.page-x-offset
+ y: e.page-y - window.page-y-offset
+
+ return false
+
+ @select-local-file = ~>
+ @refs.file-input.click!
+
+ @create-folder = ~>
+ name <~ @input-dialog do
+ 'フォルダー作成'
+ 'フォルダー名'
+ null
+
+ @api \drive/folders/create do
+ name: name
+ folder_id: if @folder? then @folder.id else undefined
+ .then (folder) ~>
+ @add-folder folder, true
+ @update!
+ .catch (err) ~>
+ console.error err
+
+ @change-file-input = ~>
+ files = @refs.file-input.files
+ for i from 0 to files.length - 1
+ file = files.item i
+ @upload file, @folder
+
+ @upload = (file, folder) ~>
+ if folder? and typeof folder == \object
+ folder = folder.id
+ @refs.uploader.upload file, folder
+
+ @get-selection = ~>
+ @files.filter (file) -> file._selected
+
+ @new-window = (folder-id) ~>
+ browser = document.body.append-child document.create-element \mk-drive-browser-window
+ riot.mount browser, do
+ folder: folder-id
+
+ @move = (target-folder) ~>
+ if target-folder? and typeof target-folder == \object
+ target-folder = target-folder.id
+
+ if target-folder == null
+ @go-root!
+ return
+
+ @loading = true
+ @update!
+
+ @api \drive/folders/show do
+ folder_id: target-folder
+ .then (folder) ~>
+ @folder = folder
+ @hierarchy-folders = []
+
+ x = (f) ~>
+ @hierarchy-folders.unshift f
+ if f.parent?
+ x f.parent
+
+ if folder.parent?
+ x folder.parent
+
+ @update!
+ @load!
+ .catch (err, text-status) ->
+ console.error err
+
+ @add-folder = (folder, unshift = false) ~>
+ current = if @folder? then @folder.id else null
+ if current != folder.parent_id
+ return
+
+ if (@folders.some (f) ~> f.id == folder.id)
+ exist = (@folders.map (f) -> f.id).index-of folder.id
+ @folders[exist] = folder
+ @update!
+ return
+
+ if unshift
+ @folders.unshift folder
+ else
+ @folders.push folder
+
+ @update!
+
+ @add-file = (file, unshift = false) ~>
+ current = if @folder? then @folder.id else null
+ if current != file.folder_id
+ return
+
+ if (@files.some (f) ~> f.id == file.id)
+ exist = (@files.map (f) -> f.id).index-of file.id
+ @files[exist] = file
+ @update!
+ return
+
+ if unshift
+ @files.unshift file
+ else
+ @files.push file
+
+ @update!
+
+ @remove-folder = (folder) ~>
+ if typeof folder == \object
+ folder = folder.id
+ @folders = @folders.filter (f) -> f.id != folder
+ @update!
+
+ @remove-file = (file) ~>
+ if typeof file == \object
+ file = file.id
+ @files = @files.filter (f) -> f.id != file
+ @update!
+
+ @go-root = ~>
+ if @folder != null
+ @folder = null
+ @hierarchy-folders = []
+ @update!
+ @load!
+
+ @load = ~>
+ @folders = []
+ @files = []
+ @more-folders = false
+ @more-files = false
+ @loading = true
+ @update!
+
+ load-folders = null
+ load-files = null
+
+ folders-max = 30
+ files-max = 30
+
+ # フォルダ一覧取得
+ @api \drive/folders do
+ folder_id: if @folder? then @folder.id else null
+ limit: folders-max + 1
+ .then (folders) ~>
+ if folders.length == folders-max + 1
+ @more-folders = true
+ folders.pop!
+ load-folders := folders
+ complete!
+ .catch (err, text-status) ~>
+ console.error err
+
+ # ファイル一覧取得
+ @api \drive/files do
+ folder_id: if @folder? then @folder.id else null
+ limit: files-max + 1
+ .then (files) ~>
+ if files.length == files-max + 1
+ @more-files = true
+ files.pop!
+ load-files := files
+ complete!
+ .catch (err, text-status) ~>
+ console.error err
+
+ flag = false
+ complete = ~>
+ if flag
+ load-folders.for-each (folder) ~>
+ @add-folder folder
+ load-files.for-each (file) ~>
+ @add-file file
+ @loading = false
+ @update!
+ else
+ flag := true
+
+ function contains(parent, child)
+ node = child.parent-node
+ while node?
+ if node == parent
+ return true
+ node = node.parent-node
+ return false
diff --git a/src/web/app/desktop/tags/drive/file-contextmenu.tag b/src/web/app/desktop/tags/drive/file-contextmenu.tag
new file mode 100644
index 0000000000..7d7dca6c92
--- /dev/null
+++ b/src/web/app/desktop/tags/drive/file-contextmenu.tag
@@ -0,0 +1,97 @@
+mk-drive-browser-file-contextmenu
+ mk-contextmenu@ctx: ul
+ li(onclick={ parent.rename }): p
+ i.fa.fa-i-cursor
+ | 名前を変更
+ li(onclick={ parent.copy-url }): p
+ i.fa.fa-link
+ | URLをコピー
+ li: a(href={ parent.file.url + '?download' }, download={ parent.file.name }, onclick={ parent.download })
+ i.fa.fa-download
+ | ダウンロード
+ li.separator
+ li(onclick={ parent.delete }): p
+ i.fa.fa-trash-o
+ | 削除
+ li.separator
+ li.has-child
+ p
+ | その他...
+ i.fa.fa-caret-right
+ ul
+ li(onclick={ parent.set-avatar }): p
+ | アバターに設定
+ li(onclick={ parent.set-banner }): p
+ | バナーに設定
+ li(onclick={ parent.set-wallpaper }): p
+ | 壁紙に設定
+ li.has-child
+ p
+ | アプリで開く...
+ i.fa.fa-caret-right
+ ul
+ li(onclick={ parent.add-app }): p
+ | アプリを追加...
+
+script.
+ @mixin \api
+ @mixin \i
+ @mixin \update-avatar
+ @mixin \update-banner
+ @mixin \update-wallpaper
+ @mixin \input-dialog
+ @mixin \NotImplementedException
+
+ @browser = @opts.browser
+ @file = @opts.file
+
+ @on \mount ~>
+ @refs.ctx.on \closed ~>
+ @trigger \closed
+ @unmount!
+
+ @open = (pos) ~>
+ @refs.ctx.open pos
+
+ @rename = ~>
+ @refs.ctx.close!
+
+ name <~ @input-dialog do
+ 'ファイル名の変更'
+ '新しいファイル名を入力してください'
+ @file.name
+
+ @api \drive/files/update do
+ file_id: @file.id
+ name: name
+ .then ~>
+ # something
+ .catch (err) ~>
+ console.error err
+
+ @copy-url = ~>
+ @NotImplementedException!
+
+ @download = ~>
+ @refs.ctx.close!
+
+ @set-avatar = ~>
+ @refs.ctx.close!
+ @update-avatar @I, (i) ~>
+ @update-i i
+ , @file
+
+ @set-banner = ~>
+ @refs.ctx.close!
+ @update-banner @I, (i) ~>
+ @update-i i
+ , @file
+
+ @set-wallpaper = ~>
+ @refs.ctx.close!
+ @update-wallpaper @I, (i) ~>
+ @update-i i
+ , @file
+
+ @add-app = ~>
+ @NotImplementedException!
diff --git a/src/web/app/desktop/tags/drive/file.tag b/src/web/app/desktop/tags/drive/file.tag
new file mode 100644
index 0000000000..1702bb6501
--- /dev/null
+++ b/src/web/app/desktop/tags/drive/file.tag
@@ -0,0 +1,207 @@
+mk-drive-browser-file(data-is-selected={ (file._selected || false).toString() }, data-is-contextmenu-showing={ is-contextmenu-showing.toString() }, onclick={ onclick }, oncontextmenu={ oncontextmenu }, draggable='true', ondragstart={ ondragstart }, ondragend={ ondragend }, title={ title })
+ div.label(if={ I.avatar_id == file.id })
+ img(src='/_/resources/label.svg')
+ p アバター
+ div.label(if={ I.banner_id == file.id })
+ img(src='/_/resources/label.svg')
+ p バナー
+ div.label(if={ I.data.wallpaper == file.id })
+ img(src='/_/resources/label.svg')
+ p 壁紙
+ div.thumbnail: img(src={ file.url + '?thumbnail&size=128' }, alt='')
+ p.name
+ span { file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }
+ span.ext(if={ file.name.lastIndexOf('.') != -1 }) { file.name.substr(file.name.lastIndexOf('.')) }
+
+style.
+ display block
+ margin 4px
+ padding 8px 0 0 0
+ width 144px
+ height 180px
+ border-radius 4px
+
+ &, *
+ cursor pointer
+
+ &:hover
+ background rgba(0, 0, 0, 0.05)
+
+ > .label
+ &:before
+ &:after
+ background #0b65a5
+
+ &:active
+ background rgba(0, 0, 0, 0.1)
+
+ > .label
+ &:before
+ &:after
+ background #0b588c
+
+ &[data-is-selected='true']
+ background $theme-color
+
+ &:hover
+ background lighten($theme-color, 10%)
+
+ &:active
+ background darken($theme-color, 10%)
+
+ > .label
+ &:before
+ &:after
+ display none
+
+ > .name
+ color $theme-color-foreground
+
+ &[data-is-contextmenu-showing='true']
+ &:after
+ content ""
+ pointer-events none
+ position absolute
+ top -4px
+ right -4px
+ bottom -4px
+ left -4px
+ border 2px dashed rgba($theme-color, 0.3)
+ border-radius 4px
+
+ > .label
+ position absolute
+ top 0
+ left 0
+ pointer-events none
+
+ &:before
+ content ""
+ display block
+ position absolute
+ z-index 1
+ top 0
+ left 57px
+ width 28px
+ height 8px
+ background #0c7ac9
+
+ &:after
+ content ""
+ display block
+ position absolute
+ z-index 1
+ top 57px
+ left 0
+ width 8px
+ height 28px
+ background #0c7ac9
+
+ > img
+ position absolute
+ z-index 2
+ top 0
+ left 0
+
+ > p
+ position absolute
+ z-index 3
+ top 19px
+ left -28px
+ width 120px
+ margin 0
+ text-align center
+ line-height 28px
+ color #fff
+ transform rotate(-45deg)
+
+ > .thumbnail
+ width 128px
+ height 128px
+ left 8px
+
+ > img
+ display block
+ position absolute
+ top 0
+ left 0
+ right 0
+ bottom 0
+ margin auto
+ max-width 128px
+ max-height 128px
+ pointer-events none
+
+ > .name
+ display block
+ margin 4px 0 0 0
+ font-size 0.8em
+ text-align center
+ word-break break-all
+ color #444
+ overflow hidden
+
+ > .ext
+ opacity 0.5
+
+script.
+ @mixin \i
+ @mixin \bytes-to-size
+
+ @file = @opts.file
+ @browser = @parent
+
+ @title = @file.name + '\n' + @file.type + ' ' + (@bytes-to-size @file.datasize)
+
+ @is-contextmenu-showing = false
+
+ @onclick = ~>
+ if @browser.multiple
+ if @file._selected?
+ @file._selected = !@file._selected
+ else
+ @file._selected = true
+ @browser.trigger \change-selection @browser.get-selection!
+ else
+ if @file._selected
+ @browser.trigger \selected @file
+ else
+ @browser.files.for-each (file) ~>
+ file._selected = false
+ @file._selected = true
+ @browser.trigger \change-selection @file
+
+ @oncontextmenu = (e) ~>
+ e.prevent-default!
+ e.stop-immediate-propagation!
+
+ @is-contextmenu-showing = true
+ @update!
+ ctx = document.body.append-child document.create-element \mk-drive-browser-file-contextmenu
+ ctx = riot.mount ctx, do
+ browser: @browser
+ file: @file
+ ctx = ctx.0
+ ctx.open do
+ x: e.page-x - window.page-x-offset
+ y: e.page-y - window.page-y-offset
+ ctx.on \closed ~>
+ @is-contextmenu-showing = false
+ @update!
+ return false
+
+ @ondragstart = (e) ~>
+ e.data-transfer.effect-allowed = \move
+ e.data-transfer.set-data 'text' JSON.stringify do
+ type: \file
+ id: @file.id
+ file: @file
+ @is-dragging = true
+
+ # 親ブラウザに対して、ドラッグが開始されたフラグを立てる
+ # (=あなたの子供が、ドラッグを開始しましたよ)
+ @browser.is-drag-source = true
+
+ @ondragend = (e) ~>
+ @is-dragging = false
+ @browser.is-drag-source = false
diff --git a/src/web/app/desktop/tags/drive/folder-contextmenu.tag b/src/web/app/desktop/tags/drive/folder-contextmenu.tag
new file mode 100644
index 0000000000..67fb1047b7
--- /dev/null
+++ b/src/web/app/desktop/tags/drive/folder-contextmenu.tag
@@ -0,0 +1,62 @@
+mk-drive-browser-folder-contextmenu
+ mk-contextmenu@ctx: ul
+ li(onclick={ parent.move }): p
+ i.fa.fa-arrow-right
+ | このフォルダへ移動
+ li(onclick={ parent.new-window }): p
+ i.fa.fa-share-square-o
+ | 新しいウィンドウで表示
+ li.separator
+ li(onclick={ parent.rename }): p
+ i.fa.fa-i-cursor
+ | 名前を変更
+ li.separator
+ li(onclick={ parent.delete }): p
+ i.fa.fa-trash-o
+ | 削除
+
+script.
+ @mixin \api
+ @mixin \input-dialog
+
+ @browser = @opts.browser
+ @folder = @opts.folder
+
+ @open = (pos) ~>
+ @refs.ctx.open pos
+
+ @refs.ctx.on \closed ~>
+ @trigger \closed
+ @unmount!
+
+ @move = ~>
+ @browser.move @folder.id
+ @refs.ctx.close!
+
+ @new-window = ~>
+ @browser.new-window @folder.id
+ @refs.ctx.close!
+
+ @create-folder = ~>
+ @browser.create-folder!
+ @refs.ctx.close!
+
+ @upload = ~>
+ @browser.select-lcoal-file!
+ @refs.ctx.close!
+
+ @rename = ~>
+ @refs.ctx.close!
+
+ name <~ @input-dialog do
+ 'フォルダ名の変更'
+ '新しいフォルダ名を入力してください'
+ @folder.name
+
+ @api \drive/folders/update do
+ folder_id: @folder.id
+ name: name
+ .then ~>
+ # something
+ .catch (err) ~>
+ console.error err
diff --git a/src/web/app/desktop/tags/drive/folder.tag b/src/web/app/desktop/tags/drive/folder.tag
new file mode 100644
index 0000000000..0f3b06d543
--- /dev/null
+++ b/src/web/app/desktop/tags/drive/folder.tag
@@ -0,0 +1,183 @@
+mk-drive-browser-folder(data-is-contextmenu-showing={ is-contextmenu-showing.toString() }, data-draghover={ draghover.toString() }, onclick={ onclick }, onmouseover={ onmouseover }, onmouseout={ onmouseout }, ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop }, oncontextmenu={ oncontextmenu }, draggable='true', ondragstart={ ondragstart }, ondragend={ ondragend }, title={ title })
+ p.name
+ i.fa.fa-fw(class={ fa-folder-o: !hover, fa-folder-open-o: hover })
+ | { folder.name }
+
+style.
+ display block
+ margin 4px
+ padding 8px
+ width 144px
+ height 64px
+ background lighten($theme-color, 95%)
+ border-radius 4px
+
+ &, *
+ cursor pointer
+
+ *
+ pointer-events none
+
+ &:hover
+ background lighten($theme-color, 90%)
+
+ &:active
+ background lighten($theme-color, 85%)
+
+ &[data-is-contextmenu-showing='true']
+ &[data-draghover='true']
+ &:after
+ content ""
+ pointer-events none
+ position absolute
+ top -4px
+ right -4px
+ bottom -4px
+ left -4px
+ border 2px dashed rgba($theme-color, 0.3)
+ border-radius 4px
+
+ &[data-draghover='true']
+ background lighten($theme-color, 90%)
+
+ > .name
+ margin 0
+ font-size 0.9em
+ color darken($theme-color, 30%)
+
+ > i
+ margin-right 4px
+ margin-left 2px
+ text-align left
+
+script.
+ @mixin \api
+ @mixin \dialog
+
+ @folder = @opts.folder
+ @browser = @parent
+
+ @title = @folder.name
+ @hover = false
+ @draghover = false
+ @is-contextmenu-showing = false
+
+ @onclick = ~>
+ @browser.move @folder
+
+ @onmouseover = ~>
+ @hover = true
+
+ @onmouseout = ~>
+ @hover = false
+
+ @ondragover = (e) ~>
+ e.prevent-default!
+ e.stop-propagation!
+
+ # 自分自身がドラッグされていない場合
+ if !@is-dragging
+ # ドラッグされてきたものがファイルだったら
+ if e.data-transfer.effect-allowed == \all
+ e.data-transfer.drop-effect = \copy
+ else
+ e.data-transfer.drop-effect = \move
+ else
+ # 自分自身にはドロップさせない
+ e.data-transfer.drop-effect = \none
+ return false
+
+ @ondragenter = ~>
+ if !@is-dragging
+ @draghover = true
+
+ @ondragleave = ~>
+ @draghover = false
+
+ @ondrop = (e) ~>
+ e.stop-propagation!
+ @draghover = false
+
+ # ファイルだったら
+ if e.data-transfer.files.length > 0
+ Array.prototype.for-each.call e.data-transfer.files, (file) ~>
+ @browser.upload file, @folder
+ return false
+
+ # データ取得
+ data = e.data-transfer.get-data 'text'
+ if !data?
+ return false
+
+ # パース
+ obj = JSON.parse data
+
+ # (ドライブの)ファイルだったら
+ if obj.type == \file
+ file = obj.id
+ @browser.remove-file file
+ @api \drive/files/update do
+ file_id: file
+ folder_id: @folder.id
+ .then ~>
+ # something
+ .catch (err, text-status) ~>
+ console.error err
+
+ # (ドライブの)フォルダーだったら
+ else if obj.type == \folder
+ folder = obj.id
+ # 移動先が自分自身ならreject
+ if folder == @folder.id
+ return false
+ @browser.remove-folder folder
+ @api \drive/folders/update do
+ folder_id: folder
+ parent_id: @folder.id
+ .then ~>
+ # something
+ .catch (err) ~>
+ if err == 'detected-circular-definition'
+ @dialog do
+ '<i class="fa fa-exclamation-triangle"></i>操作を完了できません'
+ '移動先のフォルダーは、移動するフォルダーのサブフォルダーです。'
+ [
+ text: \OK
+ ]
+
+ return false
+
+ @ondragstart = (e) ~>
+ e.data-transfer.effect-allowed = \move
+ e.data-transfer.set-data 'text' JSON.stringify do
+ type: \folder
+ id: @folder.id
+ @is-dragging = true
+
+ # 親ブラウザに対して、ドラッグが開始されたフラグを立てる
+ # (=あなたの子供が、ドラッグを開始しましたよ)
+ @browser.is-drag-source = true
+
+ @ondragend = (e) ~>
+ @is-dragging = false
+ @browser.is-drag-source = false
+
+ @oncontextmenu = (e) ~>
+ e.prevent-default!
+ e.stop-immediate-propagation!
+
+ @is-contextmenu-showing = true
+ @update!
+ ctx = document.body.append-child document.create-element \mk-drive-browser-folder-contextmenu
+ ctx = riot.mount ctx, do
+ browser: @browser
+ folder: @folder
+ ctx = ctx.0
+ ctx.open do
+ x: e.page-x - window.page-x-offset
+ y: e.page-y - window.page-y-offset
+ ctx.on \closed ~>
+ @is-contextmenu-showing = false
+ @update!
+
+ return false
diff --git a/src/web/app/desktop/tags/drive/nav-folder.tag b/src/web/app/desktop/tags/drive/nav-folder.tag
new file mode 100644
index 0000000000..398a26a80b
--- /dev/null
+++ b/src/web/app/desktop/tags/drive/nav-folder.tag
@@ -0,0 +1,96 @@
+mk-drive-browser-nav-folder(data-draghover={ draghover }, onclick={ onclick }, ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop })
+ i.fa.fa-cloud(if={ folder == null })
+ span { folder == null ? 'ドライブ' : folder.name }
+
+style.
+ &[data-draghover]
+ background #eee
+
+script.
+ @mixin \api
+
+ # Riotのバグでnullを渡しても""になる
+ # https://github.com/riot/riot/issues/2080
+ #@folder = @opts.folder
+ @folder = if @opts.folder? and @opts.folder != '' then @opts.folder else null
+ @browser = @parent
+
+ @hover = false
+
+ @onclick = ~>
+ @browser.move @folder
+
+ @onmouseover = ~>
+ @hover = true
+
+ @onmouseout = ~>
+ @hover = false
+
+ @ondragover = (e) ~>
+ e.prevent-default!
+ e.stop-propagation!
+
+ # このフォルダがルートかつカレントディレクトリならドロップ禁止
+ if @folder == null and @browser.folder == null
+ e.data-transfer.drop-effect = \none
+ # ドラッグされてきたものがファイルだったら
+ else if e.data-transfer.effect-allowed == \all
+ e.data-transfer.drop-effect = \copy
+ else
+ e.data-transfer.drop-effect = \move
+ return false
+
+ @ondragenter = ~>
+ if @folder != null or @browser.folder != null
+ @draghover = true
+
+ @ondragleave = ~>
+ if @folder != null or @browser.folder != null
+ @draghover = false
+
+ @ondrop = (e) ~>
+ e.stop-propagation!
+ @draghover = false
+
+ # ファイルだったら
+ if e.data-transfer.files.length > 0
+ Array.prototype.for-each.call e.data-transfer.files, (file) ~>
+ @browser.upload file, @folder
+ return false
+
+ # データ取得
+ data = e.data-transfer.get-data 'text'
+ if !data?
+ return false
+
+ # パース
+ obj = JSON.parse data
+
+ # (ドライブの)ファイルだったら
+ if obj.type == \file
+ file = obj.id
+ @browser.remove-file file
+ @api \drive/files/update do
+ file_id: file
+ folder_id: if @folder? then @folder.id else null
+ .then ~>
+ # something
+ .catch (err, text-status) ~>
+ console.error err
+
+ # (ドライブの)フォルダーだったら
+ else if obj.type == \folder
+ folder = obj.id
+ # 移動先が自分自身ならreject
+ if @folder? and folder == @folder.id
+ return false
+ @browser.remove-folder folder
+ @api \drive/folders/update do
+ folder_id: folder
+ parent_id: if @folder? then @folder.id else null
+ .then ~>
+ # something
+ .catch (err, text-status) ~>
+ console.error err
+
+ return false
diff --git a/src/web/app/desktop/tags/ellipsis-icon.tag b/src/web/app/desktop/tags/ellipsis-icon.tag
new file mode 100644
index 0000000000..5d18bc0473
--- /dev/null
+++ b/src/web/app/desktop/tags/ellipsis-icon.tag
@@ -0,0 +1,34 @@
+mk-ellipsis-icon
+ div
+ div
+ div
+
+style.
+ display block
+ width 70px
+ margin 0 auto
+ text-align center
+
+ > div
+ display inline-block
+ width 18px
+ height 18px
+ background-color rgba(0, 0, 0, 0.3)
+ border-radius 100%
+ animation bounce 1.4s infinite ease-in-out both
+
+ &:nth-child(1)
+ animation-delay 0s
+
+ &:nth-child(2)
+ margin 0 6px
+ animation-delay 0.16s
+
+ &:nth-child(3)
+ animation-delay 0.32s
+
+ @keyframes bounce
+ 0%, 80%, 100%
+ transform scale(0)
+ 40%
+ transform scale(1)
diff --git a/src/web/app/desktop/tags/follow-button.tag b/src/web/app/desktop/tags/follow-button.tag
new file mode 100644
index 0000000000..41bd1f0e12
--- /dev/null
+++ b/src/web/app/desktop/tags/follow-button.tag
@@ -0,0 +1,127 @@
+mk-follow-button
+ button(if={ !init }, class={ wait: wait, follow: !user.is_following, unfollow: user.is_following },
+ onclick={ onclick },
+ disabled={ wait },
+ title={ user.is_following ? 'フォロー解除' : 'フォローする' })
+ i.fa.fa-minus(if={ !wait && user.is_following })
+ i.fa.fa-plus(if={ !wait && !user.is_following })
+ i.fa.fa-spinner.fa-pulse.fa-fw(if={ wait })
+ div.init(if={ init }): i.fa.fa-spinner.fa-pulse.fa-fw
+
+style.
+ display block
+
+ > button
+ > .init
+ display block
+ cursor pointer
+ padding 0
+ margin 0
+ width 32px
+ height 32px
+ font-size 1em
+ outline none
+ border-radius 4px
+
+ *
+ pointer-events none
+
+ &: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
+
+ &.follow
+ 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
+
+ &.unfollow
+ 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
+
+ &.wait
+ cursor wait !important
+ opacity 0.7
+
+script.
+ @mixin \api
+ @mixin \is-promise
+ @mixin \stream
+
+ @user = null
+ @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user
+ @init = true
+ @wait = false
+
+ @on \mount ~>
+ @user-promise.then (user) ~>
+ @user = user
+ @init = false
+ @update!
+ @stream.on \follow @on-stream-follow
+ @stream.on \unfollow @on-stream-unfollow
+
+ @on \unmount ~>
+ @stream.off \follow @on-stream-follow
+ @stream.off \unfollow @on-stream-unfollow
+
+ @on-stream-follow = (user) ~>
+ if user.id == @user.id
+ @user = user
+ @update!
+
+ @on-stream-unfollow = (user) ~>
+ if user.id == @user.id
+ @user = user
+ @update!
+
+ @onclick = ~>
+ @wait = true
+ if @user.is_following
+ @api \following/delete do
+ user_id: @user.id
+ .then ~>
+ @user.is_following = false
+ .catch (err) ->
+ console.error err
+ .then ~>
+ @wait = false
+ @update!
+ else
+ @api \following/create do
+ user_id: @user.id
+ .then ~>
+ @user.is_following = true
+ .catch (err) ->
+ console.error err
+ .then ~>
+ @wait = false
+ @update!
diff --git a/src/web/app/desktop/tags/following-setuper.tag b/src/web/app/desktop/tags/following-setuper.tag
new file mode 100644
index 0000000000..9b75a251e0
--- /dev/null
+++ b/src/web/app/desktop/tags/following-setuper.tag
@@ -0,0 +1,163 @@
+mk-following-setuper
+ p.title 気になるユーザーをフォロー:
+ div.users(if={ !loading && users.length > 0 })
+ div.user(each={ users })
+ a.avatar-anchor(href={ CONFIG.url + '/' + username })
+ img.avatar(src={ avatar_url + '?thumbnail&size=42' }, alt='', data-user-preview={ id })
+ div.body
+ a.name(href={ CONFIG.url + '/' + username }, target='_blank', data-user-preview={ id }) { name }
+ p.username @{ username }
+ mk-follow-button(user={ this })
+ p.empty(if={ !loading && users.length == 0 })
+ | おすすめのユーザーは見つかりませんでした。
+ p.loading(if={ loading })
+ i.fa.fa-spinner.fa-pulse.fa-fw
+ | 読み込んでいます
+ mk-ellipsis
+ a.refresh(onclick={ refresh }) もっと見る
+ button.close(onclick={ close }, title='閉じる'): i.fa.fa-times
+
+style.
+ display block
+ padding 24px
+ background #fff
+
+ > .title
+ margin 0 0 12px 0
+ font-size 1em
+ font-weight bold
+ color #888
+
+ > .users
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .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
+
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > .loading
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+ > .refresh
+ display block
+ margin 0 8px 0 0
+ text-align right
+ font-size 0.9em
+ color #999
+
+ > .close
+ cursor pointer
+ display block
+ position absolute
+ top 6px
+ right 6px
+ z-index 1
+ margin 0
+ padding 0
+ font-size 1.2em
+ color #999
+ border none
+ outline none
+ background transparent
+
+ &:hover
+ color #555
+
+ &:active
+ color #222
+
+ > i
+ padding 14px
+
+script.
+ @mixin \api
+ @mixin \user-preview
+
+ @users = null
+ @loading = true
+
+ @limit = 6users
+ @page = 0
+
+ @on \mount ~>
+ @load!
+
+ @load = ~>
+ @loading = true
+ @users = null
+ @update!
+
+ @api \users/recommendation do
+ limit: @limit
+ offset: @limit * @page
+ .then (users) ~>
+ @loading = false
+ @users = users
+ @update!
+ .catch (err, text-status) ->
+ console.error err
+
+ @refresh = ~>
+ if @users.length < @limit
+ @page = 0
+ else
+ @page++
+ @load!
+
+ @close = ~>
+ @unmount!
diff --git a/src/web/app/desktop/tags/go-top.tag b/src/web/app/desktop/tags/go-top.tag
new file mode 100644
index 0000000000..a11f4a3640
--- /dev/null
+++ b/src/web/app/desktop/tags/go-top.tag
@@ -0,0 +1,15 @@
+mk-go-top
+ button.hidden(title='一番上へ')
+ i.fa.fa-angle-up
+
+script.
+
+ window.add-event-listener \load @on-scroll
+ window.add-event-listener \scroll @on-scroll
+ window.add-event-listener \resize @on-scroll
+
+ @on-scroll = ~>
+ if $ window .scroll-top! > 500px
+ @remove-class \hidden
+ else
+ @add-class \hidden
diff --git a/src/web/app/desktop/tags/home-widgets/broadcast.tag b/src/web/app/desktop/tags/home-widgets/broadcast.tag
new file mode 100644
index 0000000000..43f1251643
--- /dev/null
+++ b/src/web/app/desktop/tags/home-widgets/broadcast.tag
@@ -0,0 +1,75 @@
+mk-broadcast-home-widget
+ div.icon
+ svg(height='32', version='1.1', viewBox='0 0 32 32', width='32')
+ path.tower(d='M16.04,11.24c1.79,0,3.239-1.45,3.239-3.24S17.83,4.76,16.04,4.76c-1.79,0-3.24,1.45-3.24,3.24 C12.78,9.78,14.24,11.24,16.04,11.24z M16.04,13.84c-0.82,0-1.66-0.2-2.4-0.6L7.34,29.98h2.98l1.72-2h8l1.681,2H24.7L18.42,13.24 C17.66,13.64,16.859,13.84,16.04,13.84z M16.02,14.8l2.02,7.2h-4L16.02,14.8z M12.04,25.98l2-2h4l2,2H12.04z')
+ path.wave.a(d='M4.66,1.04c-0.508-0.508-1.332-0.508-1.84,0c-1.86,1.92-2.8,4.44-2.8,6.94c0,2.52,0.94,5.04,2.8,6.96 c0.5,0.52,1.32,0.52,1.82,0s0.5-1.36,0-1.88C3.28,11.66,2.6,9.82,2.6,7.98S3.28,4.3,4.64,2.9C5.157,2.391,5.166,1.56,4.66,1.04z')
+ path.wave.b(d='M9.58,12.22c0.5-0.5,0.5-1.34,0-1.84C8.94,9.72,8.62,8.86,8.62,8s0.32-1.72,0.96-2.38c0.5-0.52,0.5-1.34,0-1.84 C9.346,3.534,9.02,3.396,8.68,3.4c-0.32,0-0.66,0.12-0.9,0.38C6.64,4.94,6.08,6.48,6.08,8s0.58,3.06,1.7,4.22 C8.28,12.72,9.1,12.72,9.58,12.22z')
+ path.wave.c(d='M22.42,3.78c-0.5,0.5-0.5,1.34,0,1.84c0.641,0.66,0.96,1.52,0.96,2.38s-0.319,1.72-0.96,2.38c-0.5,0.52-0.5,1.34,0,1.84 c0.487,0.497,1.285,0.505,1.781,0.018c0.007-0.006,0.013-0.012,0.02-0.018c1.139-1.16,1.699-2.7,1.699-4.22s-0.561-3.06-1.699-4.22 c-0.494-0.497-1.297-0.5-1.794-0.007C22.424,3.775,22.422,3.778,22.42,3.78z')
+ path.wave.d(d='M29.18,1.06c-0.479-0.502-1.273-0.522-1.775-0.044c-0.016,0.015-0.029,0.029-0.045,0.044c-0.5,0.52-0.5,1.36,0,1.88 c1.361,1.4,2.041,3.24,2.041,5.08s-0.68,3.66-2.041,5.08c-0.5,0.52-0.5,1.36,0,1.88c0.509,0.508,1.332,0.508,1.841,0 c1.86-1.92,2.8-4.44,2.8-6.96C31.99,5.424,30.98,2.931,29.18,1.06z')
+
+ h1 開発者募集中!
+ p: a(href='https://github.com/syuilo/misskey', target='_blank') Misskeyはオープンソースで開発されています。Webのリポジトリはこちら
+
+style.
+ display block
+ padding 10px 10px 10px 50px
+ background transparent
+ border-color #4078c0 !important
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .icon
+ display block
+ float left
+ margin-left -40px
+
+ > svg
+ fill currentColor
+ color #4078c0
+
+ > .wave
+ opacity 1
+
+ &.a
+ animation wave 20s ease-in-out 2.1s infinite
+ &.b
+ animation wave 20s ease-in-out 2s infinite
+ &.c
+ animation wave 20s ease-in-out 2s infinite
+ &.d
+ animation wave 20s ease-in-out 2.1s infinite
+
+ @keyframes wave
+ 0%
+ opacity 1
+ 1.5%
+ opacity 0
+ 3.5%
+ opacity 0
+ 5%
+ opacity 1
+ 6.5%
+ opacity 0
+ 8.5%
+ opacity 0
+ 10%
+ opacity 1
+
+ > h1
+ margin 0
+ font-size 0.95em
+ font-weight normal
+ color #4078c0
+
+ > p
+ display block
+ z-index 1
+ margin 0
+ font-size 0.7em
+ color #555
+
+ a
+ color #555
diff --git a/src/web/app/desktop/tags/home-widgets/calendar.tag b/src/web/app/desktop/tags/home-widgets/calendar.tag
new file mode 100644
index 0000000000..26cea1c699
--- /dev/null
+++ b/src/web/app/desktop/tags/home-widgets/calendar.tag
@@ -0,0 +1,147 @@
+mk-calendar-home-widget(data-special={ special })
+ div.calendar(data-is-holiday={ is-holiday })
+ p.month-and-year
+ span.year { year }年
+ span.month { month }月
+ p.day { day }日
+ p.week-day { week-day }曜日
+ div.info
+ div
+ p
+ | 今日:
+ b { day-p.to-fixed(1) }%
+ div.meter
+ div.val(style={ 'width:' + day-p + '%' })
+
+ div
+ p
+ | 今月:
+ b { month-p.to-fixed(1) }%
+ div.meter
+ div.val(style={ 'width:' + month-p + '%' })
+
+ div
+ p
+ | 今年:
+ b { year-p.to-fixed(1) }%
+ div.meter
+ div.val(style={ 'width:' + year-p + '%' })
+
+style.
+ display block
+ padding 16px 0
+ color #777
+ background #fff
+
+ &[data-special='on-new-years-day']
+ border-color #ef95a0 !important
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .calendar
+ float left
+ width 60%
+ text-align center
+
+ &[data-is-holiday]
+ > .day
+ color #ef95a0
+
+ > p
+ margin 0
+ line-height 18px
+ font-size 14px
+
+ > span
+ margin 0 4px
+
+ > .day
+ margin 10px 0
+ line-height 32px
+ font-size 28px
+
+ > .info
+ display block
+ float left
+ width 40%
+ padding 0 16px 0 0
+
+ > div
+ margin-bottom 8px
+
+ &:last-child
+ margin-bottom 4px
+
+ > p
+ margin 0 0 2px 0
+ font-size 12px
+ line-height 18px
+ color #888
+
+ > b
+ margin-left 2px
+
+ > .meter
+ width 100%
+ overflow hidden
+ background #eee
+ border-radius 8px
+
+ > .val
+ height 4px
+ background $theme-color
+
+ &:nth-child(1)
+ > .meter > .val
+ background #f7796c
+
+ &:nth-child(2)
+ > .meter > .val
+ background #a1de41
+
+ &:nth-child(3)
+ > .meter > .val
+ background #41ddde
+
+script.
+ @draw = ~>
+ now = new Date!
+ nd = now.get-date!
+ nm = now.get-month!
+ ny = now.get-full-year!
+
+ @year = ny
+ @month = nm + 1
+ @day = nd
+ @week-day = [\日 \月 \火 \水 \木 \金 \土][now.get-day!]
+
+ @day-numer = (now - (new Date ny, nm, nd))
+ @day-denom = 1000ms * 60s * 60m * 24h
+ @month-numer = (now - (new Date ny, nm, 1))
+ @month-denom = (new Date ny, nm + 1, 1) - (new Date ny, nm, 1)
+ @year-numer = (now - (new Date ny, 0, 0))
+ @year-denom = (new Date ny + 1, 0, 0) - (new Date ny, 0, 0)
+
+ @day-p = @day-numer / @day-denom * 100
+ @month-p = @month-numer / @month-denom * 100
+ @year-p = @year-numer / @year-denom * 100
+
+ @is-holiday =
+ (now.get-day! == 0 or now.get-day! == 6)
+
+ @special =
+ | nm == 0 and nd == 1 => \on-new-years-day
+ | _ => false
+
+ @update!
+
+ @draw!
+
+ @on \mount ~>
+ @clock = set-interval @draw, 1000ms
+
+ @on \unmount ~>
+ clear-interval @clock
diff --git a/src/web/app/desktop/tags/home-widgets/donation.tag b/src/web/app/desktop/tags/home-widgets/donation.tag
new file mode 100644
index 0000000000..a41bd9f8a4
--- /dev/null
+++ b/src/web/app/desktop/tags/home-widgets/donation.tag
@@ -0,0 +1,37 @@
+mk-donation-home-widget
+ article
+ h1
+ i.fa.fa-heart
+ | 寄付のお願い
+ p
+ | Misskeyの運営にはドメイン、サーバー等のコストが掛かります。
+ | Misskeyは広告を掲載したりしないため、 収入を皆様からの寄付に頼っています。
+ | もしご興味があれば、
+ a(href='/syuilo', data-user-preview='@syuilo') @syuilo
+ | までご連絡ください。ご協力ありがとうございます。
+
+style.
+ display block
+ background #fff
+ border-color #ead8bb !important
+
+ > article
+ padding 20px
+
+ > h1
+ margin 0 0 5px 0
+ font-size 1em
+ color #888
+
+ > i
+ margin-right 0.25em
+
+ > p
+ display block
+ z-index 1
+ margin 0
+ font-size 0.8em
+ color #999
+
+script.
+ @mixin \user-preview
diff --git a/src/web/app/desktop/tags/home-widgets/mentions.tag b/src/web/app/desktop/tags/home-widgets/mentions.tag
new file mode 100644
index 0000000000..0f0cd0269d
--- /dev/null
+++ b/src/web/app/desktop/tags/home-widgets/mentions.tag
@@ -0,0 +1,117 @@
+mk-mentions-home-widget
+ header
+ span(data-is-active={ mode == 'all' }, onclick={ set-mode.bind(this, 'all') }) すべて
+ span(data-is-active={ mode == 'following' }, onclick={ set-mode.bind(this, 'following') }) フォロー中
+ div.loading(if={ is-loading })
+ mk-ellipsis-icon
+ p.empty(if={ is-empty })
+ i.fa.fa-comments-o
+ span(if={ mode == 'all' }) あなた宛ての投稿はありません。
+ span(if={ mode == 'following' }) あなたがフォローしているユーザーからの言及はありません。
+ mk-timeline@timeline
+ <yield to="footer">
+ i.fa.fa-moon-o(if={ !parent.more-loading })
+ i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading })
+ </yield>
+
+style.
+ display block
+ background #fff
+
+ > header
+ padding 8px 16px
+ border-bottom solid 1px #eee
+
+ > span
+ margin-right 16px
+ line-height 27px
+ font-size 18px
+ color #555
+
+ &:not([data-is-active])
+ color $theme-color
+ cursor pointer
+
+ &:hover
+ text-decoration underline
+
+ > .loading
+ padding 64px 0
+
+ > .empty
+ display block
+ margin 0 auto
+ padding 32px
+ max-width 400px
+ text-align center
+ color #999
+
+ > i
+ display block
+ margin-bottom 16px
+ font-size 3em
+ color #ccc
+
+script.
+ @mixin \i
+ @mixin \api
+
+ @is-loading = true
+ @is-empty = false
+ @more-loading = false
+ @mode = \all
+
+ @on \mount ~>
+ document.add-event-listener \keydown @on-document-keydown
+ window.add-event-listener \scroll @on-scroll
+
+ @fetch ~>
+ @trigger \loaded
+
+ @on \unmount ~>
+ document.remove-event-listener \keydown @on-document-keydown
+ window.remove-event-listener \scroll @on-scroll
+
+ @on-document-keydown = (e) ~>
+ tag = e.target.tag-name.to-lower-case!
+ if tag != \input and tag != \textarea
+ if e.which == 84 # t
+ @refs.timeline.focus!
+
+ @fetch = (cb) ~>
+ @api \posts/mentions do
+ following: @mode == \following
+ .then (posts) ~>
+ @is-loading = false
+ @is-empty = posts.length == 0
+ @update!
+ @refs.timeline.set-posts posts
+ if cb? then cb!
+ .catch (err) ~>
+ console.error err
+ if cb? then cb!
+
+ @more = ~>
+ if @more-loading or @is-loading or @refs.timeline.posts.length == 0
+ return
+ @more-loading = true
+ @update!
+ @api \posts/mentions do
+ following: @mode == \following
+ max_id: @refs.timeline.tail!.id
+ .then (posts) ~>
+ @more-loading = false
+ @update!
+ @refs.timeline.prepend-posts posts
+ .catch (err) ~>
+ console.error err
+
+ @on-scroll = ~>
+ current = window.scroll-y + window.inner-height
+ if current > document.body.offset-height - 8
+ @more!
+
+ @set-mode = (mode) ~>
+ @update do
+ mode: mode
+ @fetch!
diff --git a/src/web/app/desktop/tags/home-widgets/nav.tag b/src/web/app/desktop/tags/home-widgets/nav.tag
new file mode 100644
index 0000000000..5b8e7e1b3c
--- /dev/null
+++ b/src/web/app/desktop/tags/home-widgets/nav.tag
@@ -0,0 +1,23 @@
+mk-nav-home-widget
+ a(href={ CONFIG.urls.about }) Misskeyについて
+ i ・
+ a(href={ CONFIG.urls.about + '/status' }) ステータス
+ i ・
+ a(href='https://github.com/syuilo/misskey') リポジトリ
+ i ・
+ a(href={ CONFIG.urls.dev }) 開発者
+ i ・
+ a(href='https://twitter.com/misskey_xyz', target='_blank') Follow us on <i class="fa fa-twitter"></i>
+
+style.
+ display block
+ padding 16px
+ font-size 12px
+ color #aaa
+ background #fff
+
+ a
+ color #999
+
+ i
+ color #ccc
diff --git a/src/web/app/desktop/tags/home-widgets/notifications.tag b/src/web/app/desktop/tags/home-widgets/notifications.tag
new file mode 100644
index 0000000000..588a765d02
--- /dev/null
+++ b/src/web/app/desktop/tags/home-widgets/notifications.tag
@@ -0,0 +1,49 @@
+mk-notifications-home-widget
+ p.title
+ i.fa.fa-bell-o
+ | 通知
+ button(onclick={ settings }, title='通知の設定'): i.fa.fa-cog
+ mk-notifications
+
+style.
+ display block
+ background #fff
+
+ > .title
+ z-index 1
+ margin 0
+ padding 0 16px
+ line-height 42px
+ font-size 0.9em
+ font-weight bold
+ color #888
+ box-shadow 0 1px rgba(0, 0, 0, 0.07)
+
+ > i
+ margin-right 4px
+
+ > button
+ position absolute
+ z-index 2
+ top 0
+ right 0
+ padding 0
+ width 42px
+ font-size 0.9em
+ line-height 42px
+ color #ccc
+
+ &:hover
+ color #aaa
+
+ &:active
+ color #999
+
+ > mk-notifications
+ max-height 300px
+ overflow auto
+
+script.
+ @settings = ~>
+ w = riot.mount document.body.append-child document.create-element \mk-settings-window .0
+ w.switch \notification
diff --git a/src/web/app/desktop/tags/home-widgets/photo-stream.tag b/src/web/app/desktop/tags/home-widgets/photo-stream.tag
new file mode 100644
index 0000000000..b972706574
--- /dev/null
+++ b/src/web/app/desktop/tags/home-widgets/photo-stream.tag
@@ -0,0 +1,86 @@
+mk-photo-stream-home-widget
+ p.title
+ i.fa.fa-camera
+ | フォトストリーム
+ p.initializing(if={ initializing })
+ i.fa.fa-spinner.fa-pulse.fa-fw
+ | 読み込んでいます
+ mk-ellipsis
+ div.stream(if={ !initializing && images.length > 0 })
+ virtual(each={ image in images })
+ div.img(style={ 'background-image: url(' + image.url + '?thumbnail&size=256)' })
+ p.empty(if={ !initializing && images.length == 0 })
+ | 写真はありません
+
+style.
+ display block
+ background #fff
+
+ > .title
+ z-index 1
+ margin 0
+ padding 0 16px
+ line-height 42px
+ font-size 0.9em
+ font-weight bold
+ color #888
+ box-shadow 0 1px rgba(0, 0, 0, 0.07)
+
+ > i
+ margin-right 4px
+
+ > .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
+
+ > .initializing
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+script.
+ @mixin \api
+ @mixin \stream
+
+ @images = []
+ @initializing = true
+
+ @on \mount ~>
+ @stream.on \drive_file_created @on-stream-drive-file-created
+
+ @api \drive/stream do
+ type: 'image/*'
+ limit: 9images
+ .then (images) ~>
+ @initializing = false
+ @images = images
+ @update!
+
+ @on \unmount ~>
+ @stream.off \drive_file_created @on-stream-drive-file-created
+
+ @on-stream-drive-file-created = (file) ~>
+ if /^image\/.+$/.test file.type
+ @images.unshift file
+ if @images.length > 9
+ @images.pop!
+ @update!
diff --git a/src/web/app/desktop/tags/home-widgets/profile.tag b/src/web/app/desktop/tags/home-widgets/profile.tag
new file mode 100644
index 0000000000..ae8d43c645
--- /dev/null
+++ b/src/web/app/desktop/tags/home-widgets/profile.tag
@@ -0,0 +1,55 @@
+mk-profile-home-widget
+ div.banner(style={ I.banner_url ? 'background-image: url(' + I.banner_url + '?thumbnail&size=256)' : '' }, onclick={ set-banner })
+ img.avatar(src={ I.avatar_url + '?thumbnail&size=64' }, onclick={ set-avatar }, alt='avatar', data-user-preview={ I.id })
+ a.name(href={ CONFIG.url + '/' + I.username }) { I.name }
+ p.username @{ I.username }
+
+style.
+ display block
+ background #fff
+
+ > .banner
+ height 100px
+ background-color #f5f5f5
+ background-size cover
+ background-position center
+
+ > .avatar
+ display block
+ position absolute
+ top 76px
+ left 16px
+ width 58px
+ height 58px
+ margin 0
+ border solid 3px #fff
+ border-radius 8px
+ vertical-align bottom
+
+ > .name
+ display block
+ margin 10px 0 0 92px
+ line-height 16px
+ font-weight bold
+ color #555
+
+ > .username
+ display block
+ margin 4px 0 8px 92px
+ line-height 16px
+ font-size 0.9em
+ color #999
+
+script.
+ @mixin \i
+ @mixin \user-preview
+ @mixin \update-avatar
+ @mixin \update-banner
+
+ @set-avatar = ~>
+ @update-avatar @I, (i) ~>
+ @update-i i
+
+ @set-banner = ~>
+ @update-banner @I, (i) ~>
+ @update-i i
diff --git a/src/web/app/desktop/tags/home-widgets/rss-reader.tag b/src/web/app/desktop/tags/home-widgets/rss-reader.tag
new file mode 100644
index 0000000000..b9095fb2d2
--- /dev/null
+++ b/src/web/app/desktop/tags/home-widgets/rss-reader.tag
@@ -0,0 +1,94 @@
+mk-rss-reader-home-widget
+ p.title
+ i.fa.fa-rss-square
+ | RSS
+ button(onclick={ settings }, title='設定'): i.fa.fa-cog
+ div.feed(if={ !initializing })
+ virtual(each={ item in items })
+ a(href={ item.link }, target='_blank') { item.title }
+ p.initializing(if={ initializing })
+ i.fa.fa-spinner.fa-pulse.fa-fw
+ | 読み込んでいます
+ mk-ellipsis
+
+style.
+ display block
+ background #fff
+
+ > .title
+ margin 0
+ padding 0 16px
+ line-height 42px
+ font-size 0.9em
+ font-weight bold
+ color #888
+ box-shadow 0 1px rgba(0, 0, 0, 0.07)
+
+ > i
+ margin-right 4px
+
+ > button
+ position absolute
+ top 0
+ right 0
+ padding 0
+ width 42px
+ font-size 0.9em
+ line-height 42px
+ color #ccc
+
+ &:hover
+ color #aaa
+
+ &:active
+ color #999
+
+ > .feed
+ padding 12px 16px
+ font-size 0.9em
+
+ > a
+ display block
+ padding 4px 0
+ color #666
+ border-bottom dashed 1px #eee
+
+ &:last-child
+ border-bottom none
+
+ > .initializing
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+script.
+ @mixin \api
+ @mixin \NotImplementedException
+
+ @url = 'http://news.yahoo.co.jp/pickup/rss.xml'
+ @items = []
+ @initializing = true
+
+ @on \mount ~>
+ @fetch!
+ @clock = set-interval @fetch, 60000ms
+
+ @on \unmount ~>
+ clear-interval @clock
+
+ @fetch = ~>
+ @api CONFIG.url + '/api:rss' do
+ url: @url
+ .then (feed) ~>
+ @items = feed.rss.channel.item
+ @initializing = false
+ @update!
+ .catch (err) ->
+ console.error err
+
+ @settings = ~>
+ @NotImplementedException!
diff --git a/src/web/app/desktop/tags/home-widgets/timeline.tag b/src/web/app/desktop/tags/home-widgets/timeline.tag
new file mode 100644
index 0000000000..ea2746d1ed
--- /dev/null
+++ b/src/web/app/desktop/tags/home-widgets/timeline.tag
@@ -0,0 +1,113 @@
+mk-timeline-home-widget
+ mk-following-setuper(if={ no-following })
+ div.loading(if={ is-loading })
+ mk-ellipsis-icon
+ p.empty(if={ is-empty })
+ i.fa.fa-comments-o
+ | 自分の投稿や、自分がフォローしているユーザーの投稿が表示されます。
+ mk-timeline@timeline
+ <yield to="footer">
+ i.fa.fa-moon-o(if={ !parent.more-loading })
+ i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading })
+ </yield>
+
+style.
+ display block
+ background #fff
+
+ > mk-following-setuper
+ border-bottom solid 1px #eee
+
+ > .loading
+ padding 64px 0
+
+ > .empty
+ display block
+ margin 0 auto
+ padding 32px
+ max-width 400px
+ text-align center
+ color #999
+
+ > i
+ display block
+ margin-bottom 16px
+ font-size 3em
+ color #ccc
+
+script.
+ @mixin \i
+ @mixin \api
+ @mixin \stream
+
+ @is-loading = true
+ @is-empty = false
+ @more-loading = false
+ @no-following = @I.following_count == 0
+
+ @on \mount ~>
+ @stream.on \post @on-stream-post
+ @stream.on \follow @on-stream-follow
+ @stream.on \unfollow @on-stream-unfollow
+
+ document.add-event-listener \keydown @on-document-keydown
+ window.add-event-listener \scroll @on-scroll
+
+ @load ~>
+ @trigger \loaded
+
+ @on \unmount ~>
+ @stream.off \post @on-stream-post
+ @stream.off \follow @on-stream-follow
+ @stream.off \unfollow @on-stream-unfollow
+
+ document.remove-event-listener \keydown @on-document-keydown
+ window.remove-event-listener \scroll @on-scroll
+
+ @on-document-keydown = (e) ~>
+ tag = e.target.tag-name.to-lower-case!
+ if tag != \input and tag != \textarea
+ if e.which == 84 # t
+ @refs.timeline.focus!
+
+ @load = (cb) ~>
+ @api \posts/timeline
+ .then (posts) ~>
+ @is-loading = false
+ @is-empty = posts.length == 0
+ @update!
+ @refs.timeline.set-posts posts
+ if cb? then cb!
+ .catch (err) ~>
+ console.error err
+ if cb? then cb!
+
+ @more = ~>
+ if @more-loading or @is-loading or @refs.timeline.posts.length == 0
+ return
+ @more-loading = true
+ @update!
+ @api \posts/timeline do
+ max_id: @refs.timeline.tail!.id
+ .then (posts) ~>
+ @more-loading = false
+ @update!
+ @refs.timeline.prepend-posts posts
+ .catch (err) ~>
+ console.error err
+
+ @on-stream-post = (post) ~>
+ @is-empty = false
+ @update!
+ @refs.timeline.add-post post
+
+ @on-stream-follow = ~>
+ @load!
+
+ @on-stream-unfollow = ~>
+ @load!
+
+ @on-scroll = ~>
+ current = window.scroll-y + window.inner-height
+ if current > document.body.offset-height - 8
+ @more!
diff --git a/src/web/app/desktop/tags/home-widgets/tips.tag b/src/web/app/desktop/tags/home-widgets/tips.tag
new file mode 100644
index 0000000000..9c1aa33ec0
--- /dev/null
+++ b/src/web/app/desktop/tags/home-widgets/tips.tag
@@ -0,0 +1,70 @@
+mk-tips-home-widget
+ p@tip
+ i.fa.fa-lightbulb-o
+ span@text
+
+style.
+ display block
+ background transparent !important
+ border none !important
+ overflow visible !important
+
+ > p
+ display block
+ margin 0
+ padding 0 12px
+ text-align center
+ font-size 0.7em
+ color #999
+
+ > i
+ margin-right 4px
+
+ kbd
+ display inline
+ padding 0 6px
+ margin 0 2px
+ font-size 1em
+ font-family inherit
+ border solid 1px #999
+ border-radius 2px
+
+script.
+ @tips = [
+ '<kbd>t</kbd>でタイムラインにフォーカスできます'
+ '<kbd>p</kbd>または<kbd>n</kbd>で投稿フォームを開きます'
+ '投稿フォームにはファイルをドラッグ&ドロップできます'
+ '投稿フォームにクリップボードにある画像データをペーストできます'
+ 'ドライブにファイルをドラッグ&ドロップしてアップロードできます'
+ 'ドライブでファイルをドラッグしてフォルダ移動できます'
+ 'ドライブでフォルダをドラッグしてフォルダ移動できます'
+ 'ホームをカスタマイズできます(準備中)'
+ 'MisskeyはMIT Licenseです'
+ ]
+
+ @on \mount ~>
+ @set!
+ @clock = set-interval @change, 20000ms
+
+ @on \unmount ~>
+ clear-interval @clock
+
+ @set = ~>
+ @refs.text.innerHTML = @tips[Math.floor Math.random! * @tips.length]
+ @update!
+
+ @change = ~>
+ Velocity @refs.tip, {
+ opacity: 0
+ } {
+ duration: 500ms
+ easing: \linear
+ complete: @set
+ }
+
+ Velocity @refs.tip, {
+ opacity: 1
+ } {
+ duration: 500ms
+ easing: \linear
+ }
diff --git a/src/web/app/desktop/tags/home-widgets/user-recommendation.tag b/src/web/app/desktop/tags/home-widgets/user-recommendation.tag
new file mode 100644
index 0000000000..bfb90da065
--- /dev/null
+++ b/src/web/app/desktop/tags/home-widgets/user-recommendation.tag
@@ -0,0 +1,154 @@
+mk-user-recommendation-home-widget
+ p.title
+ i.fa.fa-users
+ | おすすめユーザー
+ button(onclick={ refresh }, title='他を見る'): i.fa.fa-refresh
+ div.user(if={ !loading && users.length != 0 }, each={ _user in users })
+ a.avatar-anchor(href={ CONFIG.url + '/' + _user.username })
+ img.avatar(src={ _user.avatar_url + '?thumbnail&size=42' }, alt='', data-user-preview={ _user.id })
+ div.body
+ a.name(href={ CONFIG.url + '/' + _user.username }, data-user-preview={ _user.id }) { _user.name }
+ p.username @{ _user.username }
+ mk-follow-button(user={ _user })
+ p.empty(if={ !loading && users.length == 0 })
+ | いません!
+ p.loading(if={ loading })
+ i.fa.fa-spinner.fa-pulse.fa-fw
+ | 読み込んでいます
+ mk-ellipsis
+
+style.
+ display block
+ background #fff
+
+ > .title
+ margin 0
+ padding 0 16px
+ line-height 42px
+ font-size 0.9em
+ font-weight bold
+ color #888
+ border-bottom solid 1px #eee
+
+ > i
+ margin-right 4px
+
+ > button
+ position absolute
+ z-index 2
+ top 0
+ right 0
+ padding 0
+ width 42px
+ font-size 0.9em
+ line-height 42px
+ color #ccc
+
+ &:hover
+ color #aaa
+
+ &:active
+ color #999
+
+ > .user
+ padding 16px
+ border-bottom solid 1px #eee
+
+ &:last-child
+ border-bottom none
+
+ &: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
+ display block
+ margin 0
+ font-size 15px
+ line-height 16px
+ color #ccc
+
+ > mk-follow-button
+ position absolute
+ top 16px
+ right 16px
+
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > .loading
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+script.
+ @mixin \api
+ @mixin \user-preview
+
+ @users = null
+ @loading = true
+
+ @limit = 3users
+ @page = 0
+
+ @on \mount ~>
+ @fetch!
+ @clock = set-interval ~>
+ if @users.length < @limit
+ @fetch true
+ , 60000ms
+
+ @on \unmount ~>
+ clear-interval @clock
+
+ @fetch = (quiet = false) ~>
+ @loading = true
+ @users = null
+ if not quiet then @update!
+ @api \users/recommendation do
+ limit: @limit
+ offset: @limit * @page
+ .then (users) ~>
+ @loading = false
+ @users = users
+ @update!
+ .catch (err, text-status) ->
+ console.error err
+
+ @refresh = ~>
+ if @users.length < @limit
+ @page = 0
+ else
+ @page++
+ @fetch!
diff --git a/src/web/app/desktop/tags/home.tag b/src/web/app/desktop/tags/home.tag
new file mode 100644
index 0000000000..1ae856a6b2
--- /dev/null
+++ b/src/web/app/desktop/tags/home.tag
@@ -0,0 +1,86 @@
+mk-home
+ div.main
+ div.left@left
+ main
+ mk-timeline-home-widget@tl(if={ mode == 'timeline' })
+ mk-mentions-home-widget@tl(if={ mode == 'mentions' })
+ div.right@right
+ mk-detect-slow-internet-connection-notice
+
+style.
+ display block
+
+ > .main
+ margin 0 auto
+ max-width 1200px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > *
+ float left
+
+ > *
+ display block
+ //border solid 1px #eaeaea
+ border solid 1px rgba(0, 0, 0, 0.075)
+ border-radius 6px
+ overflow hidden
+
+ &:not(:last-child)
+ margin-bottom 16px
+
+ > main
+ padding 16px
+ width calc(100% - 275px * 2)
+
+ > *:not(main)
+ width 275px
+
+ > .left
+ padding 16px 0 16px 16px
+
+ > .right
+ padding 16px 16px 16px 0
+
+ @media (max-width 1100px)
+ > *:not(main)
+ display none
+
+ > main
+ float none
+ width 100%
+ max-width 700px
+ margin 0 auto
+
+script.
+ @mixin \i
+ @mode = @opts.mode || \timeline
+
+ # https://github.com/riot/riot/issues/2080
+ if @mode == '' then @mode = \timeline
+
+ @home = []
+
+ @on \mount ~>
+ @refs.tl.on \loaded ~>
+ @trigger \loaded
+
+ @I.data.home.for-each (widget) ~>
+ try
+ el = document.create-element \mk- + widget.name + \-home-widget
+ switch widget.place
+ | \left => @refs.left.append-child el
+ | \right => @refs.right.append-child el
+ @home.push (riot.mount el, do
+ id: widget.id
+ data: widget.data
+ .0)
+ catch e
+ # noop
+
+ @on \unmount ~>
+ @home.for-each (widget) ~>
+ widget.unmount!
diff --git a/src/web/app/desktop/tags/image-dialog.tag b/src/web/app/desktop/tags/image-dialog.tag
new file mode 100644
index 0000000000..6a3885d7c7
--- /dev/null
+++ b/src/web/app/desktop/tags/image-dialog.tag
@@ -0,0 +1,73 @@
+mk-image-dialog
+ div.bg@bg(onclick={ close })
+ img@img(src={ image.url }, alt={ image.name }, title={ image.name }, onclick={ close })
+
+style.
+ display block
+ position fixed
+ z-index 2048
+ top 0
+ left 0
+ width 100%
+ height 100%
+ opacity 0
+
+ > .bg
+ display block
+ position fixed
+ z-index 1
+ top 0
+ left 0
+ width 100%
+ height 100%
+ background rgba(0, 0, 0, 0.7)
+
+ > img
+ position fixed
+ z-index 2
+ top 0
+ right 0
+ bottom 0
+ left 0
+ max-width 100%
+ max-height 100%
+ margin auto
+ cursor zoom-out
+
+script.
+ @image = @opts.image
+
+ @on \mount ~>
+ Velocity @root, {
+ opacity: 1
+ } {
+ duration: 100ms
+ easing: \linear
+ }
+
+ #Velocity @img, {
+ # scale: 1
+ # opacity: 1
+ #} {
+ # duration: 200ms
+ # easing: \ease-out
+ #}
+
+ @close = ~>
+ Velocity @root, {
+ opacity: 0
+ } {
+ duration: 100ms
+ easing: \linear
+ complete: ~> @unmount!
+ }
+
+ #Velocity @img, {
+ # scale: 0.9
+ # opacity: 0
+ #} {
+ # duration: 200ms
+ # easing: \ease-in
+ # complete: ~>
+ # @unmount!
+ #}
diff --git a/src/web/app/desktop/tags/images-viewer.tag b/src/web/app/desktop/tags/images-viewer.tag
new file mode 100644
index 0000000000..a9939d67c4
--- /dev/null
+++ b/src/web/app/desktop/tags/images-viewer.tag
@@ -0,0 +1,43 @@
+mk-images-viewer
+ div.image@view(onmousemove={ mousemove }, style={ 'background-image: url(' + image.url + '?thumbnail' }, onclick={ click })
+ img(src={ image.url + '?thumbnail&size=512' }, alt={ image.name }, title={ image.name })
+
+style.
+ display block
+ padding 8px
+ overflow hidden
+ box-shadow 0 0 4px rgba(0, 0, 0, 0.2)
+ border-radius 4px
+
+ > .image
+ cursor zoom-in
+
+ > img
+ display block
+ max-height 256px
+ max-width 100%
+ margin 0 auto
+
+ &:hover
+ > img
+ visibility hidden
+
+ &:not(:hover)
+ background-image none !important
+
+script.
+ @images = @opts.images
+ @image = @images.0
+
+ @mousemove = (e) ~>
+ rect = @refs.view.get-bounding-client-rect!
+ mouse-x = e.client-x - rect.left
+ mouse-y = e.client-y - rect.top
+ xp = mouse-x / @refs.view.offset-width * 100
+ yp = mouse-y / @refs.view.offset-height * 100
+ @refs.view.style.background-position = xp + '% ' + yp + '%'
+
+ @click = ~>
+ dialog = document.body.append-child document.create-element \mk-image-dialog
+ riot.mount dialog, do
+ image: @image
diff --git a/src/web/app/desktop/tags/input-dialog.tag b/src/web/app/desktop/tags/input-dialog.tag
new file mode 100644
index 0000000000..62ec4f5177
--- /dev/null
+++ b/src/web/app/desktop/tags/input-dialog.tag
@@ -0,0 +1,156 @@
+mk-input-dialog
+ mk-window@window(is-modal={ true }, width={ '500px' })
+ <yield to="header">
+ i.fa.fa-i-cursor
+ | { parent.title }
+ </yield>
+ <yield to="content">
+ div.body
+ input@text(oninput={ parent.update }, onkeydown={ parent.on-keydown }, placeholder={ parent.placeholder })
+ div.action
+ button.cancel(onclick={ parent.cancel }) キャンセル
+ button.ok(disabled={ !parent.allow-empty && refs.text.value.length == 0 }, onclick={ parent.ok }) 決定
+ </yield>
+
+style.
+ display block
+
+ > mk-window
+ [data-yield='header']
+ > i
+ margin-right 4px
+
+ [data-yield='content']
+ > .body
+ padding 16px
+
+ > input
+ display block
+ padding 8px
+ margin 0
+ width 100%
+ max-width 100%
+ min-width 100%
+ font-size 1em
+ color #333
+ background #fff
+ outline none
+ border solid 1px rgba($theme-color, 0.1)
+ border-radius 4px
+ transition border-color .3s ease
+
+ &:hover
+ border-color rgba($theme-color, 0.2)
+ transition border-color .1s ease
+
+ &:focus
+ color $theme-color
+ border-color rgba($theme-color, 0.5)
+ transition border-color 0s ease
+
+ &::-webkit-input-placeholder
+ color rgba($theme-color, 0.3)
+
+ > .action
+ height 72px
+ background lighten($theme-color, 95%)
+
+ .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
+
+script.
+ @done = false
+
+ @title = @opts.title
+ @placeholder = @opts.placeholder
+ @default = @opts.default
+ @allow-empty = if @opts.allow-empty? then @opts.allow-empty else true
+
+ @on \mount ~>
+ @text = @refs.window.refs.text
+ if @default?
+ @text.value = @default
+ @text.focus!
+
+ @refs.window.on \closing ~>
+ if @done
+ @opts.on-ok @text.value
+ else
+ if @opts.on-cancel?
+ @opts.on-cancel!
+
+ @refs.window.on \closed ~>
+ @unmount!
+
+ @cancel = ~>
+ @done = false
+ @refs.window.close!
+
+ @ok = ~>
+ if not @allow-empty and @text.value == '' then return
+ @done = true
+ @refs.window.close!
+
+ @on-keydown = (e) ~>
+ if e.which == 13 # Enter
+ e.prevent-default!
+ e.stop-propagation!
+ @ok!
diff --git a/src/web/app/desktop/tags/list-user.tag b/src/web/app/desktop/tags/list-user.tag
new file mode 100644
index 0000000000..1058de22e0
--- /dev/null
+++ b/src/web/app/desktop/tags/list-user.tag
@@ -0,0 +1,100 @@
+mk-list-user
+ a.avatar-anchor(href={ CONFIG.url + '/' + user.username })
+ img.avatar(src={ user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.main
+ header
+ div.left
+ a.name(href={ CONFIG.url + '/' + user.username })
+ | { user.name }
+ span.username
+ | @{ user.username }
+ div.body
+ p.followed(if={ user.is_followed }) フォローされています
+ div.bio { user.bio }
+ mk-follow-button(user={ user })
+
+style.
+ display block
+ margin 0
+ padding 16px
+ font-size 16px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .avatar-anchor
+ display block
+ float left
+ margin 0 16px 0 0
+
+ > .avatar
+ display block
+ width 58px
+ height 58px
+ margin 0
+ border-radius 8px
+ vertical-align bottom
+
+ > .main
+ float left
+ width calc(100% - 74px)
+
+ > header
+ margin-bottom 2px
+ white-space nowrap
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .left
+ float left
+
+ > .name
+ display inline
+ margin 0
+ padding 0
+ color #777
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 0 0 8px
+ color #ccc
+
+ > .body
+ > .followed
+ display inline-block
+ margin 0 0 4px 0
+ padding 2px 8px
+ vertical-align top
+ font-size 10px
+ color #71afc7
+ background #eefaff
+ border-radius 4px
+
+ > .bio
+ cursor default
+ display block
+ margin 0
+ padding 0
+ word-wrap break-word
+ font-size 1.1em
+ color #717171
+
+ > mk-follow-button
+ position absolute
+ top 16px
+ right 16px
+
+script.
+ @user = @opts.user
diff --git a/src/web/app/desktop/tags/log-window.tag b/src/web/app/desktop/tags/log-window.tag
new file mode 100644
index 0000000000..6dabc4de34
--- /dev/null
+++ b/src/web/app/desktop/tags/log-window.tag
@@ -0,0 +1,20 @@
+mk-log-window
+ mk-window@window(width={ '600px' }, height={ '400px' })
+ <yield to="header">
+ i.fa.fa-terminal
+ | Log
+ </yield>
+ <yield to="content">
+ mk-log
+ </yield>
+
+style.
+ > mk-window
+ [data-yield='header']
+ > i
+ margin-right 4px
+
+script.
+ @on \mount ~>
+ @refs.window.on \closed ~>
+ @unmount!
diff --git a/src/web/app/desktop/tags/log.tag b/src/web/app/desktop/tags/log.tag
new file mode 100644
index 0000000000..20e5f8f699
--- /dev/null
+++ b/src/web/app/desktop/tags/log.tag
@@ -0,0 +1,62 @@
+mk-log
+ header
+ button.follow(class={ following: following }, onclick={ follow }) Follow
+ div.logs@logs
+ code(each={ logs })
+ span.date { date.getHours() + ':' + date.getMinutes() + ':' + date.getSeconds() }
+ span.message { message }
+
+style.
+ display block
+ height 100%
+ color #fff
+ background #000
+
+ > header
+ height 32px
+ background #343a42
+
+ > button
+ line-height 32px
+
+ > .follow
+ position absolute
+ top 0
+ right 0
+
+ &.following
+ color #ff0
+
+ > .logs
+ height calc(100% - 32px)
+ overflow auto
+
+ > code
+ display block
+ padding 4px 8px
+
+ &:hover
+ background rgba(#fff, 0.15)
+
+ > .date
+ margin-right 8px
+ opacity 0.5
+
+script.
+ @mixin \log
+
+ @following = true
+
+ @on \mount ~>
+ @log-event.on \log @on-log
+
+ @on \unmount ~>
+ @log-event.off \log @on-log
+
+ @follow = ~>
+ @following = true
+
+ @on-log = ~>
+ @update!
+ if @following
+ @refs.logs.scroll-top = @refs.logs.scroll-height
diff --git a/src/web/app/desktop/tags/messaging/form.tag b/src/web/app/desktop/tags/messaging/form.tag
new file mode 100644
index 0000000000..12eb0cb40f
--- /dev/null
+++ b/src/web/app/desktop/tags/messaging/form.tag
@@ -0,0 +1,162 @@
+mk-messaging-form
+ textarea@text(onkeypress={ onkeypress }, onpaste={ onpaste }, placeholder='ここにメッセージを入力')
+ div.files
+ mk-uploader@uploader
+ button.send(onclick={ send }, disabled={ sending }, title='メッセージを送信')
+ i.fa.fa-paper-plane(if={ !sending })
+ i.fa.fa-spinner.fa-spin(if={ sending })
+ button.attach-from-local(type='button', title='PCから画像を添付する')
+ i.fa.fa-upload
+ button.attach-from-drive(type='button', title='アルバムから画像を添付する')
+ i.fa.fa-folder-open
+ input(name='file', type='file', accept='image/*')
+
+style.
+ display block
+
+ > textarea
+ cursor auto
+ display block
+ width 100%
+ min-width 100%
+ max-width 100%
+ height 64px
+ margin 0
+ padding 8px
+ font-size 1em
+ color #000
+ outline none
+ border none
+ border-top solid 1px #eee
+ border-radius 0
+ box-shadow none
+ background transparent
+
+ > .send
+ position absolute
+ bottom 0
+ right 0
+ margin 0
+ padding 10px 14px
+ line-height 1em
+ font-size 1em
+ color #aaa
+ transition color 0.1s ease
+
+ &:hover
+ color $theme-color
+
+ &:active
+ color darken($theme-color, 10%)
+ transition color 0s ease
+
+ .files
+ display block
+ margin 0
+ padding 0 8px
+ list-style none
+
+ &:after
+ content ''
+ display block
+ clear both
+
+ > li
+ display block
+ float left
+ margin 4px
+ padding 0
+ width 64px
+ height 64px
+ background-color #eee
+ background-repeat no-repeat
+ background-position center center
+ background-size cover
+ cursor move
+
+ &:hover
+ > .remove
+ display block
+
+ > .remove
+ display none
+ position absolute
+ right -6px
+ top -6px
+ margin 0
+ padding 0
+ background transparent
+ outline none
+ border none
+ border-radius 0
+ box-shadow none
+ cursor pointer
+
+ .attach-from-local
+ .attach-from-drive
+ margin 0
+ padding 10px 14px
+ line-height 1em
+ font-size 1em
+ font-weight normal
+ text-decoration none
+ color #aaa
+ transition color 0.1s ease
+
+ &:hover
+ color $theme-color
+
+ &:active
+ color darken($theme-color, 10%)
+ transition color 0s ease
+
+ input[type=file]
+ display none
+
+script.
+ @mixin \api
+
+ @user = @opts.user
+
+ @onpaste = (e) ~>
+ data = e.clipboard-data
+ items = data.items
+ for i from 0 to items.length - 1
+ item = items[i]
+ switch (item.kind)
+ | \file =>
+ @upload item.get-as-file!
+
+ @onkeypress = (e) ~>
+ if (e.which == 10 || e.which == 13) && e.ctrl-key
+ @send!
+
+ @select-file = ~>
+ @refs.file.click!
+
+ @select-file-from-drive = ~>
+ browser = document.body.append-child document.create-element \mk-select-file-from-drive-window
+ event = riot.observable!
+ riot.mount browser, do
+ multiple: true
+ event: event
+ event.one \selected (files) ~>
+ files.for-each @add-file
+
+ @send = ~>
+ @sending = true
+ @api \messaging/messages/create do
+ user_id: @user.id
+ text: @refs.text.value
+ .then (message) ~>
+ @clear!
+ .catch (err) ~>
+ console.error err
+ .then ~>
+ @sending = false
+ @update!
+
+ @clear = ~>
+ @refs.text.value = ''
+ @files = []
+ @update!
diff --git a/src/web/app/desktop/tags/messaging/index.tag b/src/web/app/desktop/tags/messaging/index.tag
new file mode 100644
index 0000000000..9f57500b83
--- /dev/null
+++ b/src/web/app/desktop/tags/messaging/index.tag
@@ -0,0 +1,302 @@
+mk-messaging
+ div.search
+ div.form
+ label(for='search-input')
+ i.fa.fa-search
+ input@search-input(type='search', oninput={ search }, placeholder='ユーザーを探す')
+ div.result
+ ol.users(if={ search-result.length > 0 })
+ li(each={ user in search-result })
+ a(onclick={ user._click })
+ img.avatar(src={ user.avatar_url + '?thumbnail&size=32' }, alt='')
+ span.name { user.name }
+ span.username @{ user.username }
+ div.main
+ div.history(if={ history.length > 0 })
+ virtual(each={ history })
+ a.user(data-is-me={ is_me }, data-is-read={ is_read }, onclick={ _click }): div
+ img.avatar(src={ (is_me ? recipient.avatar_url : user.avatar_url) + '?thumbnail&size=64' }, alt='')
+ header
+ span.name { is_me ? recipient.name : user.name }
+ span.username { '@' + (is_me ? recipient.username : user.username ) }
+ mk-time(time={ created_at })
+ div.body
+ p.text
+ span.me(if={ is_me }) あなた:
+ | { text }
+ p.no-history(if={ history.length == 0 })
+ | 履歴はありません。
+ br
+ | ユーザーを検索して、いつでもメッセージを送受信できます。
+
+style.
+ display block
+
+ > .search
+ display block
+ position absolute
+ top 0
+ left 0
+ z-index 1
+ width 100%
+ background #fff
+ box-shadow 0 0px 2px rgba(0, 0, 0, 0.2)
+
+ > .form
+ padding 8px
+ background #f7f7f7
+
+ > label
+ display block
+ position absolute
+ top 0
+ left 8px
+ z-index 1
+ height 100%
+ width 38px
+ pointer-events none
+
+ > i
+ display block
+ position absolute
+ top 0
+ right 0
+ bottom 0
+ left 0
+ width 1em
+ height 1em
+ margin auto
+ color #555
+
+ > input
+ margin 0
+ padding 0 12px 0 38px
+ width 100%
+ font-size 1em
+ line-height 38px
+ color #000
+ outline none
+ border solid 1px #eee
+ border-radius 5px
+ box-shadow none
+ transition color 0.5s ease, border 0.5s ease
+
+ &:hover
+ border solid 1px #ddd
+ transition border 0.2s ease
+
+ &:focus
+ color darken($theme-color, 20%)
+ border solid 1px $theme-color
+ transition color 0, border 0
+
+ > .result
+ display block
+ top 0
+ left 0
+ z-index 2
+ width 100%
+ margin 0
+ padding 0
+ background #fff
+
+ > .users
+ margin 0
+ padding 0
+ list-style none
+
+ > li
+ > a
+ display inline-block
+ z-index 1
+ width 100%
+ padding 8px 32px
+ vertical-align top
+ white-space nowrap
+ overflow hidden
+ color rgba(0, 0, 0, 0.8)
+ text-decoration none
+ transition none
+
+ &:hover
+ color #fff
+ background $theme-color
+
+ .name
+ color #fff
+
+ .username
+ color #fff
+
+ &:active
+ color #fff
+ background darken($theme-color, 10%)
+
+ .name
+ color #fff
+
+ .username
+ color #fff
+
+ .avatar
+ vertical-align middle
+ min-width 32px
+ min-height 32px
+ max-width 32px
+ max-height 32px
+ margin 0 8px 0 0
+ border-radius 6px
+
+ .name
+ margin 0 8px 0 0
+ /*font-weight bold*/
+ font-weight normal
+ color rgba(0, 0, 0, 0.8)
+
+ .username
+ font-weight normal
+ color rgba(0, 0, 0, 0.3)
+
+ > .main
+ padding-top 56px
+
+ > .history
+
+ > a
+ display block
+ padding 20px 30px
+ text-decoration none
+ background #fff
+ border-bottom solid 1px #eee
+
+ *
+ pointer-events none
+ user-select none
+
+ &:hover
+ background #fafafa
+
+ > .avatar
+ filter saturate(200%)
+
+ &:active
+ background #eee
+
+ &[data-is-read]
+ &[data-is-me]
+ opacity 0.8
+
+ &:not([data-is-me]):not([data-is-read])
+ background-image url("/_/resources/desktop/unread.svg")
+ background-repeat no-repeat
+ background-position 0 center
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > div
+ max-width 500px
+ margin 0 auto
+
+ > header
+ margin-bottom 2px
+ white-space nowrap
+ overflow hidden
+
+ > .name
+ text-align left
+ display inline
+ margin 0
+ padding 0
+ font-size 1em
+ color rgba(0, 0, 0, 0.9)
+ font-weight bold
+ transition all 0.1s ease
+
+ > .username
+ text-align left
+ margin 0 0 0 8px
+ color rgba(0, 0, 0, 0.5)
+
+ > mk-time
+ position absolute
+ top 0
+ right 0
+ display inline
+ color rgba(0, 0, 0, 0.5)
+ font-size small
+
+ > .avatar
+ float left
+ width 54px
+ height 54px
+ margin 0 16px 0 0
+ border-radius 8px
+ transition all 0.1s ease
+
+ > .body
+
+ > .text
+ display block
+ margin 0 0 0 0
+ padding 0
+ overflow hidden
+ word-wrap break-word
+ font-size 1.1em
+ color rgba(0, 0, 0, 0.8)
+
+ .me
+ color rgba(0, 0, 0, 0.4)
+
+ > .image
+ display block
+ max-width 100%
+ max-height 512px
+
+ > .no-history
+ margin 0
+ padding 2em 1em
+ text-align center
+ color #999
+ font-weight 500
+
+script.
+ @mixin \i
+ @mixin \api
+
+ @search-result = []
+
+ @on \mount ~>
+ @api \messaging/history
+ .then (history) ~>
+ @is-loading = false
+ history.for-each (message) ~>
+ message.is_me = message.user_id == @I.id
+ message._click = ~>
+ if message.is_me
+ @trigger \navigate-user message.recipient
+ else
+ @trigger \navigate-user message.user
+ @history = history
+ @update!
+ .catch (err) ~>
+ console.error err
+
+ @search = ~>
+ q = @refs.search-input.value
+ if q == ''
+ @search-result = []
+ else
+ @api \users/search do
+ query: q
+ .then (users) ~>
+ users.for-each (user) ~>
+ user._click = ~>
+ @trigger \navigate-user user
+ @search-result = []
+ @search-result = users
+ @update!
+ .catch (err) ~>
+ console.error err
diff --git a/src/web/app/desktop/tags/messaging/message.tag b/src/web/app/desktop/tags/messaging/message.tag
new file mode 100644
index 0000000000..d7a2cc32a6
--- /dev/null
+++ b/src/web/app/desktop/tags/messaging/message.tag
@@ -0,0 +1,227 @@
+mk-messaging-message(data-is-me={ message.is_me })
+ a.avatar-anchor(href={ CONFIG.url + '/' + message.user.username }, title={ message.user.username }, target='_blank')
+ img.avatar(src={ message.user.avatar_url + '?thumbnail&size=64' }, alt='')
+ div.content-container
+ div.balloon
+ p.read(if={ message.is_me && message.is_read }) 既読
+ button.delete-button(if={ message.is_me }, title='メッセージを削除')
+ img(src='/_/resources/desktop/messaging/delete.png', alt='Delete')
+ div.content(if={ !message.is_deleted })
+ div@text
+ div.image(if={ message.file })
+ img(src={ message.file.url }, alt='image', title={ message.file.name })
+ div.content(if={ message.is_deleted })
+ p.is-deleted このメッセージは削除されました
+ footer
+ mk-time(time={ message.created_at })
+ i.fa.fa-pencil.is-edited(if={ message.is_edited })
+
+style.
+ $me-balloon-color = #23A7B6
+
+ display block
+ padding 10px 12px 10px 12px
+ background-color transparent
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .avatar-anchor
+ display block
+
+ > .avatar
+ display block
+ min-width 54px
+ min-height 54px
+ max-width 54px
+ max-height 54px
+ margin 0
+ border-radius 8px
+ transition all 0.1s ease
+
+ > .content-container
+ display block
+ margin 0 12px
+ padding 0
+ max-width calc(100% - 78px)
+
+ > .balloon
+ display block
+ float inherit
+ margin 0
+ padding 0
+ max-width 100%
+ min-height 38px
+ border-radius 16px
+
+ &:before
+ content ""
+ pointer-events none
+ display block
+ position absolute
+ top 12px
+
+ &:hover
+ > .delete-button
+ display block
+
+ > .delete-button
+ display none
+ position absolute
+ z-index 1
+ top -4px
+ right -4px
+ margin 0
+ padding 0
+ cursor pointer
+ outline none
+ border none
+ border-radius 0
+ box-shadow none
+ background transparent
+
+ > img
+ vertical-align bottom
+ width 16px
+ height 16px
+ cursor pointer
+
+ > .read
+ user-select none
+ display block
+ position absolute
+ z-index 1
+ bottom -4px
+ left -12px
+ margin 0
+ color rgba(0, 0, 0, 0.5)
+ font-size 11px
+
+ > .content
+
+ > .is-deleted
+ display block
+ margin 0
+ padding 0
+ overflow hidden
+ word-wrap break-word
+ font-size 1em
+ color rgba(0, 0, 0, 0.5)
+
+ > [ref='text']
+ display block
+ margin 0
+ padding 8px 16px
+ overflow hidden
+ word-wrap break-word
+ font-size 1em
+ color rgba(0, 0, 0, 0.8)
+
+ &, *
+ user-select text
+ cursor auto
+
+ & + .file
+ &.image
+ > img
+ border-radius 0 0 16px 16px
+
+ > .file
+ &.image
+ > img
+ display block
+ max-width 100%
+ max-height 512px
+ border-radius 16px
+
+ > footer
+ display block
+ clear both
+ margin 0
+ padding 2px
+ font-size 10px
+ color rgba(0, 0, 0, 0.4)
+
+ > .is-edited
+ margin-left 4px
+
+ &:not([data-is-me='true'])
+ > .avatar-anchor
+ float left
+
+ > .content-container
+ float left
+
+ > .balloon
+ background #eee
+
+ &:before
+ left -14px
+ border-top solid 8px transparent
+ border-right solid 8px #eee
+ border-bottom solid 8px transparent
+ border-left solid 8px transparent
+
+ > footer
+ text-align left
+
+ &[data-is-me='true']
+ > .avatar-anchor
+ float right
+
+ > .content-container
+ float right
+
+ > .balloon
+ background $me-balloon-color
+
+ &:before
+ right -14px
+ left auto
+ border-top solid 8px transparent
+ border-right solid 8px transparent
+ border-bottom solid 8px transparent
+ border-left solid 8px $me-balloon-color
+
+ > .content
+
+ > p.is-deleted
+ color rgba(255, 255, 255, 0.5)
+
+ > [ref='text']
+ &, *
+ color #fff !important
+
+ > footer
+ text-align right
+
+ &[data-is-deleted='true']
+ > .content-container
+ opacity 0.5
+
+script.
+ @mixin \i
+ @mixin \text
+
+ @message = @opts.message
+ @message.is_me = @message.user.id == @I.id
+
+ @on \mount ~>
+ if @message.text?
+ tokens = @analyze @message.text
+
+ @refs.text.innerHTML = @compile tokens
+
+ @refs.text.children.for-each (e) ~>
+ if e.tag-name == \MK-URL
+ riot.mount e
+
+ # URLをプレビュー
+ tokens
+ .filter (t) -> t.type == \link
+ .map (t) ~>
+ @preview = @refs.text.append-child document.create-element \mk-url-preview
+ riot.mount @preview, do
+ url: t.content
diff --git a/src/web/app/desktop/tags/messaging/room-window.tag b/src/web/app/desktop/tags/messaging/room-window.tag
new file mode 100644
index 0000000000..673b11419a
--- /dev/null
+++ b/src/web/app/desktop/tags/messaging/room-window.tag
@@ -0,0 +1,26 @@
+mk-messaging-room-window
+ mk-window@window(is-modal={ false }, width={ '500px' }, height={ '560px' })
+ <yield to="header">
+ i.fa.fa-comments
+ | メッセージ: { parent.user.name }
+ </yield>
+ <yield to="content">
+ mk-messaging-room(user={ parent.user })
+ </yield>
+
+style.
+ > mk-window
+ [data-yield='header']
+ > i
+ margin-right 4px
+
+ [data-yield='content']
+ > mk-messaging-room
+ height 100%
+
+script.
+ @user = @opts.user
+
+ @on \mount ~>
+ @refs.window.on \closed ~>
+ @unmount!
diff --git a/src/web/app/desktop/tags/messaging/room.tag b/src/web/app/desktop/tags/messaging/room.tag
new file mode 100644
index 0000000000..ca396d7418
--- /dev/null
+++ b/src/web/app/desktop/tags/messaging/room.tag
@@ -0,0 +1,227 @@
+mk-messaging-room
+ div.stream@stream
+ p.initializing(if={ init })
+ i.fa.fa-spinner.fa-spin
+ | 読み込み中
+ p.empty(if={ !init && messages.length == 0 })
+ i.fa.fa-info-circle
+ | このユーザーとまだ会話したことがありません
+ virtual(each={ message, i in messages })
+ mk-messaging-message(message={ message })
+ p.date(if={ i != messages.length - 1 && message._date != messages[i + 1]._date })
+ span { messages[i + 1]._datetext }
+
+ div.typings
+ footer
+ div@notifications
+ div.grippie(title='ドラッグしてフォームの広さを調整')
+ mk-messaging-form(user={ user })
+
+style.
+ display block
+
+ > .stream
+ position absolute
+ top 0
+ left 0
+ width 100%
+ height calc(100% - 100px)
+ overflow auto
+
+ > .empty
+ width 100%
+ margin 0
+ padding 16px 8px 8px 8px
+ text-align center
+ font-size 0.8em
+ color rgba(0, 0, 0, 0.4)
+
+ i
+ margin-right 4px
+
+ > .no-history
+ display block
+ margin 0
+ padding 16px
+ text-align center
+ font-size 0.8em
+ color rgba(0, 0, 0, 0.4)
+
+ i
+ margin-right 4px
+
+ > .message
+ // something
+
+ > .date
+ display block
+ margin 8px 0
+ text-align center
+
+ &:before
+ content ''
+ display block
+ position absolute
+ height 1px
+ width 90%
+ top 16px
+ left 0
+ right 0
+ margin 0 auto
+ background rgba(0, 0, 0, 0.1)
+
+ > span
+ display inline-block
+ margin 0
+ padding 0 16px
+ //font-weight bold
+ line-height 32px
+ color rgba(0, 0, 0, 0.3)
+ background #fff
+
+ > footer
+ position absolute
+ z-index 2
+ bottom 0
+ width 600px
+ max-width 100%
+ margin 0 auto
+ padding 0
+ background rgba(255, 255, 255, 0.95)
+ background-clip content-box
+
+ > [ref='notifications']
+ position absolute
+ top -48px
+ width 100%
+ padding 8px 0
+ text-align center
+
+ > p
+ display inline-block
+ margin 0
+ padding 0 12px 0 28px
+ cursor pointer
+ line-height 32px
+ font-size 12px
+ color $theme-color-foreground
+ background $theme-color
+ border-radius 16px
+ transition opacity 1s ease
+
+ > i
+ position absolute
+ top 0
+ left 10px
+ line-height 32px
+ font-size 16px
+
+ > .grippie
+ height 10px
+ margin-top -10px
+ background transparent
+ cursor ns-resize
+
+ &:hover
+ //background rgba(0, 0, 0, 0.1)
+
+ &:active
+ //background rgba(0, 0, 0, 0.2)
+
+script.
+ @mixin \i
+ @mixin \api
+ @mixin \messaging-stream
+
+ @user = @opts.user
+ @init = true
+ @sending = false
+ @messages = []
+
+ @connection = new @MessagingStreamConnection @I, @user.id
+
+ @on \mount ~>
+ @connection.event.on \message @on-message
+ @connection.event.on \read @on-read
+
+ document.add-event-listener \visibilitychange @on-visibilitychange
+
+ @api \messaging/messages do
+ user_id: @user.id
+ .then (messages) ~>
+ @init = false
+ @messages = messages.reverse!
+ @update!
+ @scroll-to-bottom!
+ .catch (err) ~>
+ console.error err
+
+ @on \unmount ~>
+ @connection.event.off \message @on-message
+ @connection.event.off \read @on-read
+ @connection.close!
+
+ document.remove-event-listener \visibilitychange @on-visibilitychange
+
+ @on \update ~>
+ @messages.for-each (message) ~>
+ date = (new Date message.created_at).get-date!
+ month = (new Date message.created_at).get-month! + 1
+ message._date = date
+ message._datetext = month + '月 ' + date + '日'
+
+ @on-message = (message) ~>
+ is-bottom = @is-bottom!
+
+ @messages.push message
+ if message.user_id != @I.id and not document.hidden
+ @connection.socket.send JSON.stringify do
+ type: \read
+ id: message.id
+ @update!
+
+ if is-bottom
+ # Scroll to bottom
+ @scroll-to-bottom!
+ else if message.user_id != @I.id
+ # Notify
+ @notify '新しいメッセージがあります'
+
+ @on-read = (ids) ~>
+ if not Array.isArray ids then ids = [ids]
+ ids.for-each (id) ~>
+ if (@messages.some (x) ~> x.id == id)
+ exist = (@messages.map (x) -> x.id).index-of id
+ @messages[exist].is_read = true
+ @update!
+
+ @is-bottom = ~>
+ current = @refs.stream.scroll-top + @refs.stream.offset-height
+ max = @refs.stream.scroll-height
+ current > (max - 32)
+
+ @scroll-to-bottom = ~>
+ @refs.stream.scroll-top = @refs.stream.scroll-height
+
+ @notify = (message) ~>
+ n = document.create-element \p
+ n.inner-HTML = '<i class="fa fa-arrow-circle-down"></i>' + message
+ n.onclick = ~>
+ @scroll-to-bottom!
+ n.parent-node.remove-child n
+ @refs.notifications.append-child n
+
+ set-timeout ~>
+ n.style.opacity = 0
+ set-timeout ~>
+ n.parent-node.remove-child n
+ , 1000ms
+ , 4000ms
+
+ @on-visibilitychange = ~>
+ if document.hidden then return
+ @messages.for-each (message) ~>
+ if message.user_id != @I.id and not message.is_read
+ @connection.socket.send JSON.stringify do
+ type: \read
+ id: message.id
diff --git a/src/web/app/desktop/tags/messaging/window.tag b/src/web/app/desktop/tags/messaging/window.tag
new file mode 100644
index 0000000000..b6979b6244
--- /dev/null
+++ b/src/web/app/desktop/tags/messaging/window.tag
@@ -0,0 +1,29 @@
+mk-messaging-window
+ mk-window@window(is-modal={ false }, width={ '500px' }, height={ '560px' })
+ <yield to="header">
+ i.fa.fa-comments
+ | メッセージ
+ </yield>
+ <yield to="content">
+ mk-messaging@index
+ </yield>
+
+style.
+ > mk-window
+ [data-yield='header']
+ > i
+ margin-right 4px
+
+ [data-yield='content']
+ > mk-messaging
+ height 100%
+
+script.
+ @on \mount ~>
+ @refs.window.on \closed ~>
+ @unmount!
+
+ @refs.window.refs.index.on \navigate-user (user) ~>
+ w = document.body.append-child document.create-element \mk-messaging-room-window
+ riot.mount w, do
+ user: user
diff --git a/src/web/app/desktop/tags/notifications.tag b/src/web/app/desktop/tags/notifications.tag
new file mode 100644
index 0000000000..d47815a89b
--- /dev/null
+++ b/src/web/app/desktop/tags/notifications.tag
@@ -0,0 +1,226 @@
+mk-notifications
+ div.notifications(if={ notifications.length != 0 })
+ virtual(each={ notification, i in notifications })
+ div.notification(class={ notification.type })
+ mk-time(time={ notification.created_at })
+
+ div.main(if={ notification.type == 'like' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id })
+ img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=48' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-thumbs-o-up
+ a(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id }) { notification.user.name }
+ a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) }
+
+ div.main(if={ notification.type == 'repost' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-retweet
+ a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name }
+ a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post.repost) }
+
+ div.main(if={ notification.type == 'quote' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-quote-left
+ a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name }
+ a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) }
+
+ div.main(if={ notification.type == 'follow' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id })
+ img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=48' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-user-plus
+ a(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id }) { notification.user.name }
+
+ div.main(if={ notification.type == 'reply' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-reply
+ a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name }
+ a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) }
+
+ div.main(if={ notification.type == 'mention' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-at
+ a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name }
+ a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) }
+
+ p.date(if={ i != notifications.length - 1 && notification._date != notifications[i + 1]._date })
+ span
+ i.fa.fa-angle-up
+ | { notification._datetext }
+ span
+ i.fa.fa-angle-down
+ | { notifications[i + 1]._datetext }
+
+ p.empty(if={ notifications.length == 0 && !loading })
+ | ありません!
+ p.loading(if={ loading })
+ i.fa.fa-spinner.fa-pulse.fa-fw
+ | 読み込んでいます
+ mk-ellipsis
+
+style.
+ display block
+
+ > .notifications
+ > .notification
+ margin 0
+ padding 16px
+ font-size 0.9em
+ border-bottom solid 1px rgba(0, 0, 0, 0.05)
+
+ &:last-child
+ border-bottom none
+
+ > mk-time
+ display inline
+ position absolute
+ top 16px
+ right 12px
+ vertical-align top
+ color rgba(0, 0, 0, 0.6)
+ font-size small
+
+ > .main
+ word-wrap break-word
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ .avatar-anchor
+ display block
+ float left
+
+ img
+ min-width 36px
+ min-height 36px
+ max-width 36px
+ max-height 36px
+ border-radius 6px
+
+ .text
+ float right
+ width calc(100% - 36px)
+ padding-left 8px
+
+ p
+ margin 0
+
+ i
+ margin-right 4px
+
+ .post-preview
+ color rgba(0, 0, 0, 0.7)
+
+ .post-ref
+ color rgba(0, 0, 0, 0.7)
+
+ &:before, &:after
+ font-family FontAwesome
+ font-size 1em
+ font-weight normal
+ font-style normal
+ display inline-block
+ margin-right 3px
+
+ &:before
+ content "\f10d"
+
+ &:after
+ content "\f10e"
+
+ &.like
+ .text p i
+ color #FFAC33
+
+ &.repost, &.quote
+ .text p i
+ color #77B255
+
+ &.follow
+ .text p i
+ color #53c7ce
+
+ &.reply, &.mention
+ .text p i
+ color #555
+
+ > .date
+ display block
+ margin 0
+ line-height 32px
+ text-align center
+ font-size 0.8em
+ color #aaa
+ background #fdfdfd
+ border-bottom solid 1px rgba(0, 0, 0, 0.05)
+
+ span
+ margin 0 16px
+
+ i
+ margin-right 8px
+
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > .loading
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+script.
+ @mixin \api
+ @mixin \stream
+ @mixin \user-preview
+ @mixin \get-post-summary
+
+ @notifications = []
+ @loading = true
+
+ @on \mount ~>
+ @api \i/notifications
+ .then (notifications) ~>
+ @notifications = notifications
+ @loading = false
+ @update!
+ .catch (err, text-status) ->
+ console.error err
+
+ @stream.on \notification @on-notification
+
+ @on \unmount ~>
+ @stream.off \notification @on-notification
+
+ @on-notification = (notification) ~>
+ @notifications.unshift notification
+ @update!
+
+ @on \update ~>
+ @notifications.for-each (notification) ~>
+ date = (new Date notification.created_at).get-date!
+ month = (new Date notification.created_at).get-month! + 1
+ notification._date = date
+ notification._datetext = month + '月 ' + date + '日'
diff --git a/src/web/app/desktop/tags/pages/entrance.tag b/src/web/app/desktop/tags/pages/entrance.tag
new file mode 100644
index 0000000000..5e18f616a3
--- /dev/null
+++ b/src/web/app/desktop/tags/pages/entrance.tag
@@ -0,0 +1,77 @@
+mk-entrance
+ main
+ img(src='/_/resources/title.svg', alt='Misskey')
+
+ mk-entrance-signin(if={ mode == 'signin' })
+ mk-entrance-signup(if={ mode == 'signup' })
+ div.introduction(if={ mode == 'introduction' })
+ mk-introduction
+ button(onclick={ signin }) わかった
+
+ mk-forkit
+
+ footer
+ mk-copyright
+
+ // ↓ https://github.com/riot/riot/issues/2134 (将来的)
+ style(data-disable-scope).
+ #wait {
+ right: auto;
+ left: 15px;
+ }
+
+style.
+ display block
+ height 100%
+
+ > main
+ display block
+
+ > img
+ display block
+ width 160px
+ height 170px
+ margin 0 auto
+ pointer-events none
+ user-select none
+
+ > .introduction
+ max-width 360px
+ margin 0 auto
+ color #777
+
+ > mk-introduction
+ padding 32px
+ background #fff
+ box-shadow 0 4px 16px rgba(0, 0, 0, 0.2)
+
+ > button
+ display block
+ margin 16px auto 0 auto
+ color #666
+
+ &:hover
+ text-decoration underline
+
+ > footer
+ > mk-copyright
+ margin 0
+ text-align center
+ line-height 64px
+ font-size 10px
+ color rgba(#000, 0.5)
+
+script.
+ @mode = \signin
+
+ @signup = ~>
+ @mode = \signup
+ @update!
+
+ @signin = ~>
+ @mode = \signin
+ @update!
+
+ @introduction = ~>
+ @mode = \introduction
+ @update!
diff --git a/src/web/app/desktop/tags/pages/entrance/signin.tag b/src/web/app/desktop/tags/pages/entrance/signin.tag
new file mode 100644
index 0000000000..8ff39bc296
--- /dev/null
+++ b/src/web/app/desktop/tags/pages/entrance/signin.tag
@@ -0,0 +1,128 @@
+mk-entrance-signin
+ a.help(href={ CONFIG.urls.about + '/help' }, title='お困りですか?'): i.fa.fa-question
+ div.form
+ h1
+ img(if={ user }, src={ user.avatar_url + '?thumbnail&size=32' })
+ p { user ? user.name : 'アカウント' }
+ mk-signin@signin
+ div.divider: span or
+ button.signup(onclick={ parent.signup }) 新規登録
+ a.introduction(onclick={ introduction }) Misskeyについて
+
+style.
+ display block
+ width 290px
+ margin 0 auto
+ text-align center
+
+ &:hover
+ > .help
+ opacity 1
+
+ > .help
+ cursor pointer
+ display block
+ position absolute
+ top 0
+ right 0
+ z-index 1
+ margin 0
+ padding 0
+ font-size 1.2em
+ color #999
+ border none
+ outline none
+ background transparent
+ opacity 0
+ transition opacity 0.1s ease
+
+ &:hover
+ color #444
+
+ &:active
+ color #222
+
+ > i
+ padding 14px
+
+ > .form
+ padding 10px 28px 16px 28px
+ background #fff
+ box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2)
+
+ > h1
+ display block
+ margin 0
+ padding 0
+ height 54px
+ line-height 54px
+ text-align center
+ text-transform uppercase
+ font-size 1em
+ font-weight bold
+ color rgba(0, 0, 0, 0.5)
+ border-bottom solid 1px rgba(0, 0, 0, 0.1)
+
+ > p
+ display inline
+ margin 0
+ padding 0
+
+ > img
+ display inline-block
+ top 10px
+ width 32px
+ height 32px
+ margin-right 8px
+ border-radius 100%
+
+ &[src='']
+ display none
+
+ > .divider
+ padding 16px 0
+ text-align center
+
+ &:after
+ content ""
+ display block
+ position absolute
+ top 50%
+ width 100%
+ height 1px
+ border-top solid 1px rgba(0, 0, 0, 0.1)
+
+ > *
+ z-index 1
+ padding 0 8px
+ color rgba(0, 0, 0, 0.5)
+ background #fdfdfd
+
+ > .signup
+ width 100%
+ line-height 56px
+ font-size 1em
+ color #fff
+ background $theme-color
+ border-radius 64px
+
+ &:hover
+ background lighten($theme-color, 5%)
+
+ &:active
+ background darken($theme-color, 5%)
+
+ > .introduction
+ display inline-block
+ margin-top 16px
+ font-size 12px
+ color #666
+
+script.
+ @on \mount ~>
+ @refs.signin.on \user (user) ~>
+ @update do
+ user: user
+
+ @introduction = ~>
+ @parent.introduction!
diff --git a/src/web/app/desktop/tags/pages/entrance/signup.tag b/src/web/app/desktop/tags/pages/entrance/signup.tag
new file mode 100644
index 0000000000..1b585f7000
--- /dev/null
+++ b/src/web/app/desktop/tags/pages/entrance/signup.tag
@@ -0,0 +1,44 @@
+mk-entrance-signup
+ mk-signup
+ button.cancel(type='button', onclick={ parent.signin }, title='キャンセル'): i.fa.fa-times
+
+style.
+ display block
+ width 368px
+ margin 0 auto
+
+ &:hover
+ > .cancel
+ opacity 1
+
+ > mk-signup
+ padding 18px 32px 0 32px
+ background #fff
+ box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2)
+
+ > .cancel
+ cursor pointer
+ display block
+ position absolute
+ top 0
+ right 0
+ z-index 1
+ margin 0
+ padding 0
+ font-size 1.2em
+ color #999
+ border none
+ outline none
+ box-shadow none
+ background transparent
+ opacity 0
+ transition opacity 0.1s ease
+
+ &:hover
+ color #555
+
+ &:active
+ color #222
+
+ > i
+ padding 14px
diff --git a/src/web/app/desktop/tags/pages/home.tag b/src/web/app/desktop/tags/pages/home.tag
new file mode 100644
index 0000000000..5d419a5802
--- /dev/null
+++ b/src/web/app/desktop/tags/pages/home.tag
@@ -0,0 +1,51 @@
+mk-home-page
+ mk-ui@ui(page={ page }): mk-home@home(mode={ parent.opts.mode })
+
+style.
+ display block
+
+ background-position center center
+ background-attachment fixed
+ background-size cover
+
+script.
+ @mixin \i
+ @mixin \api
+ @mixin \ui-progress
+ @mixin \stream
+ @mixin \get-post-summary
+
+ @unread-count = 0
+
+ @page = switch @opts.mode
+ | \timelie => \home
+ | \mentions => \mentions
+ | _ => \home
+
+ @on \mount ~>
+ @refs.ui.refs.home.on \loaded ~>
+ @Progress.done!
+
+ document.title = 'Misskey'
+ if @I.data.wallpaper
+ @api \drive/files/show do
+ file_id: @I.data.wallpaper
+ .then (file) ~>
+ @root.style.background-image = 'url(' + file.url + ')'
+ @Progress.start!
+ @stream.on \post @on-stream-post
+ document.add-event-listener \visibilitychange @window-on-visibilitychange, false
+
+ @on \unmount ~>
+ @stream.off \post @on-stream-post
+ document.remove-event-listener \visibilitychange @window-on-visibilitychange
+
+ @on-stream-post = (post) ~>
+ if document.hidden and post.user_id !== @I.id
+ @unread-count++
+ document.title = '(' + @unread-count + ') ' + @get-post-summary post
+
+ @window-on-visibilitychange = ~>
+ if !document.hidden
+ @unread-count = 0
+ document.title = 'Misskey'
diff --git a/src/web/app/desktop/tags/pages/not-found.tag b/src/web/app/desktop/tags/pages/not-found.tag
new file mode 100644
index 0000000000..fe23cc6fa4
--- /dev/null
+++ b/src/web/app/desktop/tags/pages/not-found.tag
@@ -0,0 +1,46 @@
+mk-not-found
+ mk-ui
+ main
+ h1 Not Found
+ img(src='/_/resources/rogge.jpg', alt='')
+ div.mask
+
+style.
+ display block
+
+ main
+ display block
+ width 600px
+ margin 32px auto
+
+ > img
+ display block
+ width 600px
+ height 459px
+ pointer-events none
+ user-select none
+ border-radius 16px
+ box-shadow 0 0 16px rgba(0, 0, 0, 0.1)
+
+ > h1
+ display block
+ margin 0
+ padding 0
+ position absolute
+ top 260px
+ left 225px
+ transform rotate(-12deg)
+ z-index 2
+ color #444
+ font-size 24px
+ line-height 20px
+
+ > .mask
+ position absolute
+ top 262px
+ left 217px
+ width 126px
+ height 18px
+ transform rotate(-12deg)
+ background #D6D5DA
+ border-radius 2px 6px 7px 6px
diff --git a/src/web/app/desktop/tags/pages/post.tag b/src/web/app/desktop/tags/pages/post.tag
new file mode 100644
index 0000000000..81ab9ce006
--- /dev/null
+++ b/src/web/app/desktop/tags/pages/post.tag
@@ -0,0 +1,25 @@
+mk-post-page
+ mk-ui@ui: main: mk-post-detail@detail(post={ parent.post })
+
+style.
+ display block
+
+ main
+ padding 16px
+
+ > mk-post-detail
+ margin 0 auto
+
+script.
+ @mixin \ui-progress
+
+ @post = @opts.post
+
+ @on \mount ~>
+ @Progress.start!
+
+ @refs.ui.refs.detail.on \post-fetched ~>
+ @Progress.set 0.5
+
+ @refs.ui.refs.detail.on \loaded ~>
+ @Progress.done!
diff --git a/src/web/app/desktop/tags/pages/search.tag b/src/web/app/desktop/tags/pages/search.tag
new file mode 100644
index 0000000000..a7878ddc0f
--- /dev/null
+++ b/src/web/app/desktop/tags/pages/search.tag
@@ -0,0 +1,14 @@
+mk-search-page
+ mk-ui@ui: mk-search@search(query={ parent.opts.query })
+
+style.
+ display block
+
+script.
+ @mixin \ui-progress
+
+ @on \mount ~>
+ @Progress.start!
+
+ @refs.ui.refs.search.on \loaded ~>
+ @Progress.done!
diff --git a/src/web/app/desktop/tags/pages/user.tag b/src/web/app/desktop/tags/pages/user.tag
new file mode 100644
index 0000000000..d41093c298
--- /dev/null
+++ b/src/web/app/desktop/tags/pages/user.tag
@@ -0,0 +1,20 @@
+mk-user-page
+ mk-ui@ui: mk-user@user(user={ parent.user }, page={ parent.opts.page })
+
+style.
+ display block
+
+script.
+ @mixin \ui-progress
+
+ @user = @opts.user
+
+ @on \mount ~>
+ @Progress.start!
+
+ @refs.ui.refs.user.on \user-fetched (user) ~>
+ @Progress.set 0.5
+ document.title = user.name + ' | Misskey'
+
+ @refs.ui.refs.user.on \loaded ~>
+ @Progress.done!
diff --git a/src/web/app/desktop/tags/post-detail-sub.tag b/src/web/app/desktop/tags/post-detail-sub.tag
new file mode 100644
index 0000000000..b7aa745737
--- /dev/null
+++ b/src/web/app/desktop/tags/post-detail-sub.tag
@@ -0,0 +1,141 @@
+mk-post-detail-sub(title={ title })
+ a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username })
+ img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ post.user_id })
+ div.main
+ header
+ div.left
+ a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id })
+ | { post.user.name }
+ span.username
+ | @{ post.user.username }
+ div.right
+ a.time(href={ url })
+ mk-time(time={ post.created_at })
+ div.body
+ div.text@text
+ div.media(if={ post.media })
+ virtual(each={ file in post.media })
+ img(src={ file.url + '?thumbnail&size=512' }, alt={ file.name }, title={ file.name })
+
+style.
+ display block
+ margin 0
+ padding 20px 32px
+ background #fdfdfd
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ &:hover
+ > .main > footer > button
+ color #888
+
+ > .avatar-anchor
+ display block
+ float left
+ margin 0 16px 0 0
+
+ > .avatar
+ display block
+ width 44px
+ height 44px
+ margin 0
+ border-radius 4px
+ vertical-align bottom
+
+ > .main
+ float left
+ width calc(100% - 60px)
+
+ > header
+ margin-bottom 4px
+ white-space nowrap
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .left
+ float left
+
+ > .name
+ display inline
+ margin 0
+ padding 0
+ color #777
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 0 0 8px
+ color #ccc
+
+ > .right
+ float right
+
+ > .time
+ font-size 0.9em
+ color #c0c0c0
+
+ > .body
+
+ > .text
+ cursor default
+ display block
+ margin 0
+ padding 0
+ word-wrap break-word
+ font-size 1em
+ color #717171
+
+ > mk-url-preview
+ margin-top 8px
+
+ > .media
+ > img
+ display block
+ max-width 100%
+
+script.
+ @mixin \api
+ @mixin \text
+ @mixin \date-stringify
+ @mixin \user-preview
+
+ @post = @opts.post
+
+ @url = CONFIG.url + '/' + @post.user.username + '/' + @post.id
+
+ @title = @date-stringify @post.created_at
+
+ @on \mount ~>
+ if @post.text?
+ tokens = @analyze @post.text
+ @refs.text.innerHTML = @compile tokens
+
+ @refs.text.children.for-each (e) ~>
+ if e.tag-name == \MK-URL
+ riot.mount e
+
+ @like = ~>
+ if @post.is_liked
+ @api \posts/likes/delete do
+ post_id: @post.id
+ .then ~>
+ @post.is_liked = false
+ @update!
+ else
+ @api \posts/likes/create do
+ post_id: @post.id
+ .then ~>
+ @post.is_liked = true
+ @update!
diff --git a/src/web/app/desktop/tags/post-detail.tag b/src/web/app/desktop/tags/post-detail.tag
new file mode 100644
index 0000000000..e071b7c704
--- /dev/null
+++ b/src/web/app/desktop/tags/post-detail.tag
@@ -0,0 +1,415 @@
+mk-post-detail(title={ title })
+
+ div.fetching(if={ fetching })
+ mk-ellipsis-icon
+
+ div.main(if={ !fetching })
+
+ button.read-more(if={ p.reply_to && p.reply_to.reply_to_id && context == null }, title='会話をもっと読み込む', onclick={ load-context }, disabled={ loading-context })
+ i.fa.fa-ellipsis-v(if={ !loading-context })
+ i.fa.fa-spinner.fa-pulse(if={ loading-context })
+
+ div.context
+ virtual(each={ post in context })
+ mk-post-detail-sub(post={ post })
+
+ div.reply-to(if={ p.reply_to })
+ mk-post-detail-sub(post={ p.reply_to })
+
+ div.repost(if={ is-repost })
+ p
+ a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=32' }, alt='avatar')
+ i.fa.fa-retweet
+ a.name(href={ CONFIG.url + '/' + post.user.username }) { post.user.name }
+ | がRepost
+
+ article
+ a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username })
+ img.avatar(src={ p.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ p.user.id })
+ header
+ a.name(href={ CONFIG.url + '/' + p.user.username }, data-user-preview={ p.user.id })
+ | { p.user.name }
+ span.username
+ | @{ p.user.username }
+ a.time(href={ url })
+ mk-time(time={ p.created_at })
+ div.body
+ div.text@text
+ div.media(if={ p.media })
+ virtual(each={ file in p.media })
+ img(src={ file.url + '?thumbnail&size=512' }, alt={ file.name }, title={ file.name })
+ footer
+ button(onclick={ reply }, title='返信')
+ i.fa.fa-reply
+ p.count(if={ p.replies_count > 0 }) { p.replies_count }
+ button(onclick={ repost }, title='Repost')
+ i.fa.fa-retweet
+ p.count(if={ p.repost_count > 0 }) { p.repost_count }
+ button(class={ liked: p.is_liked }, onclick={ like }, title='善哉')
+ i.fa.fa-thumbs-o-up
+ p.count(if={ p.likes_count > 0 }) { p.likes_count }
+ button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h
+ div.reposts-and-likes
+ div.reposts(if={ reposts && reposts.length > 0 })
+ header
+ a { p.repost_count }
+ p Repost
+ ol.users
+ li.user(each={ reposts })
+ a.avatar-anchor(href={ CONFIG.url + '/' + user.username }, title={ user.name }, data-user-preview={ user.id })
+ img.avatar(src={ user.avatar_url + '?thumbnail&size=32' }, alt='')
+ div.likes(if={ likes && likes.length > 0 })
+ header
+ a { p.likes_count }
+ p いいね
+ ol.users
+ li.user(each={ likes })
+ a.avatar-anchor(href={ CONFIG.url + '/' + username }, title={ name }, data-user-preview={ id })
+ img.avatar(src={ avatar_url + '?thumbnail&size=32' }, alt='')
+
+ div.replies
+ virtual(each={ post in replies })
+ mk-post-detail-sub(post={ post })
+
+style.
+ display block
+ margin 0
+ padding 0
+ width 640px
+ overflow hidden
+ background #fff
+ border solid 1px rgba(0, 0, 0, 0.1)
+ border-radius 8px
+
+ > .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
+
+ &:hover
+ background #f6f6f6
+
+ &:active
+ background #f0f0f0
+
+ &: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
+ border-bottom 1px solid #eef0f2
+
+ > article
+ padding 28px 32px 18px 32px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ &:hover
+ > .main > footer > button
+ color #888
+
+ > .avatar-anchor
+ display block
+ width 60px
+ height 60px
+
+ > .avatar
+ display block
+ width 60px
+ height 60px
+ margin 0
+ border-radius 8px
+ vertical-align bottom
+
+ > header
+ position absolute
+ top 28px
+ left 108px
+ width calc(100% - 108px)
+
+ > .name
+ display inline-block
+ margin 0
+ line-height 24px
+ color #777
+ font-size 18px
+ font-weight 700
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ display block
+ text-align left
+ margin 0
+ color #ccc
+
+ > .time
+ position absolute
+ top 0
+ right 32px
+ font-size 1em
+ color #c0c0c0
+
+ > .body
+ padding 8px 0
+
+ > .text
+ cursor default
+ display block
+ margin 0
+ padding 0
+ word-wrap break-word
+ font-size 1.5em
+ color #717171
+
+ > mk-url-preview
+ margin-top 8px
+
+ > .media
+ > img
+ display block
+ max-width 100%
+
+ > footer
+ font-size 1.2em
+
+ > button
+ margin 0 28px 0 0
+ padding 8px
+ background transparent
+ border none
+ font-size 1em
+ color #ddd
+ cursor pointer
+
+ &:hover
+ color #666
+
+ > .count
+ display inline
+ margin 0 0 0 8px
+ color #999
+
+ &.liked
+ color $theme-color
+
+ > .reposts-and-likes
+ display flex
+ justify-content center
+ padding 0
+ margin 16px 0
+
+ &:empty
+ display none
+
+ > .reposts
+ > .likes
+ display flex
+ flex 1 1
+ padding 0
+ border-top solid 1px #F2EFEE
+
+ > header
+ flex 1 1 80px
+ max-width 80px
+ padding 8px 5px 0px 10px
+
+ > a
+ display block
+ font-size 1.5em
+ line-height 1.4em
+
+ > p
+ display block
+ margin 0
+ font-size 0.7em
+ line-height 1em
+ font-weight normal
+ color #a0a2a5
+
+ > .users
+ display block
+ flex 1 1
+ margin 0
+ padding 10px 10px 10px 5px
+ list-style none
+
+ > .user
+ display block
+ float left
+ margin 4px
+ padding 0
+
+ > .avatar-anchor
+ display:block
+
+ > .avatar
+ vertical-align bottom
+ width 24px
+ height 24px
+ border-radius 4px
+
+ > .reposts + .likes
+ margin-left 16px
+
+ > .replies
+ > *
+ border-top 1px solid #eef0f2
+
+script.
+ @mixin \api
+ @mixin \text
+ @mixin \user-preview
+ @mixin \date-stringify
+ @mixin \NotImplementedException
+
+ @fetching = true
+ @loading-context = false
+ @content = null
+ @post = null
+
+ @on \mount ~>
+
+ @api \posts/show do
+ post_id: @opts.post
+ .then (post) ~>
+ @fetching = false
+ @post = post
+ @trigger \loaded
+
+ @is-repost = @post.repost?
+ @p = if @is-repost then @post.repost else @post
+
+ @title = @date-stringify @p.created_at
+
+ @update!
+
+ if @p.text?
+ tokens = @analyze @p.text
+ @refs.text.innerHTML = @compile tokens
+
+ @refs.text.children.for-each (e) ~>
+ if e.tag-name == \MK-URL
+ riot.mount e
+
+ # URLをプレビュー
+ tokens
+ .filter (t) -> t.type == \link
+ .map (t) ~>
+ @preview = @refs.text.append-child document.create-element \mk-url-preview
+ riot.mount @preview, do
+ url: t.content
+
+ # Get likes
+ @api \posts/likes do
+ post_id: @p.id
+ limit: 8
+ .then (likes) ~>
+ @likes = likes
+ @update!
+
+ # Get reposts
+ @api \posts/reposts do
+ post_id: @p.id
+ limit: 8
+ .then (reposts) ~>
+ @reposts = reposts
+ @update!
+
+ # Get replies
+ @api \posts/replies do
+ post_id: @p.id
+ limit: 8
+ .then (replies) ~>
+ @replies = replies
+ @update!
+
+ @update!
+
+ @reply = ~>
+ form = document.body.append-child document.create-element \mk-post-form-window
+ riot.mount form, do
+ reply: @p
+
+ @repost = ~>
+ form = document.body.append-child document.create-element \mk-repost-form-window
+ riot.mount form, do
+ post: @p
+
+ @like = ~>
+ if @p.is_liked
+ @api \posts/likes/delete do
+ post_id: @p.id
+ .then ~>
+ @p.is_liked = false
+ @update!
+ else
+ @api \posts/likes/create do
+ post_id: @p.id
+ .then ~>
+ @p.is_liked = true
+ @update!
+
+ @load-context = ~>
+ @loading-context = true
+
+ # Get context
+ @api \posts/context do
+ post_id: @p.reply_to_id
+ .then (context) ~>
+ @context = context.reverse!
+ @loading-context = false
+ @update!
diff --git a/src/web/app/desktop/tags/post-form-window.tag b/src/web/app/desktop/tags/post-form-window.tag
new file mode 100644
index 0000000000..8727777944
--- /dev/null
+++ b/src/web/app/desktop/tags/post-form-window.tag
@@ -0,0 +1,60 @@
+mk-post-form-window
+
+ mk-window@window(is-modal={ true }, colored={ true })
+
+ <yield to="header">
+ span(if={ !parent.opts.reply }) 新規投稿
+ span(if={ parent.opts.reply }) 返信
+ span.files(if={ parent.files.length != 0 }) 添付: { parent.files.length }ファイル
+ span.uploading-files(if={ parent.uploading-files.length != 0 })
+ | { parent.uploading-files.length }個のファイルをアップロード中
+ mk-ellipsis
+ </yield>
+
+ <yield to="content">
+ div.ref(if={ parent.opts.reply })
+ mk-post-preview(post={ parent.opts.reply })
+ div.body
+ mk-post-form@form(reply={ parent.opts.reply })
+ </yield>
+
+style.
+ > mk-window
+
+ [data-yield='header']
+ > .files
+ > .uploading-files
+ margin-left 8px
+ opacity 0.8
+
+ &:before
+ content '('
+
+ &:after
+ content ')'
+
+ [data-yield='content']
+ > .ref
+ > mk-post-preview
+ margin 16px 22px
+
+script.
+ @uploading-files = []
+ @files = []
+
+ @on \mount ~>
+ @refs.window.refs.form.focus!
+
+ @refs.window.on \closed ~>
+ @unmount!
+
+ @refs.window.refs.form.on \post ~>
+ @refs.window.close!
+
+ @refs.window.refs.form.on \change-uploading-files (files) ~>
+ @uploading-files = files
+ @update!
+
+ @refs.window.refs.form.on \change-files (files) ~>
+ @files = files
+ @update!
diff --git a/src/web/app/desktop/tags/post-form.tag b/src/web/app/desktop/tags/post-form.tag
new file mode 100644
index 0000000000..2248587885
--- /dev/null
+++ b/src/web/app/desktop/tags/post-form.tag
@@ -0,0 +1,430 @@
+mk-post-form(ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop })
+ textarea@text(disabled={ wait }, class={ withfiles: files.length != 0 }, oninput={ update }, onkeydown={ onkeydown }, onpaste={ onpaste }, placeholder={ opts.reply ? 'この投稿への返信...' : 'いまどうしてる?' })
+ div.attaches(if={ files.length != 0 })
+ ul.files@attaches
+ li.file(each={ files })
+ div.img(style='background-image: url({ url + "?thumbnail&size=64" })', title={ name })
+ img.remove(onclick={ _remove }, src='/_/resources/desktop/remove.png', title='添付取り消し', alt='')
+ li.add(if={ files.length < 4 }, title='PCからファイルを添付', onclick={ select-file }): i.fa.fa-plus
+ p.remain
+ | 残り{ 4 - files.length }
+ mk-uploader@uploader
+ button@upload(title='PCからファイルを添付', onclick={ select-file }): i.fa.fa-upload
+ button@drive(title='ドライブからファイルを添付', onclick={ select-file-from-drive }): i.fa.fa-cloud
+ p.text-count(class={ over: refs.text.value.length > 300 }) のこり{ 300 - refs.text.value.length }文字
+ button@submit(class={ wait: wait }, disabled={ wait || (refs.text.value.length == 0 && files.length == 0) }, onclick={ post })
+ | { wait ? '投稿中' : opts.reply ? '返信' : '投稿' }
+ mk-ellipsis(if={ wait })
+ input@file(type='file', accept='image/*', multiple, tabindex='-1', onchange={ change-file })
+ div.dropzone(if={ draghover })
+
+style.
+ display block
+ padding 16px
+ background lighten($theme-color, 95%)
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .attaches
+ margin 0
+ padding 0
+ background lighten($theme-color, 98%)
+ border solid 1px rgba($theme-color, 0.1)
+ border-top none
+ border-radius 0 0 4px 4px
+ transition border-color .3s ease
+
+ > .remain
+ display block
+ position absolute
+ top 8px
+ right 8px
+ margin 0
+ padding 0
+ color rgba($theme-color, 0.4)
+
+ > .files
+ display block
+ margin 0
+ padding 4px
+ list-style none
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .file
+ display block
+ float left
+ margin 4px
+ padding 0
+ cursor move
+
+ &:hover > .remove
+ display block
+
+ > .img
+ width 64px
+ height 64px
+ background-size cover
+ background-position center center
+
+ > .remove
+ display none
+ position absolute
+ top -6px
+ right -6px
+ width 16px
+ height 16px
+ cursor pointer
+
+ > .add
+ display block
+ float left
+ margin 4px
+ padding 0
+ border dashed 2px rgba($theme-color, 0.2)
+ cursor pointer
+
+ &:hover
+ border-color rgba($theme-color, 0.3)
+
+ > i
+ color rgba($theme-color, 0.4)
+
+ > i
+ display block
+ width 60px
+ height 60px
+ line-height 60px
+ text-align center
+ font-size 1.2em
+ color rgba($theme-color, 0.2)
+
+ > mk-uploader
+ margin 8px 0 0 0
+ padding 8px
+ border solid 1px rgba($theme-color, 0.2)
+ border-radius 4px
+
+ [ref='file']
+ display none
+
+ [ref='text']
+ display block
+ padding 12px
+ margin 0
+ width 100%
+ max-width 100%
+ min-width 100%
+ min-height calc(16px + 12px + 12px)
+ font-size 16px
+ color #333
+ background #fff
+ outline none
+ border solid 1px rgba($theme-color, 0.1)
+ border-radius 4px
+ transition border-color .3s ease
+
+ &:hover
+ border-color rgba($theme-color, 0.2)
+ transition border-color .1s ease
+
+ &:focus
+ color $theme-color
+ border-color rgba($theme-color, 0.5)
+ transition border-color 0s ease
+
+ &:disabled
+ opacity 0.5
+
+ &::-webkit-input-placeholder
+ color rgba($theme-color, 0.3)
+
+ &.withfiles
+ border-bottom solid 1px rgba($theme-color, 0.1) !important
+ border-radius 4px 4px 0 0
+
+ &:hover + .attaches
+ border-color rgba($theme-color, 0.2)
+ transition border-color .1s ease
+
+ &:focus + .attaches
+ border-color rgba($theme-color, 0.5)
+ transition border-color 0s ease
+
+ .text-count
+ pointer-events none
+ display block
+ position absolute
+ bottom 16px
+ right 138px
+ margin 0
+ line-height 40px
+ color rgba($theme-color, 0.5)
+
+ &.over
+ color #ec3828
+
+ [ref='submit']
+ display block
+ position absolute
+ bottom 16px
+ right 16px
+ cursor pointer
+ padding 0
+ margin 0
+ width 110px
+ height 40px
+ font-size 1em
+ color $theme-color-foreground
+ background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%)
+ outline none
+ border solid 1px lighten($theme-color, 15%)
+ border-radius 4px
+
+ &: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
+
+ &: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
+
+ &.wait
+ background linear-gradient(
+ 45deg,
+ darken($theme-color, 10%) 25%,
+ $theme-color 25%,
+ $theme-color 50%,
+ darken($theme-color, 10%) 50%,
+ darken($theme-color, 10%) 75%,
+ $theme-color 75%,
+ $theme-color
+ )
+ background-size 32px 32px
+ animation stripe-bg 1.5s linear infinite
+ opacity 0.7
+ cursor wait
+
+ @keyframes stripe-bg
+ from {background-position: 0 0;}
+ to {background-position: -64px 32px;}
+
+ [ref='upload']
+ [ref='drive']
+ display inline-block
+ 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 linear-gradient(to bottom, lighten($theme-color, 80%) 0%, lighten($theme-color, 90%) 100%)
+ border-color rgba($theme-color, 0.5)
+ box-shadow 0 2px 4px rgba(0, 0, 0, 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
+
+ > .dropzone
+ position absolute
+ left 0
+ top 0
+ width 100%
+ height 100%
+ border dashed 2px rgba($theme-color, 0.5)
+ pointer-events none
+
+script.
+ @mixin \api
+ @mixin \notify
+ @mixin \autocomplete
+ @mixin \sortable
+
+ @wait = false
+ @uploadings = []
+ @files = []
+ @autocomplete = null
+
+ @in-reply-to-post = @opts.reply
+
+ # https://github.com/riot/riot/issues/2080
+ if @in-reply-to-post == '' then @in-reply-to-post = null
+
+ @on \mount ~>
+ @refs.uploader.on \uploaded (file) ~>
+ @add-file file
+
+ @refs.uploader.on \change-uploads (uploads) ~>
+ @trigger \change-uploading-files uploads
+
+ @autocomplete = new @Autocomplete @refs.text
+ @autocomplete.attach!
+
+ @on \unmount ~>
+ @autocomplete.detach!
+
+ @focus = ~>
+ @refs.text.focus!
+
+ @clear = ~>
+ @refs.text.value = ''
+ @files = []
+ @trigger \change-files
+ @update!
+
+ @ondragover = (e) ~>
+ e.stop-propagation!
+ @draghover = true
+ # ドラッグされてきたものがファイルだったら
+ if e.data-transfer.effect-allowed == \all
+ e.data-transfer.drop-effect = \copy
+ else
+ e.data-transfer.drop-effect = \move
+ return false
+
+ @ondragenter = (e) ~>
+ @draghover = true
+
+ @ondragleave = (e) ~>
+ @draghover = false
+
+ @ondrop = (e) ~>
+ e.prevent-default!
+ e.stop-propagation!
+ @draghover = false
+
+ # ファイルだったら
+ if e.data-transfer.files.length > 0
+ Array.prototype.for-each.call e.data-transfer.files, (file) ~>
+ @upload file
+ return false
+
+ # データ取得
+ data = e.data-transfer.get-data 'text'
+ if !data?
+ return false
+
+ try
+ # パース
+ obj = JSON.parse data
+
+ # (ドライブの)ファイルだったら
+ if obj.type == \file
+ @add-file obj.file
+ catch
+ # ignore
+
+ return false
+
+ @onkeydown = (e) ~>
+ if (e.which == 10 || e.which == 13) && (e.ctrl-key || e.meta-key)
+ @post!
+
+ @onpaste = (e) ~>
+ data = e.clipboard-data
+ items = data.items
+ for i from 0 to items.length - 1
+ item = items[i]
+ switch (item.kind)
+ | \file =>
+ @upload item.get-as-file!
+
+ @select-file = ~>
+ @refs.file.click!
+
+ @select-file-from-drive = ~>
+ browser = document.body.append-child document.create-element \mk-select-file-from-drive-window
+ i = riot.mount browser, do
+ multiple: true
+ i[0].one \selected (files) ~>
+ files.for-each @add-file
+
+ @change-file = ~>
+ files = @refs.file.files
+ for i from 0 to files.length - 1
+ file = files.item i
+ @upload file
+
+ @upload = (file) ~>
+ @refs.uploader.upload file
+
+ @add-file = (file) ~>
+ file._remove = ~>
+ @files = @files.filter (x) -> x.id != file.id
+ @trigger \change-files @files
+ @update!
+
+ @files.push file
+ @trigger \change-files @files
+ @update!
+
+ new @Sortable @refs.attaches, do
+ draggable: \.file
+ animation: 150ms
+
+ @post = (e) ~>
+ @wait = true
+
+ files = if @files? and @files.length > 0
+ then @files.map (f) -> f.id
+ else undefined
+
+ @api \posts/create do
+ text: @refs.text.value
+ media_ids: files
+ reply_to_id: if @in-reply-to-post? then @in-reply-to-post.id else undefined
+ .then (data) ~>
+ @trigger \post
+ @notify if @in-reply-to-post? then '返信しました!' else '投稿しました!'
+ .catch (err) ~>
+ console.error err
+ @notify '投稿できませんでした'
+ .then ~>
+ @wait = false
+ @update!
diff --git a/src/web/app/desktop/tags/post-preview.tag b/src/web/app/desktop/tags/post-preview.tag
new file mode 100644
index 0000000000..f17b152801
--- /dev/null
+++ b/src/web/app/desktop/tags/post-preview.tag
@@ -0,0 +1,94 @@
+mk-post-preview(title={ title })
+ article
+ a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username })
+ img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ post.user_id })
+ div.main
+ header
+ a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id })
+ | { post.user.name }
+ span.username
+ | @{ post.user.username }
+ a.time(href={ CONFIG.url + '/' + post.user.username + '/' + post.id })
+ mk-time(time={ post.created_at })
+ div.body
+ mk-sub-post-content.text(post={ post })
+
+style.
+ display block
+ margin 0
+ padding 0
+ font-size 0.9em
+ background #fff
+
+ > article
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ &:hover
+ > .main > footer > button
+ color #888
+
+ > .avatar-anchor
+ display block
+ float left
+ margin 0 16px 0 0
+
+ > .avatar
+ display block
+ width 52px
+ height 52px
+ margin 0
+ border-radius 8px
+ vertical-align bottom
+
+ > .main
+ float left
+ width calc(100% - 68px)
+
+ > header
+ margin-bottom 4px
+ white-space nowrap
+
+ > .name
+ display inline
+ margin 0
+ padding 0
+ color #607073
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 0 0 8px
+ color #d1d8da
+
+ > .time
+ position absolute
+ top 0
+ right 0
+ color #b2b8bb
+
+ > .body
+
+ > .text
+ cursor default
+ margin 0
+ padding 0
+ font-size 1.1em
+ color #717171
+
+script.
+ @mixin \date-stringify
+ @mixin \user-preview
+
+ @post = @opts.post
+
+ @title = @date-stringify @post.created_at
diff --git a/src/web/app/desktop/tags/post-status-graph.tag b/src/web/app/desktop/tags/post-status-graph.tag
new file mode 100644
index 0000000000..ffb081e4f3
--- /dev/null
+++ b/src/web/app/desktop/tags/post-status-graph.tag
@@ -0,0 +1,72 @@
+mk-post-status-graph
+ canvas@canv(width={ opts.width }, height={ opts.height })
+
+style.
+ display block
+
+ > canvas
+ margin 0 auto
+
+script.
+ @mixin \api
+ @mixin \is-promise
+
+ @post = null
+ @post-promise = if @is-promise @opts.post then @opts.post else Promise.resolve @opts.post
+
+ @on \mount ~>
+ post <~ @post-promise.then
+ @post = post
+ @update!
+
+ @api \aggregation/posts/like do
+ post_id: @post.id
+ limit: 30days
+ .then (likes) ~>
+ likes = likes.reverse!
+
+ @api \aggregation/posts/repost do
+ post_id: @post.id
+ limit: 30days
+ .then (repost) ~>
+ repost = repost.reverse!
+
+ @api \aggregation/posts/reply do
+ post_id: @post.id
+ limit: 30days
+ .then (replies) ~>
+ replies = replies.reverse!
+
+ new Chart @refs.canv, do
+ type: \bar
+ data:
+ labels: likes.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else ''
+ datasets: [
+ {
+ label: \いいね
+ type: \line
+ data: likes.map (x) ~> x.count
+ line-tension: 0
+ border-width: 2
+ fill: true
+ background-color: 'rgba(247, 121, 108, 0.2)'
+ point-background-color: \#fff
+ point-radius: 4
+ point-border-width: 2
+ border-color: \#F7796C
+ },
+ {
+ label: \返信
+ type: \bar
+ data: replies.map (x) ~> x.count
+ background-color: \#555
+ },
+ {
+ label: \Repost
+ type: \bar
+ data: repost.map (x) ~> x.count
+ background-color: \#a2d61e
+ }
+ ]
+ options:
+ responsive: false
diff --git a/src/web/app/desktop/tags/progress-dialog.tag b/src/web/app/desktop/tags/progress-dialog.tag
new file mode 100644
index 0000000000..7c042686e6
--- /dev/null
+++ b/src/web/app/desktop/tags/progress-dialog.tag
@@ -0,0 +1,92 @@
+mk-progress-dialog
+ mk-window@window(is-modal={ false }, can-close={ false }, width={ '500px' })
+ <yield to="header">
+ | { parent.title }
+ mk-ellipsis
+ </yield>
+ <yield to="content">
+ div.body
+ p.init(if={ isNaN(parent.value) })
+ | 待機中
+ mk-ellipsis
+ p.percentage(if={ !isNaN(parent.value) }) { Math.floor((parent.value / parent.max) * 100) }
+ progress(if={ !isNaN(parent.value) && parent.value < parent.max }, value={ isNaN(parent.value) ? 0 : parent.value }, max={ parent.max })
+ div.progress.waiting(if={ parent.value >= parent.max })
+ </yield>
+
+style.
+ display block
+
+ > mk-window
+ [data-yield='content']
+
+ > .body
+ padding 18px 24px 24px 24px
+
+ > .init
+ display block
+ margin 0
+ text-align center
+ color rgba(#000, 0.7)
+
+ > .percentage
+ display block
+ margin 0 0 4px 0
+ text-align center
+ line-height 16px
+ color rgba($theme-color, 0.7)
+
+ &:after
+ content '%'
+
+ > progress
+ > .progress
+ display block
+ margin 0
+ width 100%
+ height 10px
+ background transparent
+ border none
+ border-radius 4px
+ overflow hidden
+
+ &::-webkit-progress-value
+ background $theme-color
+
+ &::-webkit-progress-bar
+ background rgba($theme-color, 0.1)
+
+ > .progress
+ background linear-gradient(
+ 45deg,
+ lighten($theme-color, 30%) 25%,
+ $theme-color 25%,
+ $theme-color 50%,
+ lighten($theme-color, 30%) 50%,
+ lighten($theme-color, 30%) 75%,
+ $theme-color 75%,
+ $theme-color
+ )
+ background-size 32px 32px
+ animation progress-dialog-tag-progress-waiting 1.5s linear infinite
+
+ @keyframes progress-dialog-tag-progress-waiting
+ from {background-position: 0 0;}
+ to {background-position: -64px 32px;}
+
+script.
+ @title = @opts.title
+ @value = parse-int @opts.value, 10
+ @max = parse-int @opts.max, 10
+
+ @on \mount ~>
+ @refs.window.on \closed ~>
+ @unmount!
+
+ @update-progress = (value, max) ~>
+ @value = parse-int value, 10
+ @max = parse-int max, 10
+ @update!
+
+ @close = ~>
+ @refs.window.close!
diff --git a/src/web/app/desktop/tags/repost-form-window.tag b/src/web/app/desktop/tags/repost-form-window.tag
new file mode 100644
index 0000000000..40012f951f
--- /dev/null
+++ b/src/web/app/desktop/tags/repost-form-window.tag
@@ -0,0 +1,38 @@
+mk-repost-form-window
+ mk-window@window(is-modal={ true }, colored={ true })
+ <yield to="header">
+ i.fa.fa-retweet
+ | この投稿をRepostしますか?
+ </yield>
+ <yield to="content">
+ mk-repost-form@form(post={ parent.opts.post })
+ </yield>
+
+style.
+ > mk-window
+ [data-yield='header']
+ > i
+ margin-right 4px
+
+script.
+
+ @on-document-keydown = (e) ~>
+ tag = e.target.tag-name.to-lower-case!
+ if tag != \input and tag != \textarea
+ if e.which == 27 # Esc
+ @refs.window.close!
+
+ @on \mount ~>
+ @refs.window.refs.form.on \cancel ~>
+ @refs.window.close!
+
+ @refs.window.refs.form.on \posted ~>
+ @refs.window.close!
+
+ document.add-event-listener \keydown @on-document-keydown
+
+ @refs.window.on \closed ~>
+ @unmount!
+
+ @on \unmount ~>
+ document.remove-event-listener \keydown @on-document-keydown
diff --git a/src/web/app/desktop/tags/repost-form.tag b/src/web/app/desktop/tags/repost-form.tag
new file mode 100644
index 0000000000..37fbad251d
--- /dev/null
+++ b/src/web/app/desktop/tags/repost-form.tag
@@ -0,0 +1,140 @@
+mk-repost-form
+ mk-post-preview(post={ opts.post })
+ div.form(if={ quote })
+ textarea@text(disabled={ wait }, placeholder='この投稿を引用...')
+ footer
+ a.quote(if={ !quote }, onclick={ onquote }) 引用する...
+ button.cancel(onclick={ cancel }) キャンセル
+ button.ok(onclick={ ok }) Repost
+
+style.
+
+ > mk-post-preview
+ margin 16px 22px
+
+ > .form
+ [ref='text']
+ display block
+ padding 12px
+ margin 0
+ width 100%
+ max-width 100%
+ min-width 100%
+ min-height calc(1em + 12px + 12px)
+ font-size 1em
+ color #333
+ background #fff
+ outline none
+ border solid 1px rgba($theme-color, 0.1)
+ border-radius 4px
+ transition border-color .3s ease
+
+ &:hover
+ border-color rgba($theme-color, 0.2)
+ transition border-color .1s ease
+
+ &:focus
+ color $theme-color
+ border-color rgba($theme-color, 0.5)
+ transition border-color 0s ease
+
+ &:disabled
+ opacity 0.5
+
+ &::-webkit-input-placeholder
+ color rgba($theme-color, 0.3)
+
+ > div
+ padding 16px
+
+ > footer
+ height 72px
+ background lighten($theme-color, 95%)
+
+ > .quote
+ position absolute
+ bottom 16px
+ left 28px
+ line-height 40px
+
+ button
+ 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
+
+ > .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
+
+ > .ok
+ right 16px
+ font-weight bold
+ 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%)
+
+ &:hover
+ background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%)
+ border-color $theme-color
+
+ &:active
+ background $theme-color
+ border-color $theme-color
+
+script.
+ @mixin \api
+ @mixin \notify
+
+ @wait = false
+ @quote = false
+
+ @cancel = ~>
+ @trigger \cancel
+
+ @ok = ~>
+ @wait = true
+ @api \posts/create do
+ repost_id: @opts.post.id
+ text: if @quote then @refs.text.value else undefined
+ .then (data) ~>
+ @trigger \posted
+ @notify 'Repostしました!'
+ .catch (err) ~>
+ console.error err
+ @notify 'Repostできませんでした'
+ .then ~>
+ @wait = false
+ @update!
+
+ @onquote = ~>
+ @quote = true
diff --git a/src/web/app/desktop/tags/search-posts.tag b/src/web/app/desktop/tags/search-posts.tag
new file mode 100644
index 0000000000..9862ff6e4e
--- /dev/null
+++ b/src/web/app/desktop/tags/search-posts.tag
@@ -0,0 +1,88 @@
+mk-search-posts
+ div.loading(if={ is-loading })
+ mk-ellipsis-icon
+ p.empty(if={ is-empty })
+ i.fa.fa-search
+ | 「{ query }」に関する投稿は見つかりませんでした。
+ mk-timeline@timeline
+ <yield to="footer">
+ i.fa.fa-moon-o(if={ !parent.more-loading })
+ i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading })
+ </yield>
+
+style.
+ display block
+ background #fff
+
+ > .loading
+ padding 64px 0
+
+ > .empty
+ display block
+ margin 0 auto
+ padding 32px
+ max-width 400px
+ text-align center
+ color #999
+
+ > i
+ display block
+ margin-bottom 16px
+ font-size 3em
+ color #ccc
+
+script.
+ @mixin \api
+ @mixin \get-post-summary
+
+ @query = @opts.query
+ @is-loading = true
+ @is-empty = false
+ @more-loading = false
+ @page = 0
+
+ @on \mount ~>
+ document.add-event-listener \keydown @on-document-keydown
+ window.add-event-listener \scroll @on-scroll
+
+ @api \posts/search do
+ query: @query
+ .then (posts) ~>
+ @is-loading = false
+ @is-empty = posts.length == 0
+ @update!
+ @refs.timeline.set-posts posts
+ @trigger \loaded
+ .catch (err) ~>
+ console.error err
+
+ @on \unmount ~>
+ document.remove-event-listener \keydown @on-document-keydown
+ window.remove-event-listener \scroll @on-scroll
+
+ @on-document-keydown = (e) ~>
+ tag = e.target.tag-name.to-lower-case!
+ if tag != \input and tag != \textarea
+ if e.which == 84 # t
+ @refs.timeline.focus!
+
+ @more = ~>
+ if @more-loading or @is-loading or @timeline.posts.length == 0
+ return
+ @more-loading = true
+ @update!
+ @api \posts/search do
+ query: @query
+ page: @page + 1
+ .then (posts) ~>
+ @more-loading = false
+ @page++
+ @update!
+ @refs.timeline.prepend-posts posts
+ .catch (err) ~>
+ console.error err
+
+ @on-scroll = ~>
+ current = window.scroll-y + window.inner-height
+ if current > document.body.offset-height - 16 # 遊び
+ @more!
diff --git a/src/web/app/desktop/tags/search.tag b/src/web/app/desktop/tags/search.tag
new file mode 100644
index 0000000000..aec426ac79
--- /dev/null
+++ b/src/web/app/desktop/tags/search.tag
@@ -0,0 +1,28 @@
+mk-search
+ header
+ h1 { query }
+ mk-search-posts@posts(query={ query })
+
+style.
+ display block
+ padding-bottom 32px
+
+ > header
+ width 100%
+ max-width 600px
+ margin 0 auto
+ color #555
+
+ > mk-search-posts
+ max-width 600px
+ margin 0 auto
+ border solid 1px rgba(0, 0, 0, 0.075)
+ border-radius 6px
+ overflow hidden
+
+script.
+ @query = @opts.query
+
+ @on \mount ~>
+ @refs.posts.on \loaded ~>
+ @trigger \loaded
diff --git a/src/web/app/desktop/tags/select-file-from-drive-window.tag b/src/web/app/desktop/tags/select-file-from-drive-window.tag
new file mode 100644
index 0000000000..5042944837
--- /dev/null
+++ b/src/web/app/desktop/tags/select-file-from-drive-window.tag
@@ -0,0 +1,160 @@
+mk-select-file-from-drive-window
+ mk-window@window(is-modal={ true }, width={ '800px' }, height={ '500px' })
+ <yield to="header">
+ mk-raw(content={ parent.title })
+ span.count(if={ parent.multiple && parent.file.length > 0 }) ({ parent.file.length }ファイル選択中)
+ </yield>
+ <yield to="content">
+ mk-drive-browser@browser(multiple={ parent.multiple })
+ div
+ button.upload(title='PCからドライブにファイルをアップロード', onclick={ parent.upload }): i.fa.fa-upload
+ button.cancel(onclick={ parent.close }) キャンセル
+ button.ok(disabled={ parent.multiple && parent.file.length == 0 }, onclick={ parent.ok }) 決定
+ </yield>
+
+style.
+ > mk-window
+ [data-yield='header']
+ > mk-raw
+ > i
+ margin-right 4px
+
+ .count
+ margin-left 8px
+ opacity 0.7
+
+ [data-yield='content']
+ > mk-drive-browser
+ height calc(100% - 72px)
+
+ > div
+ 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
+
+script.
+ @file = []
+
+ @multiple = if @opts.multiple? then @opts.multiple else false
+ @title = @opts.title || '<i class="fa fa-file-o"></i>ファイルを選択'
+
+ @on \mount ~>
+ @refs.window.refs.browser.on \selected (file) ~>
+ @file = file
+ @ok!
+
+ @refs.window.refs.browser.on \change-selection (files) ~>
+ @file = files
+ @update!
+
+ @refs.window.on \closed ~>
+ @unmount!
+
+ @close = ~>
+ @refs.window.close!
+
+ @upload = ~>
+ @refs.window.refs.browser.select-local-file!
+
+ @ok = ~>
+ @trigger \selected @file
+ @refs.window.close!
diff --git a/src/web/app/desktop/tags/set-avatar-suggestion.tag b/src/web/app/desktop/tags/set-avatar-suggestion.tag
new file mode 100644
index 0000000000..68c9c310df
--- /dev/null
+++ b/src/web/app/desktop/tags/set-avatar-suggestion.tag
@@ -0,0 +1,44 @@
+mk-set-avatar-suggestion(onclick={ set })
+ p
+ b アバターを設定
+ | してみませんか?
+ button(onclick={ close }): i.fa.fa-times
+
+style.
+ display block
+ cursor pointer
+ color #fff
+ background #a8cad0
+
+ &:hover
+ background #70abb5
+
+ > p
+ display block
+ margin 0 auto
+ padding 8px
+ max-width 1024px
+
+ > a
+ font-weight bold
+ color #fff
+
+ > button
+ position absolute
+ top 0
+ right 0
+ padding 8px
+ color #fff
+
+script.
+ @mixin \i
+ @mixin \update-avatar
+
+ @set = ~>
+ @update-avatar @I, (i) ~>
+ @update-i i
+
+ @close = (e) ~>
+ e.prevent-default!
+ e.stop-propagation!
+ @unmount!
diff --git a/src/web/app/desktop/tags/set-banner-suggestion.tag b/src/web/app/desktop/tags/set-banner-suggestion.tag
new file mode 100644
index 0000000000..bff0385803
--- /dev/null
+++ b/src/web/app/desktop/tags/set-banner-suggestion.tag
@@ -0,0 +1,44 @@
+mk-set-banner-suggestion(onclick={ set })
+ p
+ b バナーを設定
+ | してみませんか?
+ button(onclick={ close }): i.fa.fa-times
+
+style.
+ display block
+ cursor pointer
+ color #fff
+ background #a8cad0
+
+ &:hover
+ background #70abb5
+
+ > p
+ display block
+ margin 0 auto
+ padding 8px
+ max-width 1024px
+
+ > a
+ font-weight bold
+ color #fff
+
+ > button
+ position absolute
+ top 0
+ right 0
+ padding 8px
+ color #fff
+
+script.
+ @mixin \i
+ @mixin \update-banner
+
+ @set = ~>
+ @update-banner @I, (i) ~>
+ @update-i i
+
+ @close = (e) ~>
+ e.prevent-default!
+ e.stop-propagation!
+ @unmount!
diff --git a/src/web/app/desktop/tags/settings-window.tag b/src/web/app/desktop/tags/settings-window.tag
new file mode 100644
index 0000000000..e259848718
--- /dev/null
+++ b/src/web/app/desktop/tags/settings-window.tag
@@ -0,0 +1,26 @@
+mk-settings-window
+ mk-window@window(is-modal={ true }, width={ '700px' }, height={ '550px' })
+ <yield to="header">
+ i.fa.fa-cog
+ | 設定
+ </yield>
+ <yield to="content">
+ mk-settings
+ </yield>
+
+style.
+ > mk-window
+ [data-yield='header']
+ > i
+ margin-right 4px
+
+ [data-yield='content']
+ overflow auto
+
+script.
+ @on \mount ~>
+ @refs.window.on \closed ~>
+ @unmount!
+
+ @close = ~>
+ @refs.window.close!
diff --git a/src/web/app/desktop/tags/settings.tag b/src/web/app/desktop/tags/settings.tag
new file mode 100644
index 0000000000..c6c0340912
--- /dev/null
+++ b/src/web/app/desktop/tags/settings.tag
@@ -0,0 +1,255 @@
+mk-settings
+ div.nav
+ p(class={ active: page == 'account' }, onmousedown={ set-page.bind(null, 'account') })
+ i.fa.fa-fw.fa-user
+ | アカウント
+ p(class={ active: page == 'web' }, onmousedown={ set-page.bind(null, 'web') })
+ i.fa.fa-fw.fa-desktop
+ | Web
+ p(class={ active: page == 'notification' }, onmousedown={ set-page.bind(null, 'notification') })
+ i.fa.fa-fw.fa-bell-o
+ | 通知
+ p(class={ active: page == 'drive' }, onmousedown={ set-page.bind(null, 'drive') })
+ i.fa.fa-fw.fa-cloud
+ | ドライブ
+ p(class={ active: page == 'apps' }, onmousedown={ set-page.bind(null, 'apps') })
+ i.fa.fa-fw.fa-puzzle-piece
+ | アプリ
+ p(class={ active: page == 'signin' }, onmousedown={ set-page.bind(null, 'signin') })
+ i.fa.fa-fw.fa-sign-in
+ | ログイン履歴
+ p(class={ active: page == 'password' }, onmousedown={ set-page.bind(null, 'password') })
+ i.fa.fa-fw.fa-unlock-alt
+ | パスワード
+ p(class={ active: page == 'api' }, onmousedown={ set-page.bind(null, 'api') })
+ i.fa.fa-fw.fa-key
+ | API
+
+ div.pages
+ section.account(show={ page == 'account' })
+ h1 アカウント
+ label.avatar
+ p アバター
+ img.avatar(src={ I.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ button.style-normal(onclick={ avatar }) 画像を選択
+ label
+ p 名前
+ input@account-name(type='text', value={ I.name })
+ label
+ p 場所
+ input@account-location(type='text', value={ I.location })
+ label
+ p 自己紹介
+ textarea@account-bio { I.bio }
+ button.style-primary(onclick={ update-account }) 保存
+
+ section.web(show={ page == 'web' })
+ h1 デザイン
+ label
+ p 壁紙
+ button.style-normal(onclick={ wallpaper }) 画像を選択
+ section.web(show={ page == 'web' })
+ h1 その他
+ label.checkbox
+ input(type='checkbox', checked={ I.data.cache }, onclick={ update-cache })
+ p 読み込みを高速化する
+ p API通信時に新鮮なユーザー情報をキャッシュすることでフェッチのオーバーヘッドを無くします。(実験的)
+ label.checkbox
+ input(type='checkbox', checked={ I.data.debug }, onclick={ update-debug })
+ p 開発者モード
+ p デバッグ等の開発者モードを有効にします。
+
+ section.signin(show={ page == 'signin' })
+ h1 ログイン履歴
+ mk-signin-history
+
+ section.api(show={ page == 'api' })
+ h1 API
+ p
+ | Token:
+ code { I.token }
+ p APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。
+ p アカウントを乗っ取られてしまう可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。
+ p
+ | 万が一このトークンが漏れたりその可能性がある場合は
+ button.regenerate(onclick={ regenerate-token }) トークンを再生成
+ | できます。(副作用として、ログインしているすべてのデバイスでログアウトが発生します)
+
+style.
+ display block
+
+ input:not([type])
+ input[type='text']
+ input[type='password']
+ input[type='email']
+ textarea
+ padding 8px
+ width 100%
+ font-size 16px
+ color #55595c
+ border solid 1px #dadada
+ border-radius 4px
+
+ &:hover
+ border-color #aeaeae
+
+ &:focus
+ border-color #aeaeae
+
+ > .nav
+ position absolute
+ top 0
+ left 0
+ width 200px
+ height 100%
+ padding 16px 0 0 0
+ border-right solid 1px #ddd
+
+ > p
+ display block
+ padding 10px 16px
+ margin 0
+ color #666
+ cursor pointer
+
+ -ms-user-select none
+ -moz-user-select none
+ -webkit-user-select none
+ user-select none
+
+ transition margin-left 0.2s ease
+
+ > i
+ margin-right 4px
+
+ &:hover
+ color #555
+
+ &.active
+ margin-left 8px
+ color $theme-color !important
+
+ > .pages
+ position absolute
+ top 0
+ left 200px
+ width calc(100% - 200px)
+
+ > section
+ padding 32px
+
+ // & + section
+ // margin-top 16px
+
+ h1
+ display block
+ margin 0
+ padding 0 0 8px 0
+ font-size 1em
+ color #555
+ border-bottom solid 1px #eee
+
+ label
+ display block
+ margin 16px 0
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > p
+ margin 0 0 8px 0
+ font-weight bold
+ color #373a3c
+
+ &.checkbox
+ > input
+ position absolute
+ top 0
+ left 0
+
+ &:checked + p
+ color $theme-color
+
+ > p
+ width calc(100% - 32px)
+ margin 0 0 0 32px
+ font-weight bold
+
+ &:last-child
+ font-weight normal
+ color #999
+
+ &.account
+ > .general
+ > .avatar
+ > img
+ display block
+ float left
+ width 64px
+ height 64px
+ border-radius 4px
+
+ > button
+ float left
+ margin-left 8px
+
+ &.api
+ code
+ padding 4px
+ background #eee
+
+ .regenerate
+ display inline
+ color $theme-color
+
+ &:hover
+ text-decoration underline
+
+script.
+ @mixin \i
+ @mixin \api
+ @mixin \dialog
+ @mixin \update-avatar
+ @mixin \update-wallpaper
+
+ @page = \account
+
+ @set-page = (page) ~>
+ @page = page
+
+ @avatar = ~>
+ @update-avatar @I, (i) ~>
+ @update-i i
+
+ @wallpaper = ~>
+ @update-wallpaper @I, (i) ~>
+ @update-i i
+
+ @update-account = ~>
+ @api \i/update do
+ name: @refs.account-name.value
+ location: @refs.account-location.value
+ bio: @refs.account-bio.value
+ .then (i) ~>
+ @update-i i
+ alert \ok
+ .catch (err) ~>
+ console.error err
+
+ @update-cache = ~>
+ @I.data.cache = !@I.data.cache
+ @api \i/appdata/set do
+ data: JSON.stringify do
+ cache: @I.data.cache
+ .then ~>
+ @update-i!
+
+ @update-debug = ~>
+ @I.data.debug = !@I.data.debug
+ @api \i/appdata/set do
+ data: JSON.stringify do
+ debug: @I.data.debug
+ .then ~>
+ @update-i!
diff --git a/src/web/app/desktop/tags/signin-history.tag b/src/web/app/desktop/tags/signin-history.tag
new file mode 100644
index 0000000000..311f8bfeda
--- /dev/null
+++ b/src/web/app/desktop/tags/signin-history.tag
@@ -0,0 +1,73 @@
+mk-signin-history
+ div.records(if={ history.length != 0 })
+ div(each={ history })
+ mk-time(time={ created_at })
+ header
+ i.fa.fa-check(if={ success })
+ i.fa.fa-times(if={ !success })
+ span.ip { ip }
+ pre: code { JSON.stringify(headers, null, ' ') }
+
+style.
+ display block
+
+ > .records
+ > div
+ padding 16px 0 0 0
+ border-bottom solid 1px #eee
+
+ > header
+
+ > i
+ margin-right 8px
+
+ &.fa-check
+ color #0fda82
+
+ &.fa-times
+ color #ff3100
+
+ > .ip
+ display inline-block
+ color #444
+ background #f8f8f8
+
+ > mk-time
+ position absolute
+ top 16px
+ right 0
+ color #777
+
+ > pre
+ overflow auto
+ max-height 100px
+
+ > code
+ white-space pre-wrap
+ word-break break-all
+ color #4a535a
+
+script.
+ @mixin \api
+ @mixin \stream
+
+ @history = []
+ @fetching = true
+
+ @on \mount ~>
+ @api \i/signin_history
+ .then (history) ~>
+ @history = history
+ @fetching = false
+ @update!
+ .catch (err) ~>
+ console.error err
+
+ @stream.on \signin @on-signin
+
+ @on \unmount ~>
+ @stream.off \signin @on-signin
+
+ @on-signin = (signin) ~>
+ @history.unshift signin
+ @update!
diff --git a/src/web/app/desktop/tags/stream-indicator.tag b/src/web/app/desktop/tags/stream-indicator.tag
new file mode 100644
index 0000000000..2eb5889ca6
--- /dev/null
+++ b/src/web/app/desktop/tags/stream-indicator.tag
@@ -0,0 +1,59 @@
+mk-stream-indicator
+ p(if={ state == 'initializing' })
+ i.fa.fa-spinner.fa-spin
+ span
+ | 接続中
+ mk-ellipsis
+ p(if={ state == 'reconnecting' })
+ i.fa.fa-spinner.fa-spin
+ span
+ | 切断されました 接続中
+ mk-ellipsis
+ p(if={ state == 'connected' })
+ i.fa.fa-check
+ span 接続完了
+
+style.
+ display block
+ pointer-events none
+ position fixed
+ z-index 16384
+ bottom 8px
+ right 8px
+ margin 0
+ padding 6px 12px
+ font-size 0.9em
+ color #fff
+ background rgba(0, 0, 0, 0.8)
+
+ > p
+ display block
+ margin 0
+
+ > i
+ margin-right 0.25em
+
+script.
+ @mixin \stream
+
+ @on \before-mount ~>
+ @state = @get-stream-state!
+
+ if @state == \connected
+ @root.style.opacity = 0
+
+ @stream-state-ev.on \connected ~>
+ @state = @get-stream-state!
+ @update!
+ set-timeout ~>
+ Velocity @root, {
+ opacity: 0
+ } 200ms \linear
+ , 1000ms
+
+ @stream-state-ev.on \closed ~>
+ @state = @get-stream-state!
+ @update!
+ Velocity @root, {
+ opacity: 1
+ } 0ms
diff --git a/src/web/app/desktop/tags/sub-post-content.tag b/src/web/app/desktop/tags/sub-post-content.tag
new file mode 100644
index 0000000000..976a6f398f
--- /dev/null
+++ b/src/web/app/desktop/tags/sub-post-content.tag
@@ -0,0 +1,37 @@
+mk-sub-post-content
+ div.body
+ a.reply(if={ post.reply_to_id }): i.fa.fa-reply
+ span@text
+ a.quote(if={ post.repost_id }, href={ '/post:' + post.repost_id }) RP: ...
+ details(if={ post.media })
+ summary ({ post.media.length }枚の画像)
+ mk-images-viewer(images={ post.media })
+
+style.
+ display block
+ word-wrap break-word
+
+ > .body
+ > .reply
+ margin-right 6px
+ color #717171
+
+ > .quote
+ margin-left 4px
+ font-style oblique
+ color #a0bf46
+
+script.
+ @mixin \text
+ @mixin \user-preview
+
+ @post = @opts.post
+
+ @on \mount ~>
+ if @post.text?
+ tokens = @analyze @post.text
+ @refs.text.innerHTML = @compile tokens, false
+
+ @refs.text.children.for-each (e) ~>
+ if e.tag-name == \MK-URL
+ riot.mount e
diff --git a/src/web/app/desktop/tags/timeline-post-sub.tag b/src/web/app/desktop/tags/timeline-post-sub.tag
new file mode 100644
index 0000000000..39b1ad7f71
--- /dev/null
+++ b/src/web/app/desktop/tags/timeline-post-sub.tag
@@ -0,0 +1,95 @@
+mk-timeline-post-sub(title={ title })
+ article
+ a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username })
+ img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ post.user_id })
+ div.main
+ header
+ a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id })
+ | { post.user.name }
+ span.username
+ | @{ post.user.username }
+ a.created-at(href={ CONFIG.url + '/' + post.user.username + '/' + post.id })
+ mk-time(time={ post.created_at })
+ div.body
+ mk-sub-post-content.text(post={ post })
+
+script.
+ @mixin \date-stringify
+ @mixin \user-preview
+
+ @post = @opts.post
+
+ @title = @date-stringify @post.created_at
+
+style.
+ 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
+ margin-bottom 4px
+ white-space nowrap
+ line-height 21px
+
+ > .name
+ display inline
+ margin 0
+ padding 0
+ color #607073
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 0 0 8px
+ color #d1d8da
+
+ > .created-at
+ position absolute
+ top 0
+ right 0
+ color #b2b8bb
+
+ > .body
+
+ > .text
+ cursor default
+ margin 0
+ padding 0
+ font-size 1.1em
+ color #717171
diff --git a/src/web/app/desktop/tags/timeline-post.tag b/src/web/app/desktop/tags/timeline-post.tag
new file mode 100644
index 0000000000..e23cd6306e
--- /dev/null
+++ b/src/web/app/desktop/tags/timeline-post.tag
@@ -0,0 +1,376 @@
+mk-timeline-post(tabindex='-1', title={ title }, onkeydown={ on-key-down })
+
+ div.reply-to(if={ p.reply_to })
+ mk-timeline-post-sub(post={ p.reply_to })
+
+ div.repost(if={ is-repost })
+ p
+ a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=32' }, alt='avatar')
+ i.fa.fa-retweet
+ a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }) { post.user.name }
+ | がRepost
+ mk-time(time={ post.created_at })
+
+ article
+ a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username })
+ img.avatar(src={ p.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ p.user.id })
+ div.main
+ header
+ a.name(href={ CONFIG.url + '/' + p.user.username }, data-user-preview={ p.user.id })
+ | { p.user.name }
+ span.username
+ | @{ p.user.username }
+ a.created-at(href={ url })
+ mk-time(time={ p.created_at })
+ div.body
+ div.text
+ a.reply(if={ p.reply_to }): i.fa.fa-reply
+ span@text
+ a.quote(if={ p.repost != null }) RP:
+ div.media(if={ p.media })
+ mk-images-viewer(images={ p.media })
+ div.repost(if={ p.repost })
+ i.fa.fa-quote-right.fa-flip-horizontal
+ mk-post-preview.repost(post={ p.repost })
+ footer
+ button(onclick={ reply }, title='返信')
+ i.fa.fa-reply
+ p.count(if={ p.replies_count > 0 }) { p.replies_count }
+ button(onclick={ repost }, title='Repost')
+ i.fa.fa-retweet
+ p.count(if={ p.repost_count > 0 }) { p.repost_count }
+ button(class={ liked: p.is_liked }, onclick={ like }, title='善哉')
+ i.fa.fa-thumbs-o-up
+ p.count(if={ p.likes_count > 0 }) { p.likes_count }
+ button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h
+ button(onclick={ toggle-detail }, title='詳細')
+ i.fa.fa-caret-down(if={ !is-detail-opened })
+ i.fa.fa-caret-up(if={ is-detail-opened })
+ div.detail(if={ is-detail-opened })
+ mk-post-status-graph(width='462', height='130', post={ p })
+
+style.
+ 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 0 0
+
+ > .avatar
+ display block
+ width 58px
+ height 58px
+ margin 0
+ border-radius 8px
+ vertical-align bottom
+
+ > .main
+ float left
+ width calc(100% - 74px)
+
+ > header
+ margin-bottom 4px
+ white-space nowrap
+ line-height 24px
+
+ > .name
+ display inline
+ margin 0
+ padding 0
+ color #777
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 0 0 8px
+ color #ccc
+
+ > .created-at
+ position absolute
+ top 0
+ right 0
+ font-size 0.9em
+ color #c0c0c0
+
+ > .body
+
+ > .text
+ cursor default
+ display block
+ margin 0
+ padding 0
+ word-wrap break-word
+ font-size 1.1em
+ color #717171
+
+ mk-url-preview
+ margin-top 8px
+
+ > .reply
+ margin-right 8px
+ color #717171
+
+ > .quote
+ margin-left 4px
+ font-style oblique
+ color #a0bf46
+
+ > .media
+ > img
+ display block
+ max-width 100%
+
+ > .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
+
+ &.liked
+ color $theme-color
+
+ &:last-child
+ position absolute
+ right 0
+ margin 0
+
+ > .detail
+ padding-top 4px
+ background rgba(0, 0, 0, 0.0125)
+
+style(theme='dark').
+ background #0D0D0D
+
+ > article
+
+ &:hover
+ > .main > footer > button
+ color #eee
+
+ > .main
+ > header
+ > .left
+ > .name
+ color #9e9c98
+
+ > .username
+ color #41403f
+
+ > .right
+ > .time
+ color #4e4d4b
+
+ > .body
+ > .text
+ color #9e9c98
+
+ > footer
+ > button
+ color #9e9c98
+
+ &:hover
+ color #fff
+
+ > .count
+ color #eee
+
+script.
+ @mixin \api
+ @mixin \text
+ @mixin \date-stringify
+ @mixin \user-preview
+ @mixin \NotImplementedException
+
+ @post = @opts.post
+ @is-repost = @post.repost? and !@post.text?
+ @p = if @is-repost then @post.repost else @post
+
+ @title = @date-stringify @p.created_at
+
+ @url = CONFIG.url + '/' + @p.user.username + '/' + @p.id
+ @is-detail-opened = false
+
+ @on \mount ~>
+ if @p.text?
+ tokens = if @p._highlight?
+ then @analyze @p._highlight
+ else @analyze @p.text
+
+ @refs.text.innerHTML = if @p._highlight?
+ then @compile tokens, true, false
+ else @compile tokens
+
+ @refs.text.children.for-each (e) ~>
+ if e.tag-name == \MK-URL
+ riot.mount e
+
+ # URLをプレビュー
+ tokens
+ .filter (t) -> t.type == \link
+ .map (t) ~>
+ @preview = @refs.text.append-child document.create-element \mk-url-preview
+ riot.mount @preview, do
+ url: t.content
+
+ @reply = ~>
+ form = document.body.append-child document.create-element \mk-post-form-window
+ riot.mount form, do
+ reply: @p
+
+ @repost = ~>
+ form = document.body.append-child document.create-element \mk-repost-form-window
+ riot.mount form, do
+ post: @p
+
+ @like = ~>
+ if @p.is_liked
+ @api \posts/likes/delete do
+ post_id: @p.id
+ .then ~>
+ @p.is_liked = false
+ @update!
+ else
+ @api \posts/likes/create do
+ post_id: @p.id
+ .then ~>
+ @p.is_liked = true
+ @update!
+
+ @toggle-detail = ~>
+ @is-detail-opened = !@is-detail-opened
+ @update!
+
+ @on-key-down = (e) ~>
+ should-be-cancel = true
+ switch
+ | e.which == 38 or e.which == 74 or (e.which == 9 and e.shift-key) => # ↑, j or Shift+Tab
+ focus @root, (e) -> e.previous-element-sibling
+ | e.which == 40 or e.which == 75 or e.which == 9 => # ↓, k or Tab
+ focus @root, (e) -> e.next-element-sibling
+ | e.which == 69 => # e
+ @repost!
+ | e.which == 70 or e.which == 76 => # f or l
+ @like!
+ | e.which == 82 => # r
+ @reply!
+ | _ =>
+ should-be-cancel = false
+
+ if should-be-cancel
+ e.prevent-default!
+
+ function focus(el, fn)
+ target = fn el
+ if target?
+ if target.has-attribute \tabindex
+ target.focus!
+ else
+ focus target, fn
diff --git a/src/web/app/desktop/tags/timeline.tag b/src/web/app/desktop/tags/timeline.tag
new file mode 100644
index 0000000000..dfd1b7c14e
--- /dev/null
+++ b/src/web/app/desktop/tags/timeline.tag
@@ -0,0 +1,86 @@
+mk-timeline
+ virtual(each={ post, i in posts })
+ mk-timeline-post(post={ post })
+ p.date(if={ i != posts.length - 1 && post._date != posts[i + 1]._date })
+ span
+ i.fa.fa-angle-up
+ | { post._datetext }
+ span
+ i.fa.fa-angle-down
+ | { posts[i + 1]._datetext }
+ footer(data-yield='footer')
+ | <yield from="footer"/>
+
+style.
+ display block
+
+ > mk-timeline-post
+ border-bottom solid 1px #eaeaea
+
+ &:first-child
+ border-top-left-radius 4px
+ border-top-right-radius 4px
+
+ &:last-of-type
+ border-bottom none
+
+ > .date
+ display block
+ margin 0
+ line-height 32px
+ font-size 14px
+ text-align center
+ color #aaa
+ background #fdfdfd
+ border-bottom solid 1px #eaeaea
+
+ span
+ margin 0 16px
+
+ i
+ margin-right 8px
+
+ > footer
+ padding 16px
+ text-align center
+ color #ccc
+ border-top solid 1px #eaeaea
+ border-bottom-left-radius 4px
+ border-bottom-right-radius 4px
+
+style(theme='dark').
+ > mk-timeline-post
+ border-bottom-color #222221
+
+script.
+ @posts = []
+
+ @set-posts = (posts) ~>
+ @posts = posts
+ @update!
+
+ @prepend-posts = (posts) ~>
+ posts.for-each (post) ~>
+ @posts.push post
+ @update!
+
+ @add-post = (post) ~>
+ @posts.unshift post
+ @update!
+
+ @clear = ~>
+ @posts = []
+ @update!
+
+ @focus = ~>
+ @root.children.0.focus!
+
+ @on \update ~>
+ @posts.for-each (post) ~>
+ date = (new Date post.created_at).get-date!
+ month = (new Date post.created_at).get-month! + 1
+ post._date = date
+ post._datetext = month + '月 ' + date + '日'
+
+ @tail = ~>
+ @posts[@posts.length - 1]
diff --git a/src/web/app/desktop/tags/ui-header-account.tag b/src/web/app/desktop/tags/ui-header-account.tag
new file mode 100644
index 0000000000..ffb1eeec00
--- /dev/null
+++ b/src/web/app/desktop/tags/ui-header-account.tag
@@ -0,0 +1,219 @@
+mk-ui-header-account
+ button.header(data-active={ is-open.toString() }, onclick={ toggle })
+ span.username
+ | { I.username }
+ i.fa.fa-angle-down(if={ !is-open })
+ i.fa.fa-angle-up(if={ is-open })
+ img.avatar(src={ I.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.menu(if={ is-open })
+ ul
+ li: a(href={ '/' + I.username })
+ i.fa.fa-user
+ | プロフィール
+ i.fa.fa-angle-right
+ li(onclick={ drive }): p
+ i.fa.fa-cloud
+ | ドライブ
+ i.fa.fa-angle-right
+ li: a(href='/i>mentions')
+ i.fa.fa-at
+ | あなた宛て
+ i.fa.fa-angle-right
+ ul
+ li(onclick={ settings }): p
+ i.fa.fa-cog
+ | 設定
+ i.fa.fa-angle-right
+ ul
+ li(onclick={ signout }): p
+ i(class='fa fa-power-off')
+ | サインアウト
+ i.fa.fa-angle-right
+
+style.
+ display block
+ float left
+
+ > .header
+ display block
+ margin 0
+ padding 0
+ color #9eaba8
+ border none
+ background transparent
+ cursor pointer
+
+ *
+ pointer-events none
+
+ &:hover
+ color darken(#9eaba8, 20%)
+
+ &:active
+ color darken(#9eaba8, 30%)
+
+ &[data-active='true']
+ color darken(#9eaba8, 20%)
+
+ > .avatar
+ $saturate = 150%
+ filter saturate($saturate)
+ -webkit-filter saturate($saturate)
+ -moz-filter saturate($saturate)
+ -ms-filter saturate($saturate)
+
+ > .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
+
+script.
+ @mixin \i
+ @mixin \signout
+
+ @is-open = false
+
+ @on \before-unmount ~>
+ @close!
+
+ @toggle = ~>
+ if @is-open
+ @close!
+ else
+ @open!
+
+ @open = ~>
+ @is-open = true
+ @update!
+ all = document.query-selector-all 'body *'
+ Array.prototype.for-each.call all, (el) ~>
+ el.add-event-listener \mousedown @mousedown
+
+ @close = ~>
+ @is-open = false
+ @update!
+ all = document.query-selector-all 'body *'
+ Array.prototype.for-each.call all, (el) ~>
+ el.remove-event-listener \mousedown @mousedown
+
+ @mousedown = (e) ~>
+ e.prevent-default!
+ if (!contains @root, e.target) and (@root != e.target)
+ @close!
+ return false
+
+ @drive = ~>
+ @close!
+ riot.mount document.body.append-child document.create-element \mk-drive-browser-window
+
+ @settings = ~>
+ @close!
+ riot.mount document.body.append-child document.create-element \mk-settings-window
+
+ function contains(parent, child)
+ node = child.parent-node
+ while node?
+ if node == parent
+ return true
+ node = node.parent-node
+ return false
diff --git a/src/web/app/desktop/tags/ui-header-clock.tag b/src/web/app/desktop/tags/ui-header-clock.tag
new file mode 100644
index 0000000000..987907a684
--- /dev/null
+++ b/src/web/app/desktop/tags/ui-header-clock.tag
@@ -0,0 +1,82 @@
+mk-ui-header-clock
+ div.header
+ time@time
+ div.content
+ mk-analog-clock
+
+style.
+ display inline-block
+ overflow visible
+
+ > .header
+ padding 0 12px
+ text-align center
+ font-size 0.5em
+
+ &, *
+ 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
+
+script.
+ @draw = ~>
+ now = new Date!
+
+ yyyy = now.get-full-year!
+ mm = (\0 + (now.get-month! + 1)).slice -2
+ dd = (\0 + now.get-date!).slice -2
+ yyyymmdd = "<span class='yyyymmdd'>#yyyy/#mm/#dd</span>"
+
+ hh = (\0 + now.get-hours!).slice -2
+ mm = (\0 + now.get-minutes!).slice -2
+ hhmm = "<span class='hhmm'>#hh:#mm</span>"
+
+ if now.get-seconds! % 2 == 0
+ hhmm .= replace \: '<span style=\'visibility:visible\'>:</span>'
+ else
+ hhmm .= replace \: '<span style=\'visibility:hidden\'>:</span>'
+
+ @refs.time.innerHTML = "#yyyymmdd<br>#hhmm"
+
+ @on \mount ~>
+ @draw!
+ @clock = set-interval @draw, 1000ms
+
+ @on \unmount ~>
+ clear-interval @clock
diff --git a/src/web/app/desktop/tags/ui-header-nav.tag b/src/web/app/desktop/tags/ui-header-nav.tag
new file mode 100644
index 0000000000..153c3137b4
--- /dev/null
+++ b/src/web/app/desktop/tags/ui-header-nav.tag
@@ -0,0 +1,113 @@
+mk-ui-header-nav: ul(if={ SIGNIN })
+ li.home(class={ active: page == 'home' }): a(href={ CONFIG.url })
+ i.fa.fa-home
+ p ホーム
+ li.messaging: a(onclick={ messaging })
+ i.fa.fa-comments
+ p メッセージ
+ i.fa.fa-circle(if={ has-unread-messaging-messages })
+ li.info: a(href='https://twitter.com/misskey_xyz', target='_blank')
+ i.fa.fa-info
+ p お知らせ
+ li.tv: a(href='https://misskey.tk', target='_blank')
+ i.fa.fa-television
+ p MisskeyTV™
+
+style.
+ 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 1em
+ 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
+
+script.
+ @mixin \i
+ @mixin \api
+ @mixin \stream
+
+ @page = @opts.page
+
+ @on \mount ~>
+ @stream.on \read_all_messaging_messages @on-read-all-messaging-messages
+ @stream.on \unread_messaging_message @on-unread-messaging-message
+
+ # Fetch count of unread messaging messages
+ @api \messaging/unread
+ .then (count) ~>
+ if count.count > 0
+ @has-unread-messaging-messages = true
+ @update!
+
+ @on \unmount ~>
+ @stream.off \read_all_messaging_messages @on-read-all-messaging-messages
+ @stream.off \unread_messaging_message @on-unread-messaging-message
+
+ @on-read-all-messaging-messages = ~>
+ @has-unread-messaging-messages = false
+ @update!
+
+ @on-unread-messaging-message = ~>
+ @has-unread-messaging-messages = true
+ @update!
+
+ @messaging = ~>
+ riot.mount document.body.append-child document.create-element \mk-messaging-window
diff --git a/src/web/app/desktop/tags/ui-header-notifications.tag b/src/web/app/desktop/tags/ui-header-notifications.tag
new file mode 100644
index 0000000000..495aad5004
--- /dev/null
+++ b/src/web/app/desktop/tags/ui-header-notifications.tag
@@ -0,0 +1,111 @@
+mk-ui-header-notifications
+ button.header(data-active={ is-open }, onclick={ toggle })
+ i.fa.fa-bell-o
+ div.notifications(if={ is-open })
+ mk-notifications
+
+style.
+ 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
+ color darken(#9eaba8, 20%)
+
+ &:active
+ color darken(#9eaba8, 30%)
+
+ &[data-active='true']
+ color darken(#9eaba8, 20%)
+
+ > 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
+
+script.
+ @is-open = false
+
+ @toggle = ~>
+ if @is-open
+ @close!
+ else
+ @open!
+
+ @open = ~>
+ @is-open = true
+ @update!
+ all = document.query-selector-all 'body *'
+ Array.prototype.for-each.call all, (el) ~>
+ el.add-event-listener \mousedown @mousedown
+
+ @close = ~>
+ @is-open = false
+ @update!
+ all = document.query-selector-all 'body *'
+ Array.prototype.for-each.call all, (el) ~>
+ el.remove-event-listener \mousedown @mousedown
+
+ @mousedown = (e) ~>
+ e.prevent-default!
+ if (!contains @root, e.target) and (@root != e.target)
+ @close!
+ return false
+
+ function contains(parent, child)
+ node = child.parent-node
+ while node?
+ if node == parent
+ return true
+ node = node.parent-node
+ return false
diff --git a/src/web/app/desktop/tags/ui-header-post-button.tag b/src/web/app/desktop/tags/ui-header-post-button.tag
new file mode 100644
index 0000000000..558c987619
--- /dev/null
+++ b/src/web/app/desktop/tags/ui-header-post-button.tag
@@ -0,0 +1,39 @@
+mk-ui-header-post-button
+ button(onclick={ post }, title='新規投稿')
+ i.fa.fa-pencil-square-o
+
+style.
+ 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
+
+script.
+ @post = (e) ~>
+ @parent.parent.open-post-form!
diff --git a/src/web/app/desktop/tags/ui-header-search.tag b/src/web/app/desktop/tags/ui-header-search.tag
new file mode 100644
index 0000000000..24e4e44989
--- /dev/null
+++ b/src/web/app/desktop/tags/ui-header-search.tag
@@ -0,0 +1,37 @@
+mk-ui-header-search
+ form.search(onsubmit={ onsubmit })
+ input@q(type='search', placeholder!='&#xf002; 検索')
+ div.result
+
+style.
+
+ > 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
+
+script.
+ @mixin \page
+
+ @onsubmit = (e) ~>
+ e.prevent-default!
+ @page '/search:' + @refs.q.value
diff --git a/src/web/app/desktop/tags/ui-header.tag b/src/web/app/desktop/tags/ui-header.tag
new file mode 100644
index 0000000000..b02817cd84
--- /dev/null
+++ b/src/web/app/desktop/tags/ui-header.tag
@@ -0,0 +1,85 @@
+mk-ui-header
+ mk-donation(if={ SIGNIN && !I.data.no_donation })
+ mk-special-message
+ div.main
+ div.backdrop
+ div.main: div.container
+ div.left
+ mk-ui-header-nav(page={ opts.page })
+ div.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
+
+style.
+ display block
+ position fixed
+ 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(/_/resources/desktop/header-logo.svg)
+ background-size 64px
+ background-position center
+ background-repeat no-repeat
+
+ > .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(theme='dark').
+ box-shadow 0 1px 0 #222221
+
+ > .main
+
+ > .backdrop
+ background #0D0D0D
+
+script.
+ @mixin \i
diff --git a/src/web/app/desktop/tags/ui-notification.tag b/src/web/app/desktop/tags/ui-notification.tag
new file mode 100644
index 0000000000..6e5f948b88
--- /dev/null
+++ b/src/web/app/desktop/tags/ui-notification.tag
@@ -0,0 +1,41 @@
+mk-ui-notification
+ p { opts.message }
+
+style.
+ display block
+ position fixed
+ z-index 10000
+ top -64px
+ left 0
+ right 0
+ margin 0 auto
+ 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)
+
+ > p
+ margin 0
+ line-height 64px
+ text-align center
+
+script.
+ @on \mount ~>
+ Velocity @root, {
+ top: \0px
+ } {
+ duration: 500ms
+ easing: \ease-out
+ }
+
+ set-timeout ~>
+ Velocity @root, {
+ top: \-64px
+ } {
+ duration: 500ms
+ easing: \ease-out
+ complete: ~>
+ @unmount!
+ }
+ , 6000ms
diff --git a/src/web/app/desktop/tags/ui.tag b/src/web/app/desktop/tags/ui.tag
new file mode 100644
index 0000000000..6bced1f9e4
--- /dev/null
+++ b/src/web/app/desktop/tags/ui.tag
@@ -0,0 +1,37 @@
+mk-ui
+ div.global@global
+ mk-ui-header@header(page={ opts.page })
+
+ mk-set-avatar-suggestion(if={ SIGNIN && I.avatar_id == null })
+ mk-set-banner-suggestion(if={ SIGNIN && I.banner_id == null })
+
+ div.content
+ <yield />
+
+ mk-stream-indicator
+
+style.
+ display block
+
+script.
+ @mixin \i
+
+ @open-post-form = ~>
+ riot.mount document.body.append-child document.create-element \mk-post-form-window
+
+ @set-root-layout = ~>
+ @root.style.padding-top = @refs.header.root.client-height + \px
+
+ @on \mount ~>
+ @set-root-layout!
+ document.add-event-listener \keydown @onkeydown
+
+ @on \unmount ~>
+ document.remove-event-listener \keydown @onkeydown
+
+ @onkeydown = (e) ~>
+ tag = e.target.tag-name.to-lower-case!
+ if tag != \input and tag != \textarea
+ if e.which == 80 or e.which == 78 # p or n
+ e.prevent-default!
+ @open-post-form!
diff --git a/src/web/app/desktop/tags/user-followers-window.tag b/src/web/app/desktop/tags/user-followers-window.tag
new file mode 100644
index 0000000000..d18b04446c
--- /dev/null
+++ b/src/web/app/desktop/tags/user-followers-window.tag
@@ -0,0 +1,22 @@
+mk-user-followers-window
+ mk-window(is-modal={ false }, width={ '400px' }, height={ '550px' })
+ <yield to="header">
+ img(src={ parent.user.avatar_url + '?thumbnail&size=64' }, alt='')
+ | { parent.user.name }のフォロワー
+ </yield>
+ <yield to="content">
+ mk-user-followers(user={ parent.user })
+ </yield>
+
+style.
+ > mk-window
+ [data-yield='header']
+ > img
+ display inline-block
+ vertical-align bottom
+ height calc(100% - 10px)
+ margin 5px
+ border-radius 4px
+
+script.
+ @user = @opts.user
diff --git a/src/web/app/desktop/tags/user-followers.tag b/src/web/app/desktop/tags/user-followers.tag
new file mode 100644
index 0000000000..52f9f43836
--- /dev/null
+++ b/src/web/app/desktop/tags/user-followers.tag
@@ -0,0 +1,19 @@
+mk-user-followers
+ mk-users-list(fetch={ fetch }, count={ user.followers_count }, you-know-count={ user.followers_you_know_count }, no-users={ 'フォロワーはいないようです。' })
+
+style.
+ display block
+ height 100%
+
+script.
+ @mixin \api
+
+ @user = @opts.user
+
+ @fetch = (iknow, limit, cursor, cb) ~>
+ @api \users/followers do
+ user_id: @user.id
+ iknow: iknow
+ limit: limit
+ cursor: if cursor? then cursor else undefined
+ .then cb
diff --git a/src/web/app/desktop/tags/user-following-window.tag b/src/web/app/desktop/tags/user-following-window.tag
new file mode 100644
index 0000000000..91f94f08d3
--- /dev/null
+++ b/src/web/app/desktop/tags/user-following-window.tag
@@ -0,0 +1,22 @@
+mk-user-following-window
+ mk-window(is-modal={ false }, width={ '400px' }, height={ '550px' })
+ <yield to="header">
+ img(src={ parent.user.avatar_url + '?thumbnail&size=64' }, alt='')
+ | { parent.user.name }のフォロー
+ </yield>
+ <yield to="content">
+ mk-user-following(user={ parent.user })
+ </yield>
+
+style.
+ > mk-window
+ [data-yield='header']
+ > img
+ display inline-block
+ vertical-align bottom
+ height calc(100% - 10px)
+ margin 5px
+ border-radius 4px
+
+script.
+ @user = @opts.user
diff --git a/src/web/app/desktop/tags/user-following.tag b/src/web/app/desktop/tags/user-following.tag
new file mode 100644
index 0000000000..0a39f2e4b8
--- /dev/null
+++ b/src/web/app/desktop/tags/user-following.tag
@@ -0,0 +1,19 @@
+mk-user-following
+ mk-users-list(fetch={ fetch }, count={ user.following_count }, you-know-count={ user.following_you_know_count }, no-users={ 'フォロー中のユーザーはいないようです。' })
+
+style.
+ display block
+ height 100%
+
+script.
+ @mixin \api
+
+ @user = @opts.user
+
+ @fetch = (iknow, limit, cursor, cb) ~>
+ @api \users/following do
+ user_id: @user.id
+ iknow: iknow
+ limit: limit
+ cursor: if cursor? then cursor else undefined
+ .then cb
diff --git a/src/web/app/desktop/tags/user-friends-graph.tag b/src/web/app/desktop/tags/user-friends-graph.tag
new file mode 100644
index 0000000000..47c3a15613
--- /dev/null
+++ b/src/web/app/desktop/tags/user-friends-graph.tag
@@ -0,0 +1,64 @@
+mk-user-friends-graph
+ canvas@canv(width='750', height='250')
+
+style.
+ display block
+ width 750px
+ height 250px
+
+script.
+ @mixin \api
+ @mixin \is-promise
+
+ @user = null
+ @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user
+
+ @on \mount ~>
+ user <~ @user-promise.then
+ @user = user
+ @update!
+
+ @api \aggregation/users/followers do
+ user_id: @user.id
+ limit: 30days
+ .then (followers) ~>
+ followers = followers.reverse!
+
+ @api \aggregation/users/following do
+ user_id: @user.id
+ limit: 30days
+ .then (following) ~>
+ following = following.reverse!
+
+ new Chart @refs.canv, do
+ type: \line
+ data:
+ labels: following.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else ''
+ datasets: [
+ {
+ label: \フォロー
+ data: following.map (x) ~> x.count
+ line-tension: 0
+ border-width: 2
+ fill: true
+ background-color: 'rgba(127, 221, 64, 0.2)'
+ point-background-color: \#fff
+ point-radius: 4
+ point-border-width: 2
+ border-color: \#7fdd40
+ },
+ {
+ label: \フォロワー
+ data: followers.map (x) ~> x.count
+ line-tension: 0
+ border-width: 2
+ fill: true
+ background-color: 'rgba(255, 99, 132, 0.2)'
+ point-background-color: \#fff
+ point-radius: 4
+ point-border-width: 2
+ border-color: \#FF6384
+ }
+ ]
+ options:
+ responsive: false
diff --git a/src/web/app/desktop/tags/user-graphs.tag b/src/web/app/desktop/tags/user-graphs.tag
new file mode 100644
index 0000000000..f7f0fcd5e0
--- /dev/null
+++ b/src/web/app/desktop/tags/user-graphs.tag
@@ -0,0 +1,36 @@
+mk-user-graphs
+ section
+ h1 投稿
+ mk-user-posts-graph(user={ opts.user })
+
+ section
+ h1 フォロー/フォロワー
+ mk-user-friends-graph(user={ opts.user })
+
+ section
+ h1 いいね
+ mk-user-likes-graph(user={ opts.user })
+
+style.
+ display block
+
+ > section
+ margin 16px 0
+ background #fff
+ border solid 1px rgba(0, 0, 0, 0.1)
+ border-radius 4px
+
+ > h1
+ margin 0 0 8px 0
+ padding 0 16px
+ line-height 40px
+ font-size 1em
+ color #666
+ border-bottom solid 1px #eee
+
+ > *:not(h1)
+ margin 0 auto 16px auto
+
+script.
+ @on \mount ~>
+ @trigger \loaded
diff --git a/src/web/app/desktop/tags/user-header.tag b/src/web/app/desktop/tags/user-header.tag
new file mode 100644
index 0000000000..5abd79ff1c
--- /dev/null
+++ b/src/web/app/desktop/tags/user-header.tag
@@ -0,0 +1,143 @@
+mk-user-header(data-is-dark-background={ user.banner_url != null })
+ div.banner@banner(style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=1024)' : '' }, onclick={ on-update-banner })
+ img.avatar(src={ user.avatar_url + '?thumbnail&size=150' }, alt='avatar')
+ div.title
+ p.name(href={ CONFIG.url + '/' + user.username }) { user.name }
+ p.username @{ user.username }
+ p.location(if={ user.location })
+ i.fa.fa-map-marker
+ | { user.location }
+ footer
+ a(href={ '/' + user.username }) 投稿
+ a(href={ '/' + user.username + '/media' }) メディア
+ a(href={ '/' + user.username + '/graphs' }) グラフ
+ button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h
+
+style.
+ $footer-height = 58px
+
+ display block
+ background #fff
+
+ &[data-is-dark-background]
+ > .banner
+ background-color #383838
+
+ > .title
+ color #fff
+ background linear-gradient(transparent, rgba(0, 0, 0, 0.7))
+
+ > .name
+ text-shadow 0 0 8px #000
+
+ > .banner
+ height 280px
+ background-color #f5f5f5
+ background-size cover
+ background-position center
+
+ > .avatar
+ display block
+ position absolute
+ bottom 16px
+ left 16px
+ z-index 2
+ width 150px
+ height 150px
+ margin 0
+ border solid 3px #fff
+ border-radius 8px
+ box-shadow 1px 1px 3px rgba(0, 0, 0, 0.2)
+
+ > .title
+ position absolute
+ bottom $footer-height
+ left 0
+ width 100%
+ padding 0 0 8px 195px
+ color #656565
+ font-family '游ゴシック', 'YuGothic', 'ヒラギノ角ゴ ProN W3', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'メイリオ', sans-serif
+
+ > .name
+ display block
+ margin 0
+ line-height 40px
+ font-weight bold
+ font-size 2em
+
+ > .username
+ > .location
+ display inline-block
+ margin 0 16px 0 0
+ line-height 20px
+ opacity 0.8
+
+ > i
+ margin-right 4px
+
+ > footer
+ z-index 1
+ height $footer-height
+ padding-left 195px
+ background #fff
+
+ > a
+ display inline-block
+ margin 0
+ width 100px
+ line-height $footer-height
+ color #555
+
+ > button
+ display block
+ position absolute
+ top 0
+ right 0
+ margin 8px
+ padding 0
+ width $footer-height - 16px
+ line-height $footer-height - 16px - 2px
+ font-size 1.2em
+ color #777
+ border solid 1px #eee
+ border-radius 4px
+
+ &:hover
+ color #555
+ border solid 1px #ddd
+
+script.
+ @mixin \i
+ @mixin \update-banner
+ @mixin \NotImplementedException
+
+ @user = @opts.user
+
+ @on \mount ~>
+ window.add-event-listener \load @scroll
+ window.add-event-listener \scroll @scroll
+ window.add-event-listener \resize @scroll
+
+ @on \unmount ~>
+ window.remove-event-listener \load @scroll
+ window.remove-event-listener \scroll @scroll
+ window.remove-event-listener \resize @scroll
+
+ @scroll = ~>
+ top = window.scroll-y
+ height = 280px
+
+ pos = 50 - ((top / height) * 50)
+ @refs.banner.style.background-position = 'center ' + pos + '%'
+
+ blur = top / 32
+ if blur <= 10
+ @refs.banner.style.filter = 'blur(' + blur + 'px)'
+
+ @on-update-banner = ~>
+ if not @SIGNIN or @I.id != @user.id
+ return
+
+ @update-banner @I, (i) ~>
+ @user.banner_url = i.banner_url
+ @update!
diff --git a/src/web/app/desktop/tags/user-home.tag b/src/web/app/desktop/tags/user-home.tag
new file mode 100644
index 0000000000..4bf0260ff6
--- /dev/null
+++ b/src/web/app/desktop/tags/user-home.tag
@@ -0,0 +1,40 @@
+mk-user-home
+ div.side
+ mk-user-profile(user={ user })
+ mk-user-photos(user={ user })
+ main
+ mk-user-timeline@tl(user={ user })
+
+style.
+ display flex
+ justify-content center
+
+ > *
+ > *
+ display block
+ //border solid 1px #eaeaea
+ border solid 1px rgba(0, 0, 0, 0.075)
+ border-radius 6px
+ overflow hidden
+
+ &:not(:last-child)
+ margin-bottom 16px
+
+ > main
+ flex 1 1 560px
+ max-width 560px
+ margin 0
+ padding 16px 0 16px 16px
+
+ > .side
+ flex 1 1 270px
+ max-width 270px
+ margin 0
+ padding 16px 0 16px 0
+
+script.
+ @user = @opts.user
+
+ @on \mount ~>
+ @refs.tl.on \loaded ~>
+ @trigger \loaded
diff --git a/src/web/app/desktop/tags/user-likes-graph.tag b/src/web/app/desktop/tags/user-likes-graph.tag
new file mode 100644
index 0000000000..e9d1428713
--- /dev/null
+++ b/src/web/app/desktop/tags/user-likes-graph.tag
@@ -0,0 +1,39 @@
+mk-user-likes-graph
+ canvas@canv(width='750', height='250')
+
+style.
+ display block
+ width 750px
+ height 250px
+
+script.
+ @mixin \api
+ @mixin \is-promise
+
+ @user = null
+ @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user
+
+ @on \mount ~>
+ user <~ @user-promise.then
+ @user = user
+ @update!
+
+ @api \aggregation/users/like do
+ user_id: @user.id
+ limit: 30days
+ .then (likes) ~>
+ likes = likes.reverse!
+
+ new Chart @refs.canv, do
+ type: \bar
+ data:
+ labels: likes.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else ''
+ datasets: [
+ {
+ label: \いいねした数
+ data: likes.map (x) ~> x.count
+ background-color: \#F7796C
+ }
+ ]
+ options:
+ responsive: false
diff --git a/src/web/app/desktop/tags/user-photos.tag b/src/web/app/desktop/tags/user-photos.tag
new file mode 100644
index 0000000000..61a840ee61
--- /dev/null
+++ b/src/web/app/desktop/tags/user-photos.tag
@@ -0,0 +1,85 @@
+mk-user-photos
+ p.title
+ i.fa.fa-camera
+ | フォト
+ p.initializing(if={ initializing })
+ i.fa.fa-spinner.fa-pulse.fa-fw
+ | 読み込んでいます
+ mk-ellipsis
+ div.stream(if={ !initializing && images.length > 0 })
+ virtual(each={ image in images })
+ div.img(style={ 'background-image: url(' + image.url + '?thumbnail&size=256)' })
+ p.empty(if={ !initializing && images.length == 0 })
+ | 写真はありません
+
+style.
+ display block
+ background #fff
+
+ > .title
+ z-index 1
+ margin 0
+ padding 0 16px
+ line-height 42px
+ font-size 0.9em
+ font-weight bold
+ color #888
+ box-shadow 0 1px rgba(0, 0, 0, 0.07)
+
+ > i
+ margin-right 4px
+
+ > .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
+
+ > .initializing
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+script.
+ @mixin \api
+ @mixin \is-promise
+
+ @images = []
+ @initializing = true
+
+ @user = null
+ @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user
+
+ @on \mount ~>
+ @user-promise.then (user) ~>
+ @user = user
+ @update!
+
+ @api \users/posts do
+ user_id: @user.id
+ with_media: true
+ limit: 9posts
+ .then (posts) ~>
+ @initializing = false
+ posts.for-each (post) ~>
+ post.media.for-each (image) ~>
+ if @images.length < 9
+ @images.push image
+ @update!
diff --git a/src/web/app/desktop/tags/user-posts-graph.tag b/src/web/app/desktop/tags/user-posts-graph.tag
new file mode 100644
index 0000000000..75f4ac4a67
--- /dev/null
+++ b/src/web/app/desktop/tags/user-posts-graph.tag
@@ -0,0 +1,68 @@
+mk-user-posts-graph
+ canvas@canv(width='750', height='250')
+
+style.
+ display block
+ width 750px
+ height 250px
+
+script.
+ @mixin \api
+ @mixin \is-promise
+
+ @user = null
+ @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user
+
+ @on \mount ~>
+ user <~ @user-promise.then
+ @user = user
+ @update!
+
+ @api \aggregation/users/post do
+ user_id: @user.id
+ limit: 30days
+ .then (data) ~>
+ data = data.reverse!
+ new Chart @refs.canv, do
+ type: \line
+ data:
+ labels: data.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else ''
+ datasets: [
+ {
+ label: \投稿
+ data: data.map (x) ~> x.posts
+ line-tension: 0
+ point-radius: 0
+ background-color: \#555
+ border-color: \transparent
+ },
+ {
+ label: \Repost
+ data: data.map (x) ~> x.reposts
+ line-tension: 0
+ point-radius: 0
+ background-color: \#a2d61e
+ border-color: \transparent
+ },
+ {
+ label: \返信
+ data: data.map (x) ~> x.replies
+ line-tension: 0
+ point-radius: 0
+ background-color: \#F7796C
+ border-color: \transparent
+ }
+ ]
+ options:
+ responsive: false
+ scales:
+ x-axes: [
+ {
+ stacked: true
+ }
+ ]
+ y-axes: [
+ {
+ stacked: true
+ }
+ ]
diff --git a/src/web/app/desktop/tags/user-preview.tag b/src/web/app/desktop/tags/user-preview.tag
new file mode 100644
index 0000000000..f299e6236e
--- /dev/null
+++ b/src/web/app/desktop/tags/user-preview.tag
@@ -0,0 +1,143 @@
+mk-user-preview
+ virtual(if={ user != null })
+ div.banner(style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=512)' : '' })
+ a.avatar(href={ CONFIG.url + '/' + user.username }, target='_blank'): img(src={ user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.title
+ p.name { user.name }
+ p.username @{ user.username }
+ div.bio { user.bio }
+ div.status
+ div
+ p 投稿
+ a { user.posts_count }
+ div
+ p フォロー
+ a { user.following_count }
+ div
+ p フォロワー
+ a { user.followers_count }
+ mk-follow-button(if={ SIGNIN && user.id != I.id }, user={ user-promise })
+
+style.
+ display block
+ position absolute
+ z-index 2048
+ width 250px
+ background #fff
+ background-clip content-box
+ border solid 1px rgba(0, 0, 0, 0.1)
+ border-radius 4px
+ overflow hidden
+
+ // https://github.com/riot/riot/issues/2081
+ > virtual
+ display block
+ position relative
+
+ > .banner
+ height 84px
+ background-color #f5f5f5
+ background-size cover
+ background-position center
+
+ > .avatar
+ display block
+ position absolute
+ top 62px
+ left 13px
+
+ > img
+ display block
+ width 58px
+ height 58px
+ margin 0
+ border solid 3px #fff
+ border-radius 8px
+
+ > .title
+ display block
+ padding 8px 0 8px 85px
+
+ > .name
+ display block
+ margin 0
+ font-weight bold
+ line-height 16px
+ color #656565
+
+ > .username
+ display block
+ margin 0
+ line-height 16px
+ font-size 0.8em
+ color #999
+
+ > .bio
+ padding 0 16px
+ font-size 0.7em
+ color #555
+
+ > .status
+ padding 8px 16px
+
+ > div
+ display inline-block
+ width 33%
+
+ > p
+ margin 0
+ font-size 0.7em
+ color #aaa
+
+ > a
+ font-size 1em
+ color $theme-color
+
+ > mk-follow-button
+ position absolute
+ top 92px
+ right 8px
+
+script.
+ @mixin \i
+ @mixin \api
+
+ @u = @opts.user
+ @user = null
+ @user-promise =
+ if typeof @u == \string
+ new Promise (resolve, reject) ~>
+ @api \users/show do
+ user_id: if @u.0 == \@ then undefined else @u
+ username: if @u.0 == \@ then @u.substr 1 else undefined
+ .then (user) ~>
+ resolve user
+ else
+ Promise.resolve @u
+
+ @on \mount ~>
+ @user-promise.then (user) ~>
+ @user = user
+ @update!
+
+ Velocity @root, {
+ opacity: 0
+ 'margin-top': \-8px
+ } 0ms
+ Velocity @root, {
+ opacity: 1
+ 'margin-top': 0
+ } {
+ duration: 200ms
+ easing: \ease-out
+ }
+
+ @close = ~>
+ Velocity @root, {
+ opacity: 0
+ 'margin-top': \-8px
+ } {
+ duration: 200ms
+ easing: \ease-out
+ complete: ~> @unmount!
+ }
diff --git a/src/web/app/desktop/tags/user-profile.tag b/src/web/app/desktop/tags/user-profile.tag
new file mode 100644
index 0000000000..195aefcdf7
--- /dev/null
+++ b/src/web/app/desktop/tags/user-profile.tag
@@ -0,0 +1,72 @@
+mk-user-profile
+ div.friend-form(if={ SIGNIN && I.id != user.id })
+ mk-big-follow-button(user={ user })
+ p.followed(if={ user.is_followed }) フォローされています
+ div.bio(if={ user.bio != '' }) { user.bio }
+ div.friends
+ p.following
+ i.fa.fa-angle-right
+ a(onclick={ show-following }) { user.following_count }
+ | 人を
+ b フォロー
+ p.followers
+ i.fa.fa-angle-right
+ a(onclick={ show-followers }) { user.followers_count }
+ | 人の
+ b フォロワー
+
+style.
+ display block
+ background #fff
+
+ > *:first-child
+ border-top none !important
+
+ > .friend-form
+ padding 16px
+ border-top solid 1px #eee
+
+ > mk-big-follow-button
+ width 100%
+
+ > .followed
+ margin 12px 0 0 0
+ padding 0
+ text-align center
+ line-height 24px
+ font-size 0.8em
+ color #71afc7
+ background #eefaff
+ border-radius 4px
+
+ > .bio
+ padding 16px
+ color #555
+ border-top solid 1px #eee
+
+ > .friends
+ padding 16px
+ color #555
+ border-top solid 1px #eee
+
+ > p
+ margin 8px 0
+
+ > i
+ margin-left 8px
+ margin-right 8px
+
+script.
+ @mixin \i
+
+ @user = @opts.user
+
+ @show-following = ~>
+ window = document.body.append-child document.create-element \mk-user-following-window
+ riot.mount window, do
+ user: @user
+
+ @show-followers = ~>
+ window = document.body.append-child document.create-element \mk-user-followers-window
+ riot.mount window, do
+ user: @user
diff --git a/src/web/app/desktop/tags/user-timeline.tag b/src/web/app/desktop/tags/user-timeline.tag
new file mode 100644
index 0000000000..ced90e2e84
--- /dev/null
+++ b/src/web/app/desktop/tags/user-timeline.tag
@@ -0,0 +1,142 @@
+mk-user-timeline
+ header
+ span(data-is-active={ mode == 'default' }, onclick={ set-mode.bind(this, 'default') }) 投稿
+ span(data-is-active={ mode == 'with-replies' }, onclick={ set-mode.bind(this, 'with-replies') }) 投稿と返信
+ div.loading(if={ is-loading })
+ mk-ellipsis-icon
+ p.empty(if={ is-empty })
+ i.fa.fa-comments-o
+ | このユーザーはまだ何も投稿していないようです。
+ mk-timeline@timeline
+ <yield to="footer">
+ i.fa.fa-moon-o(if={ !parent.more-loading })
+ i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading })
+ </yield>
+
+style.
+ display block
+ background #fff
+
+ > header
+ padding 8px 16px
+ border-bottom solid 1px #eee
+
+ > span
+ margin-right 16px
+ line-height 27px
+ font-size 18px
+ color #555
+
+ &:not([data-is-active])
+ color $theme-color
+ cursor pointer
+
+ &:hover
+ text-decoration underline
+
+ > .loading
+ padding 64px 0
+
+ > .empty
+ display block
+ margin 0 auto
+ padding 32px
+ max-width 400px
+ text-align center
+ color #999
+
+ > i
+ display block
+ margin-bottom 16px
+ font-size 3em
+ color #ccc
+
+script.
+ @mixin \api
+ @mixin \is-promise
+ @mixin \get-post-summary
+
+ @user = null
+ @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user
+ @is-loading = true
+ @is-empty = false
+ @more-loading = false
+ @unread-count = 0
+ @mode = \default
+
+ @on \mount ~>
+ document.add-event-listener \visibilitychange @window-on-visibilitychange, false
+ document.add-event-listener \keydown @on-document-keydown
+ window.add-event-listener \scroll @on-scroll
+
+ @user-promise.then (user) ~>
+ @user = user
+ @update!
+
+ @fetch ~>
+ @trigger \loaded
+
+ @on \unmount ~>
+ document.remove-event-listener \visibilitychange @window-on-visibilitychange
+ document.remove-event-listener \keydown @on-document-keydown
+ window.remove-event-listener \scroll @on-scroll
+
+ @on-document-keydown = (e) ~>
+ tag = e.target.tag-name.to-lower-case!
+ if tag != \input and tag != \textarea
+ if e.which == 84 # t
+ @refs.timeline.focus!
+
+ @fetch = (cb) ~>
+ @api \users/posts do
+ user_id: @user.id
+ with_replies: @mode == \with-replies
+ .then (posts) ~>
+ @is-loading = false
+ @is-empty = posts.length == 0
+ @update!
+ @refs.timeline.set-posts posts
+ if cb? then cb!
+ .catch (err) ~>
+ console.error err
+ if cb? then cb!
+
+ @more = ~>
+ if @more-loading or @is-loading or @refs.timeline.posts.length == 0
+ return
+ @more-loading = true
+ @update!
+ @api \users/posts do
+ user_id: @user.id
+ with_replies: @mode == \with-replies
+ max_id: @refs.timeline.tail!.id
+ .then (posts) ~>
+ @more-loading = false
+ @update!
+ @refs.timeline.prepend-posts posts
+ .catch (err) ~>
+ console.error err
+
+ @on-stream-post = (post) ~>
+ @is-empty = false
+ @update!
+ @refs.timeline.add-post post
+
+ if document.hidden
+ @unread-count++
+ document.title = '(' + @unread-count + ') ' + @get-post-summary post
+
+ @window-on-visibilitychange = ~>
+ if !document.hidden
+ @unread-count = 0
+ document.title = 'Misskey'
+
+ @on-scroll = ~>
+ current = window.scroll-y + window.inner-height
+ if current > document.body.offset-height - 16 # 遊び
+ @more!
+
+ @set-mode = (mode) ~>
+ @update do
+ mode: mode
+ @fetch!
diff --git a/src/web/app/desktop/tags/user.tag b/src/web/app/desktop/tags/user.tag
new file mode 100644
index 0000000000..4d022e68c4
--- /dev/null
+++ b/src/web/app/desktop/tags/user.tag
@@ -0,0 +1,45 @@
+mk-user
+ div.user(if={ !fetching })
+ header
+ mk-user-header(user={ user })
+ div.body
+ mk-user-home(if={ page == 'home' }, user={ user })
+ mk-user-graphs(if={ page == 'graphs' }, user={ user })
+
+style.
+ display block
+ background #fff
+
+ > .user
+ > header
+ max-width 560px + 270px
+ margin 0 auto
+ padding 0 16px
+
+ > mk-user-header
+ border solid 1px rgba(0, 0, 0, 0.075)
+ border-top none
+ border-radius 0 0 6px 6px
+ overflow hidden
+
+ > .body
+ max-width 560px + 270px
+ margin 0 auto
+ padding 0 16px
+
+script.
+ @mixin \api
+
+ @username = @opts.user
+ @page = if @opts.page? then @opts.page else \home
+ @fetching = true
+ @user = null
+
+ @on \mount ~>
+ @api \users/show do
+ username: @username
+ .then (user) ~>
+ @fetching = false
+ @user = user
+ @update!
+ @trigger \loaded
diff --git a/src/web/app/desktop/tags/users-list.tag b/src/web/app/desktop/tags/users-list.tag
new file mode 100644
index 0000000000..9ae96eed9f
--- /dev/null
+++ b/src/web/app/desktop/tags/users-list.tag
@@ -0,0 +1,139 @@
+mk-users-list
+ nav: div
+ span(data-is-active={ mode == 'all' }, onclick={ set-mode.bind(this, 'all') })
+ | すべて
+ span { opts.count }
+ // ↓ https://github.com/riot/riot/issues/2080
+ span(if={ SIGNIN && opts.you-know-count != '' }, data-is-active={ mode == 'iknow' }, onclick={ set-mode.bind(this, 'iknow') })
+ | 知り合い
+ span { opts.you-know-count }
+
+ div.users(if={ !fetching && users.length != 0 })
+ div(each={ users }): mk-list-user(user={ this })
+
+ button.more(if={ !fetching && next != null }, onclick={ more }, disabled={ more-fetching })
+ span(if={ !more-fetching }) もっと
+ span(if={ more-fetching })
+ | 読み込み中
+ mk-ellipsis
+
+ p.no(if={ !fetching && users.length == 0 })
+ | { opts.no-users }
+ p.fetching(if={ fetching })
+ i.fa.fa-spinner.fa-pulse.fa-fw
+ | 読み込んでいます
+ mk-ellipsis
+
+style.
+ display block
+ height 100%
+ background #fff
+
+ > nav
+ z-index 1
+ box-shadow 0 1px 0 rgba(#000, 0.1)
+
+ > div
+ display flex
+ justify-content center
+ margin 0 auto
+ max-width 600px
+
+ > span
+ display block
+ flex 1 1
+ text-align center
+ line-height 52px
+ font-size 14px
+ color #657786
+ border-bottom solid 2px transparent
+ cursor pointer
+
+ *
+ pointer-events none
+
+ &[data-is-active]
+ font-weight bold
+ color $theme-color
+ border-color $theme-color
+ cursor default
+
+ > span
+ display inline-block
+ margin-left 4px
+ padding 2px 5px
+ font-size 12px
+ line-height 1
+ color #888
+ background #eee
+ border-radius 20px
+
+ > .users
+ height calc(100% - 54px)
+ overflow auto
+
+ > *
+ border-bottom solid 1px rgba(0, 0, 0, 0.05)
+
+ > *
+ max-width 600px
+ margin 0 auto
+
+ > .no
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > .fetching
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+script.
+ @mixin \i
+
+ @limit = 30users
+ @mode = \all
+
+ @fetching = true
+ @more-fetching = false
+
+ @on \mount ~>
+ @fetch ~>
+ @trigger \loaded
+
+ @fetch = (cb) ~>
+ @fetching = true
+ @update!
+ obj <~ @opts.fetch do
+ @mode == \iknow
+ @limit
+ null
+ @users = obj.users
+ @next = obj.next
+ @fetching = false
+ @update!
+ if cb? then cb!
+
+ @more = ~>
+ @more-fetching = true
+ @update!
+ obj <~ @opts.fetch do
+ @mode == \iknow
+ @limit
+ @cursor
+ @users = @users.concat obj.users
+ @next = obj.next
+ @more-fetching = false
+ @update!
+
+ @set-mode = (mode) ~>
+ @update do
+ mode: mode
+
+ @fetch!
diff --git a/src/web/app/desktop/tags/window.tag b/src/web/app/desktop/tags/window.tag
new file mode 100644
index 0000000000..9732a6c552
--- /dev/null
+++ b/src/web/app/desktop/tags/window.tag
@@ -0,0 +1,515 @@
+mk-window(data-flexible={ is-flexible }, data-colored={ opts.colored }, ondragover={ ondragover })
+ div.bg@bg(show={ is-modal }, onclick={ bg-click })
+ div.main@main(tabindex='-1', data-is-modal={ is-modal }, onmousedown={ on-body-mousedown }, onkeydown={ on-keydown })
+ div.body
+ header@header(onmousedown={ on-header-mousedown })
+ h1(data-yield='header')
+ | <yield from="header"/>
+ button.close(if={ can-close }, onmousedown={ repel-move }, onclick={ close }, title='閉じる'): i.fa.fa-times
+ div.content(data-yield='content')
+ | <yield from="content"/>
+ div.handle.top(if={ can-resize }, onmousedown={ on-top-handle-mousedown })
+ div.handle.right(if={ can-resize }, onmousedown={ on-right-handle-mousedown })
+ div.handle.bottom(if={ can-resize }, onmousedown={ on-bottom-handle-mousedown })
+ div.handle.left(if={ can-resize }, onmousedown={ on-left-handle-mousedown })
+ div.handle.top-left(if={ can-resize }, onmousedown={ on-top-left-handle-mousedown })
+ div.handle.top-right(if={ can-resize }, onmousedown={ on-top-right-handle-mousedown })
+ div.handle.bottom-right(if={ can-resize }, onmousedown={ on-bottom-right-handle-mousedown })
+ div.handle.bottom-left(if={ can-resize }, onmousedown={ on-bottom-left-handle-mousedown })
+
+style.
+ display block
+
+ > .bg
+ display block
+ position fixed
+ z-index 2048
+ top 0
+ left 0
+ width 100%
+ height 100%
+ background rgba(0, 0, 0, 0.7)
+ opacity 0
+ pointer-events none
+
+ > .main
+ display block
+ position fixed
+ z-index 2048
+ top 15%
+ left 0
+ margin 0
+ opacity 0
+ pointer-events none
+
+ &:focus
+ &:not([data-is-modal])
+ > .body
+ box-shadow 0 0 0px 1px rgba($theme-color, 0.5), 0 2px 6px 0 rgba(0, 0, 0, 0.2)
+
+ > .handle
+ $size = 8px
+
+ position absolute
+
+ &.top
+ top -($size)
+ left 0
+ width 100%
+ height $size
+ cursor ns-resize
+
+ &.right
+ top 0
+ right -($size)
+ width $size
+ height 100%
+ cursor ew-resize
+
+ &.bottom
+ bottom -($size)
+ left 0
+ width 100%
+ height $size
+ cursor ns-resize
+
+ &.left
+ top 0
+ left -($size)
+ width $size
+ height 100%
+ cursor ew-resize
+
+ &.top-left
+ top -($size)
+ left -($size)
+ width $size * 2
+ height $size * 2
+ cursor nwse-resize
+
+ &.top-right
+ top -($size)
+ right -($size)
+ width $size * 2
+ height $size * 2
+ cursor nesw-resize
+
+ &.bottom-right
+ bottom -($size)
+ right -($size)
+ width $size * 2
+ height $size * 2
+ cursor nwse-resize
+
+ &.bottom-left
+ bottom -($size)
+ left -($size)
+ width $size * 2
+ height $size * 2
+ cursor nesw-resize
+
+ > .body
+ height 100%
+ overflow hidden
+ background #fff
+ border-radius 6px
+ box-shadow 0 2px 6px 0 rgba(0, 0, 0, 0.2)
+
+ > header
+ z-index 128
+ overflow hidden
+ cursor move
+ background #fff
+ border-radius 6px 6px 0 0
+ box-shadow 0 1px 0 rgba(#000, 0.1)
+
+ &, *
+ user-select none
+
+ > h1
+ pointer-events none
+ display block
+ margin 0
+ height 40px
+ text-align center
+ font-size 1em
+ line-height 40px
+ font-weight normal
+ color #666
+
+ > .close
+ cursor pointer
+ display block
+ position absolute
+ top 0
+ right 0
+ z-index 1
+ margin 0
+ padding 0
+ font-size 1.2em
+ color rgba(#000, 0.4)
+ border none
+ outline none
+ background transparent
+
+ &:hover
+ color rgba(#000, 0.6)
+
+ &:active
+ color darken(#000, 30%)
+
+ > i
+ padding 0
+ width 40px
+ line-height 40px
+
+ > .content
+ height 100%
+
+ &:not([flexible])
+ > .main > .body > .content
+ height calc(100% - 40px)
+
+ &[data-colored]
+
+ > .main > .body
+
+ > header
+ box-shadow 0 1px 0 rgba($theme-color, 0.1)
+
+ > h1
+ color #d0b4ac
+
+ > .close
+ color rgba($theme-color, 0.4)
+
+ &:hover
+ color rgba($theme-color, 0.6)
+
+ &:active
+ color darken($theme-color, 30%)
+
+script.
+ @min-height = 40px
+ @min-width = 200px
+
+ @is-modal = if @opts.is-modal? then @opts.is-modal else false
+ @can-close = if @opts.can-close? then @opts.can-close else true
+ @is-flexible = !@opts.height?
+ @can-resize = not @is-flexible
+
+ @on \mount ~>
+ @refs.main.style.width = @opts.width || \530px
+ @refs.main.style.height = @opts.height || \auto
+
+ @refs.main.style.top = \15%
+ @refs.main.style.left = (window.inner-width / 2) - (@refs.main.offset-width / 2) + \px
+
+ @refs.header.add-event-listener \contextmenu (e) ~>
+ e.prevent-default!
+
+ window.add-event-listener \resize @on-browser-resize
+
+ @open!
+
+ @on \unmount ~>
+ window.remove-event-listener \resize @on-browser-resize
+
+ @on-browser-resize = ~>
+ position = @refs.main.get-bounding-client-rect!
+ browser-width = window.inner-width
+ browser-height = window.inner-height
+ window-width = @refs.main.offset-width
+ window-height = @refs.main.offset-height
+
+ if position.left < 0
+ @refs.main.style.left = 0
+
+ if position.top < 0
+ @refs.main.style.top = 0
+
+ if position.left + window-width > browser-width
+ @refs.main.style.left = browser-width - window-width + \px
+
+ if position.top + window-height > browser-height
+ @refs.main.style.top = browser-height - window-height + \px
+
+ @open = ~>
+ @trigger \opening
+
+ @top!
+
+ if @is-modal
+ @refs.bg.style.pointer-events = \auto
+ Velocity @refs.bg, \finish true
+ Velocity @refs.bg, {
+ opacity: 1
+ } {
+ queue: false
+ duration: 100ms
+ easing: \linear
+ }
+
+ @refs.main.style.pointer-events = \auto
+ Velocity @refs.main, \finish true
+ Velocity @refs.main, {scale: 1.1} 0ms
+ Velocity @refs.main, {
+ opacity: 1
+ scale: 1
+ } {
+ queue: false
+ duration: 200ms
+ easing: \ease-out
+ }
+
+ #@refs.main.focus!
+
+ set-timeout ~>
+ @trigger \opened
+ , 300ms
+
+ @close = ~>
+ @trigger \closing
+
+ if @is-modal
+ @refs.bg.style.pointer-events = \none
+ Velocity @refs.bg, \finish true
+ Velocity @refs.bg, {
+ opacity: 0
+ } {
+ queue: false
+ duration: 300ms
+ easing: \linear
+ }
+
+ @refs.main.style.pointer-events = \none
+ Velocity @refs.main, \finish true
+ Velocity @refs.main, {
+ opacity: 0
+ scale: 0.8
+ } {
+ queue: false
+ duration: 300ms
+ easing: [ 0.5, -0.5, 1, 0.5 ]
+ }
+
+ set-timeout ~>
+ @trigger \closed
+ , 300ms
+
+ # 最前面へ移動します
+ @top = ~>
+ z = 0
+
+ ws = document.query-selector-all \mk-window
+ ws.for-each (w) !~>
+ if w == @root then return
+ m = w.query-selector ':scope > .main'
+ mz = Number(document.default-view.get-computed-style m, null .z-index)
+ if mz > z then z := mz
+
+ if z > 0
+ @refs.main.style.z-index = z + 1
+ if @is-modal then @refs.bg.style.z-index = z + 1
+
+ @repel-move = (e) ~>
+ e.stop-propagation!
+ return true
+
+ @bg-click = ~>
+ if @can-close
+ @close!
+
+ @on-body-mousedown = (e) ~>
+ @top!
+ true
+
+ # ヘッダー掴み時
+ @on-header-mousedown = (e) ~>
+ e.prevent-default!
+
+ if not contains @refs.main, document.active-element
+ @refs.main.focus!
+
+ position = @refs.main.get-bounding-client-rect!
+
+ click-x = e.client-x
+ click-y = e.client-y
+ move-base-x = click-x - position.left
+ move-base-y = click-y - position.top
+ browser-width = window.inner-width
+ browser-height = window.inner-height
+ window-width = @refs.main.offset-width
+ window-height = @refs.main.offset-height
+
+ # 動かした時
+ drag-listen (me) ~>
+ move-left = me.client-x - move-base-x
+ move-top = me.client-y - move-base-y
+
+ # 上はみ出し
+ if move-top < 0
+ move-top = 0
+
+ # 左はみ出し
+ if move-left < 0
+ move-left = 0
+
+ # 下はみ出し
+ if move-top + window-height > browser-height
+ move-top = browser-height - window-height
+
+ # 右はみ出し
+ if move-left + window-width > browser-width
+ move-left = browser-width - window-width
+
+ @refs.main.style.left = move-left + \px
+ @refs.main.style.top = move-top + \px
+
+ # 上ハンドル掴み時
+ @on-top-handle-mousedown = (e) ~>
+ e.prevent-default!
+
+ base = e.client-y
+ height = parse-int((get-computed-style @refs.main, '').height, 10)
+ top = parse-int((get-computed-style @refs.main, '').top, 10)
+
+ # 動かした時
+ drag-listen (me) ~>
+ move = me.client-y - base
+ if top + move > 0
+ if height + -move > @min-height
+ @apply-transform-height height + -move
+ @apply-transform-top top + move
+ else # 最小の高さより小さくなろうとした時
+ @apply-transform-height @min-height
+ @apply-transform-top top + (height - @min-height)
+ else # 上のはみ出し時
+ @apply-transform-height top + height
+ @apply-transform-top 0
+
+ # 右ハンドル掴み時
+ @on-right-handle-mousedown = (e) ~>
+ e.prevent-default!
+
+ base = e.client-x
+ width = parse-int((get-computed-style @refs.main, '').width, 10)
+ left = parse-int((get-computed-style @refs.main, '').left, 10)
+ browser-width = window.inner-width
+
+ # 動かした時
+ drag-listen (me) ~>
+ move = me.client-x - base
+ if left + width + move < browser-width
+ if width + move > @min-width
+ @apply-transform-width width + move
+ else # 最小の幅より小さくなろうとした時
+ @apply-transform-width @min-width
+ else # 右のはみ出し時
+ @apply-transform-width browser-width - left
+
+ # 下ハンドル掴み時
+ @on-bottom-handle-mousedown = (e) ~>
+ e.prevent-default!
+
+ base = e.client-y
+ height = parse-int((get-computed-style @refs.main, '').height, 10)
+ top = parse-int((get-computed-style @refs.main, '').top, 10)
+ browser-height = window.inner-height
+
+ # 動かした時
+ drag-listen (me) ~>
+ move = me.client-y - base
+ if top + height + move < browser-height
+ if height + move > @min-height
+ @apply-transform-height height + move
+ else # 最小の高さより小さくなろうとした時
+ @apply-transform-height @min-height
+ else # 下のはみ出し時
+ @apply-transform-height browser-height - top
+
+ # 左ハンドル掴み時
+ @on-left-handle-mousedown = (e) ~>
+ e.prevent-default!
+
+ base = e.client-x
+ width = parse-int((get-computed-style @refs.main, '').width, 10)
+ left = parse-int((get-computed-style @refs.main, '').left, 10)
+
+ # 動かした時
+ drag-listen (me) ~>
+ move = me.client-x - base
+ if left + move > 0
+ if width + -move > @min-width
+ @apply-transform-width width + -move
+ @apply-transform-left left + move
+ else # 最小の幅より小さくなろうとした時
+ @apply-transform-width @min-width
+ @apply-transform-left left + (width - @min-width)
+ else # 左のはみ出し時
+ @apply-transform-width left + width
+ @apply-transform-left 0
+
+ # 左上ハンドル掴み時
+ @on-top-left-handle-mousedown = (e) ~>
+ @on-top-handle-mousedown e
+ @on-left-handle-mousedown e
+
+ # 右上ハンドル掴み時
+ @on-top-right-handle-mousedown = (e) ~>
+ @on-top-handle-mousedown e
+ @on-right-handle-mousedown e
+
+ # 右下ハンドル掴み時
+ @on-bottom-right-handle-mousedown = (e) ~>
+ @on-bottom-handle-mousedown e
+ @on-right-handle-mousedown e
+
+ # 左下ハンドル掴み時
+ @on-bottom-left-handle-mousedown = (e) ~>
+ @on-bottom-handle-mousedown e
+ @on-left-handle-mousedown e
+
+ # 高さを適用
+ @apply-transform-height = (height) ~>
+ @refs.main.style.height = height + \px
+
+ # 幅を適用
+ @apply-transform-width = (width) ~>
+ @refs.main.style.width = width + \px
+
+ # Y座標を適用
+ @apply-transform-top = (top) ~>
+ @refs.main.style.top = top + \px
+
+ # X座標を適用
+ @apply-transform-left = (left) ~>
+ @refs.main.style.left = left + \px
+
+ function drag-listen fn
+ window.add-event-listener \mousemove fn
+ window.add-event-listener \mouseleave drag-clear.bind null fn
+ window.add-event-listener \mouseup drag-clear.bind null fn
+
+ function drag-clear fn
+ window.remove-event-listener \mousemove fn
+ window.remove-event-listener \mouseleave drag-clear
+ window.remove-event-listener \mouseup drag-clear
+
+ @ondragover = (e) ~>
+ e.data-transfer.drop-effect = \none
+
+ @on-keydown = (e) ~>
+ if e.which == 27 # Esc
+ if @can-close
+ e.prevent-default!
+ e.stop-propagation!
+ @close!
+
+ function contains(parent, child)
+ node = child.parent-node
+ while node?
+ if node == parent
+ return true
+ node = node.parent-node
+ return false
diff --git a/src/web/app/dev/router.ls b/src/web/app/dev/router.ls
new file mode 100644
index 0000000000..ac408b36ed
--- /dev/null
+++ b/src/web/app/dev/router.ls
@@ -0,0 +1,51 @@
+# Router
+#================================
+
+route = require \page
+page = null
+
+module.exports = (me) ~>
+
+ # Routing
+ #--------------------------------
+
+ route \/ index
+ route \/apps apps
+ route \/app/new new-app
+ route \/app/:app app
+ route \* not-found
+
+ # Handlers
+ #--------------------------------
+
+ function index
+ mount document.create-element \mk-index
+
+ function apps
+ mount document.create-element \mk-apps-page
+
+ function new-app
+ mount document.create-element \mk-new-app-page
+
+ function app ctx
+ document.create-element \mk-app-page
+ ..set-attribute \app ctx.params.app
+ .. |> mount
+
+ function not-found
+ mount document.create-element \mk-not-found
+
+ # Exec
+ #--------------------------------
+
+ route!
+
+# Mount
+#================================
+
+riot = require \riot
+
+function mount content
+ if page? then page.unmount!
+ body = document.get-element-by-id \app
+ page := riot.mount body.append-child content .0
diff --git a/src/web/app/dev/script.js b/src/web/app/dev/script.js
new file mode 100644
index 0000000000..407f4e84c0
--- /dev/null
+++ b/src/web/app/dev/script.js
@@ -0,0 +1,15 @@
+/**
+ * Developer Center
+ */
+
+require('./tags.ls');
+const boot = require('../boot.ls');
+const route = require('./router.ls');
+
+/**
+ * Boot
+ */
+boot(me => {
+ // Start routing
+ route(me);
+});
diff --git a/src/web/app/dev/style.styl b/src/web/app/dev/style.styl
new file mode 100644
index 0000000000..a7e51b8943
--- /dev/null
+++ b/src/web/app/dev/style.styl
@@ -0,0 +1,10 @@
+@import "../base"
+
+html
+ background-color #fff
+
+#init
+ background #100f0f
+
+ > p
+ color $theme-color
diff --git a/src/web/app/dev/tags.ls b/src/web/app/dev/tags.ls
new file mode 100644
index 0000000000..7783402634
--- /dev/null
+++ b/src/web/app/dev/tags.ls
@@ -0,0 +1,5 @@
+require './tags/pages/index.tag'
+require './tags/pages/apps.tag'
+require './tags/pages/app.tag'
+require './tags/pages/new-app.tag'
+require './tags/new-app-form.tag'
diff --git a/src/web/app/dev/tags/new-app-form.tag b/src/web/app/dev/tags/new-app-form.tag
new file mode 100644
index 0000000000..443bf2bfff
--- /dev/null
+++ b/src/web/app/dev/tags/new-app-form.tag
@@ -0,0 +1,260 @@
+mk-new-app-form
+ form(onsubmit={ onsubmit }, autocomplete='off')
+ section.name: label
+ p.caption
+ | アプリケーション名
+ input@name(
+ type='text'
+ placeholder='ex) Misskey for iOS'
+ autocomplete='off'
+ required)
+
+ section.nid: label
+ p.caption
+ | Named ID
+ input@nid(
+ type='text'
+ pattern='^[a-zA-Z0-9\-]{3,30}$'
+ placeholder='ex) misskey-for-ios'
+ autocomplete='off'
+ required
+ onkeyup={ on-change-nid })
+
+ p.info(if={ nid-state == 'wait' }, style='color:#999')
+ i.fa.fa-fw.fa-spinner.fa-pulse
+ | 確認しています...
+ p.info(if={ nid-state == 'ok' }, style='color:#3CB7B5')
+ i.fa.fa-fw.fa-check
+ | 利用できます
+ p.info(if={ nid-state == 'unavailable' }, style='color:#FF1161')
+ i.fa.fa-fw.fa-exclamation-triangle
+ | 既に利用されています
+ p.info(if={ nid-state == 'error' }, style='color:#FF1161')
+ i.fa.fa-fw.fa-exclamation-triangle
+ | 通信エラー
+ p.info(if={ nid-state == 'invalid-format' }, style='color:#FF1161')
+ i.fa.fa-fw.fa-exclamation-triangle
+ | a~z、A~Z、0~9、-(ハイフン)が使えます
+ p.info(if={ nid-state == 'min-range' }, style='color:#FF1161')
+ i.fa.fa-fw.fa-exclamation-triangle
+ | 3文字以上でお願いします!
+ p.info(if={ nid-state == 'max-range' }, style='color:#FF1161')
+ i.fa.fa-fw.fa-exclamation-triangle
+ | 30文字以内でお願いします
+
+ section.description: label
+ p.caption
+ | アプリの概要
+ textarea@description(
+ placeholder='ex) Misskey iOSクライアント。'
+ autocomplete='off'
+ required)
+
+ section.callback: label
+ p.caption
+ | コールバックURL (オプション)
+ input@cb(
+ type='url'
+ placeholder='ex) https://your.app.example.com/callback.php'
+ autocomplete='off')
+
+ section.permission
+ p.caption
+ | 権限
+ div@permission
+ label
+ input(type='checkbox', value='account-read')
+ p アカウントの情報を見る。
+ label
+ input(type='checkbox', value='account-write')
+ p アカウントの情報を操作する。
+ label
+ input(type='checkbox', value='post-write')
+ p 投稿する。
+ label
+ input(type='checkbox', value='like-write')
+ p いいねしたりいいね解除する。
+ label
+ input(type='checkbox', value='following-write')
+ p フォローしたりフォロー解除する。
+ label
+ input(type='checkbox', value='drive-read')
+ p ドライブを見る。
+ label
+ input(type='checkbox', value='drive-write')
+ p ドライブを操作する。
+ label
+ input(type='checkbox', value='notification-read')
+ p 通知を見る。
+ label
+ input(type='checkbox', value='notification-write')
+ p 通知を操作する。
+ p
+ i.fa.fa-exclamation-triangle
+ | アプリ作成後も変更できますが、新たな権限を付与する場合、その時点で関連付けられているユーザーキーはすべて無効になります。
+
+ button(onclick={ onsubmit })
+ | アプリ作成
+
+style.
+ display block
+ overflow hidden
+
+ > form
+
+ section
+ display block
+ margin 16px 0
+
+ .caption
+ margin 0 0 4px 0
+ color #616161
+ font-size 0.95em
+
+ > i
+ margin-right 0.25em
+ color #96adac
+
+ .info
+ display block
+ margin 4px 0
+ font-size 0.8em
+
+ > i
+ margin-right 0.3em
+
+ section.permission
+ div
+ padding 8px 0
+ max-height 160px
+ overflow auto
+ background #fff
+ border solid 1px #cecece
+ border-radius 4px
+
+ label
+ display block
+ padding 0 12px
+ line-height 32px
+ cursor pointer
+
+ &:hover
+ > p
+ color #999
+
+ [type='checkbox']:checked + p
+ color #000
+
+ [type='checkbox']
+ margin-right 4px
+
+ [type='checkbox']:checked + p
+ color #111
+
+ > p
+ display inline
+ color #aaa
+ user-select none
+
+ > p:last-child
+ margin 6px
+ font-size 0.8em
+ color #999
+
+ > i
+ margin-right 4px
+
+ [type=text]
+ [type=url]
+ textarea
+ user-select text
+ display inline-block
+ cursor auto
+ padding 8px 12px
+ margin 0
+ width 100%
+ font-size 1em
+ color #333
+ background #fff
+ outline none
+ border solid 1px #cecece
+ border-radius 4px
+
+ &:hover
+ border-color #bbb
+
+ &:focus
+ border-color $theme-color
+
+ &:disabled
+ opacity 0.5
+
+ > button
+ margin 20px 0 32px 0
+ width 100%
+ font-size 1em
+ color #111
+ border-radius 3px
+
+script.
+ @mixin \api
+
+ @nid-state = null
+
+ @on-change-nid = ~>
+ nid = @refs.nid.value
+
+ if nid == ''
+ @nid-state = null
+ @update!
+ return
+
+ err = switch
+ | not nid.match /^[a-zA-Z0-9\-]+$/ => \invalid-format
+ | nid.length < 3chars => \min-range
+ | nid.length > 30chars => \max-range
+ | _ => null
+
+ if err?
+ @nid-state = err
+ @update!
+ else
+ @nid-state = \wait
+ @update!
+
+ @api \app/name_id/available do
+ name_id: nid
+ .then (result) ~>
+ if result.available
+ @nid-state = \ok
+ else
+ @nid-state = \unavailable
+ @update!
+ .catch (err) ~>
+ @nid-state = \error
+ @update!
+
+ @onsubmit = ~>
+ name = @refs.name.value
+ nid = @refs.nid.value
+ description = @refs.description.value
+ cb = @refs.cb.value
+ permission = []
+
+ @refs.permission.query-selector-all \input .for-each (el) ~>
+ if el.checked then permission.push el.value
+
+ locker = document.body.append-child document.create-element \mk-locker
+
+ @api \app/create do
+ name: name
+ name_id: nid
+ description: description
+ callback_url: cb
+ permission: permission.join \,
+ .then ~>
+ location.href = '/apps'
+ .catch ~>
+ alert 'アプリの作成に失敗しました。再度お試しください。'
+
+ locker.parent-node.remove-child locker
diff --git a/src/web/app/dev/tags/pages/app.tag b/src/web/app/dev/tags/pages/app.tag
new file mode 100644
index 0000000000..aa9ba68f3f
--- /dev/null
+++ b/src/web/app/dev/tags/pages/app.tag
@@ -0,0 +1,24 @@
+mk-app-page
+ p(if={ fetching }) 読み込み中
+ main(if={ !fetching })
+ header
+ h1 { app.name }
+ div.body
+ p App Secret
+ input(value={ app.secret }, readonly)
+
+style.
+ display block
+
+script.
+ @mixin \api
+
+ @fetching = true
+
+ @on \mount ~>
+ @api \app/show do
+ app_id: @opts.app
+ .then (app) ~>
+ @app = app
+ @fetching = false
+ @update!
diff --git a/src/web/app/dev/tags/pages/apps.tag b/src/web/app/dev/tags/pages/apps.tag
new file mode 100644
index 0000000000..f46a9d3282
--- /dev/null
+++ b/src/web/app/dev/tags/pages/apps.tag
@@ -0,0 +1,26 @@
+mk-apps-page
+ h1 アプリを管理
+ a(href='/app/new') アプリ作成
+ div.apps
+ p(if={ fetching }) 読み込み中
+ virtual(if={ !fetching })
+ p(if={ apps.length == 0 }) アプリなし
+ ul(if={ apps.length > 0 })
+ li(each={ app in apps })
+ a(href={ '/app/' + app.id })
+ p.name { app.name }
+
+style.
+ display block
+
+script.
+ @mixin \api
+
+ @fetching = true
+
+ @on \mount ~>
+ @api \my/apps
+ .then (apps) ~>
+ @fetching = false
+ @apps = apps
+ @update!
diff --git a/src/web/app/dev/tags/pages/index.tag b/src/web/app/dev/tags/pages/index.tag
new file mode 100644
index 0000000000..7bc57fbb00
--- /dev/null
+++ b/src/web/app/dev/tags/pages/index.tag
@@ -0,0 +1,5 @@
+mk-index
+ a(href='/apps') アプリ
+
+style.
+ display block
diff --git a/src/web/app/dev/tags/pages/new-app.tag b/src/web/app/dev/tags/pages/new-app.tag
new file mode 100644
index 0000000000..8c19e39f4b
--- /dev/null
+++ b/src/web/app/dev/tags/pages/new-app.tag
@@ -0,0 +1,33 @@
+mk-new-app-page
+ main
+ header
+ h1 新しいアプリを作成
+ p MisskeyのAPIを利用したアプリケーションを作成できます。
+ mk-new-app-form
+
+style.
+ display block
+ padding 64px 0
+
+ > main
+ width 100%
+ max-width 700px
+ margin 0 auto
+
+ > header
+ margin 0 0 16px 0
+ padding 0 0 16px 0
+ border-bottom solid 1px #282827
+
+ > h1
+ margin 0 0 12px 0
+ padding 0
+ line-height 32px
+ font-size 32px
+ font-weight normal
+ color #000
+
+ > p
+ margin 0
+ line-height 16px
+ color #9a9894
diff --git a/src/web/app/dev/view.pug b/src/web/app/dev/view.pug
new file mode 100644
index 0000000000..aea2f2adb8
--- /dev/null
+++ b/src/web/app/dev/view.pug
@@ -0,0 +1,5 @@
+extends ../base
+
+block head
+ link(rel='stylesheet', href='/_/resources/dev/style.css')
+ script(src='/_/resources/dev/script.js', async, defer)
diff --git a/src/web/app/init.styl b/src/web/app/init.styl
new file mode 100644
index 0000000000..972997725d
--- /dev/null
+++ b/src/web/app/init.styl
@@ -0,0 +1,56 @@
+@charset 'utf-8'
+
+html
+ font-family sans-serif
+
+body > noscript > div
+ position fixed
+ z-index 32768
+ top 0
+ left 0
+ width 100%
+ height 100%
+ text-align center
+ background #fff
+
+ > p
+ display block
+ margin 32px
+ font-size 2em
+ color #555
+
+#init
+ position fixed
+ z-index 16384
+ top 0
+ left 0
+ width 100%
+ height 100%
+ text-align center
+ background #fff
+ cursor wait
+
+ > p
+ display block
+ user-select none
+ margin 32px
+ font-size 4em
+ color #555
+
+ > span
+ animation init 1.4s infinite ease-in-out both
+
+ &:nth-child(1)
+ animation-delay 0s
+
+ &:nth-child(2)
+ animation-delay 0.16s
+
+ &:nth-child(3)
+ animation-delay 0.32s
+
+ @keyframes init
+ 0%, 80%, 100%
+ opacity 1
+ 40%
+ opacity 0
diff --git a/src/web/app/mobile/mixins.ls b/src/web/app/mobile/mixins.ls
new file mode 100644
index 0000000000..902774f91a
--- /dev/null
+++ b/src/web/app/mobile/mixins.ls
@@ -0,0 +1,19 @@
+riot = require \riot
+
+module.exports = (me) ~>
+ if me?
+ (require './scripts/stream.ls') me
+
+ require './scripts/ui.ls'
+
+ riot.mixin \open-post-form do
+ open-post-form: (opts) ->
+ app = document.get-element-by-id \app
+ app.style.display = \none
+ form = document.body.append-child document.create-element \mk-post-form
+ form = riot.mount form, opts .0
+ form.on \cancel recover
+ form.on \post recover
+
+ function recover
+ app.style.display = \block
diff --git a/src/web/app/mobile/router.ls b/src/web/app/mobile/router.ls
new file mode 100644
index 0000000000..33ae3e82da
--- /dev/null
+++ b/src/web/app/mobile/router.ls
@@ -0,0 +1,110 @@
+# Router
+#================================
+
+riot = require \riot
+route = require \page
+page = null
+
+module.exports = (me) ~>
+
+ # Routing
+ #--------------------------------
+
+ route \/ index
+ route \/i/notifications notifications
+ route \/i/drive drive
+ route \/i/drive/folder/:folder drive
+ route \/i/drive/file/:file drive
+ route \/post/new new-post
+ route \/post::post post
+ route \/search::query search
+ route \/:user user.bind null \posts
+ route \/:user/graphs user.bind null \graphs
+ route \/:user/followers user-followers
+ route \/:user/following user-following
+ route \/:user/:post post
+ route \* not-found
+
+ # Handlers
+ #--------------------------------
+
+ # /
+ function index
+ if me? then home! else entrance!
+
+ # ホーム
+ function home
+ mount document.create-element \mk-home-page
+
+ # 玄関
+ function entrance
+ mount document.create-element \mk-entrance
+
+ # 通知
+ function notifications
+ mount document.create-element \mk-notifications-page
+
+ # 新規投稿
+ function new-post
+ mount document.create-element \mk-new-post-page
+
+ # 検索
+ function search ctx
+ document.create-element \mk-search-page
+ ..set-attribute \query ctx.params.query
+ .. |> mount
+
+ # ユーザー
+ function user page, ctx
+ document.create-element \mk-user-page
+ ..set-attribute \user ctx.params.user
+ ..set-attribute \page page
+ .. |> mount
+
+ # フォロー一覧
+ function user-following ctx
+ document.create-element \mk-user-following-page
+ ..set-attribute \user ctx.params.user
+ .. |> mount
+
+ # フォロワー一覧
+ function user-followers ctx
+ document.create-element \mk-user-followers-page
+ ..set-attribute \user ctx.params.user
+ .. |> mount
+
+ # 投稿詳細ページ
+ function post ctx
+ document.create-element \mk-post-page
+ ..set-attribute \post ctx.params.post
+ .. |> mount
+
+ # ドライブ
+ function drive ctx
+ p = document.create-element \mk-drive-page
+ if ctx.params.folder then p.set-attribute \folder ctx.params.folder
+ if ctx.params.file then p.set-attribute \file ctx.params.file
+ mount p
+
+ # not found
+ function not-found
+ mount document.create-element \mk-not-found
+
+ # Register mixin
+ #--------------------------------
+
+ riot.mixin \page do
+ page: route
+
+ # Exec
+ #--------------------------------
+
+ route!
+
+# Mount
+#================================
+
+function mount content
+ if page? then page.unmount!
+ body = document.get-element-by-id \app
+ page := riot.mount body.append-child content .0
diff --git a/src/web/app/mobile/script.js b/src/web/app/mobile/script.js
new file mode 100644
index 0000000000..1c269a57d9
--- /dev/null
+++ b/src/web/app/mobile/script.js
@@ -0,0 +1,20 @@
+/**
+ * Mobile Client
+ */
+
+require('./tags.ls');
+require('./scripts/sp-slidemenu.js');
+const boot = require('../boot.ls');
+const mixins = require('./mixins.ls');
+const route = require('./router.ls');
+
+/**
+ * Boot
+ */
+boot(me => {
+ // Register mixins
+ mixins(me);
+
+ // Start routing
+ route(me);
+});
diff --git a/src/web/app/mobile/scripts/sp-slidemenu.js b/src/web/app/mobile/scripts/sp-slidemenu.js
new file mode 100644
index 0000000000..f2dcae9cef
--- /dev/null
+++ b/src/web/app/mobile/scripts/sp-slidemenu.js
@@ -0,0 +1,839 @@
+/**
+ * sp-slidemenu.js
+ *
+ * @version 0.1.0
+ * @url https://github.com/be-hase/sp-slidemenu
+ *
+ * Copyright 2013 be-hase.com, Inc.
+ * Licensed under the MIT License:
+ * http://www.opensource.org/licenses/mit-license.php
+ */
+
+/**
+ * CUSTOMIZED BY SYUILO
+ */
+
+; (function(window, document, undefined) {
+ "use strict";
+ var div, PREFIX, support, gestureStart, EVENTS, ANIME_SPEED, SLIDE_STATUS, SCROLL_STATUS, THRESHOLD, EVENT_MOE_TIME, rclass, ITEM_CLICK_CLASS_NAME;
+ div = document.createElement('div');
+ PREFIX = ['webkit', 'moz', 'o', 'ms'];
+ support = SpSlidemenu.support = {};
+ support.transform3d = hasProp([
+ 'perspectiveProperty',
+ 'WebkitPerspective',
+ 'MozPerspective',
+ 'OPerspective',
+ 'msPerspective'
+ ]);
+ support.transform = hasProp([
+ 'transformProperty',
+ 'WebkitTransform',
+ 'MozTransform',
+ 'OTransform',
+ 'msTransform'
+ ]);
+ support.transition = hasProp([
+ 'transitionProperty',
+ 'WebkitTransitionProperty',
+ 'MozTransitionProperty',
+ 'OTransitionProperty',
+ 'msTransitionProperty'
+ ]);
+ support.addEventListener = 'addEventListener' in window;
+ support.msPointer = window.navigator.msPointerEnabled;
+ support.cssAnimation = (support.transform3d || support.transform) && support.transition;
+ support.touch = 'ontouchend' in window;
+ EVENTS = {
+ start: {
+ touch: 'touchstart',
+ mouse: 'mousedown'
+ },
+ move: {
+ touch: 'touchmove',
+ mouse: 'mousemove'
+ },
+ end: {
+ touch: 'touchend',
+ mouse: 'mouseup'
+ }
+ };
+ gestureStart = false;
+ if (support.addEventListener) {
+ document.addEventListener('gesturestart', function() {
+ gestureStart = true;
+ });
+ document.addEventListener('gestureend', function() {
+ gestureStart = false;
+ });
+ }
+ ANIME_SPEED = {
+ slider: 200,
+ scrollOverBack: 400
+ };
+ SLIDE_STATUS = {
+ close: 0,
+ open: 1,
+ progress: 2
+ };
+ THRESHOLD = 10;
+ EVENT_MOE_TIME = 50;
+ rclass = /[\t\r\n\f]/g;
+ ITEM_CLICK_CLASS_NAME = 'menu-item';
+ /*
+ [MEMO]
+ SpSlidemenu properties which is not function is ...
+ -- element --
+ element: main
+ element: slidemenu
+ element: button
+ element: slidemenuBody
+ element: slidemenuContent
+ element: slidemenuHeader
+ -- options --
+ bool: disableCssAnimation
+ bool: disabled3d
+ -- animation --
+ bool: useCssAnimation
+ bool: use3d
+ -- slide --
+ int: slideWidth
+ string: htmlOverflowX
+ string: bodyOverflowX
+ int: buttonStartPageX
+ int: buttonStartPageY
+ -- scroll --
+ bool: scrollTouchStarted
+ bool: scrollMoveReady
+ int: scrollStartPageX
+ int: scrollStartPageY
+ int: scrollBasePageY
+ int: scrollTimeForVelocity
+ int: scrollCurrentY
+ int: scrollMoveEventCnt
+ int: scrollAnimationTimer
+ int: scrollOverTimer
+ int: scrollMaxY
+ */
+ function SpSlidemenu(main, slidemenu, button, options) {
+ if (this instanceof SpSlidemenu) {
+ return this.init(main, slidemenu, button, options);
+ } else {
+ return new SpSlidemenu(main, slidemenu, button, options);
+ }
+ }
+ SpSlidemenu.prototype.init = function(main, slidemenu, button, options) {
+ var _this = this;
+ // find and set element.
+ _this.setElement(main, slidemenu, button);
+ if (!_this.main || !_this.slidemenu || !_this.button || !_this.slidemenuBody || !_this.slidemenuContent) {
+ throw new Error('Element not found. Please set correctly.');
+ }
+ // options
+ options = options || {};
+ _this.disableCssAnimation = (options.disableCssAnimation === undefined) ? false : options.disableCssAnimation;
+ _this.disable3d = (options.disable3d === undefined) ? false : options.disable3d;
+ _this.direction = 'left';
+ if (options.direction === 'right') {
+ _this.direction = 'right';
+ }
+ // animation
+ _this.useCssAnimation = support.cssAnimation;
+ if (_this.disableCssAnimation === true) {
+ _this.useCssAnimation = false;
+ }
+ _this.use3d = support.transform3d;
+ if (_this.disable3d === true) {
+ _this.use3d = false;
+ }
+ // slide
+ _this.slideWidth = (getDimentions(_this.slidemenu)).width;
+ _this.main.SpSlidemenuStatus = SLIDE_STATUS.close;
+ _this.htmlOverflowX = '';
+ _this.bodyOverflowX = '';
+ // scroll
+ _this.scrollCurrentY = 0;
+ _this.scrollAnimationTimer = false;
+ _this.scrollOverTimer = false;
+ // set default style.
+ _this.setDefaultStyle();
+ // bind some method for callback.
+ _this.bindMethods();
+ // add event
+ addTouchEvent('start', _this.button, _this.buttonTouchStart, false);
+ addTouchEvent('move', _this.button, blockEvent, false);
+ addTouchEvent('end', _this.button, _this.buttonTouchEnd, false);
+ addTouchEvent('start', _this.slidemenuContent, _this.scrollTouchStart, false);
+ addTouchEvent('move', _this.slidemenuContent, _this.scrollTouchMove, false);
+ addTouchEvent('end', _this.slidemenuContent, _this.scrollTouchEnd, false);
+ _this.slidemenuContent.addEventListener('click', _this.itemClick, false);
+ // window size change
+ window.addEventListener('resize', debounce(_this.setHeight, 100), false);
+ return _this;
+ };
+ SpSlidemenu.prototype.bindMethods = function() {
+ var _this, funcs;
+ _this = this;
+ funcs = [
+ 'setHeight',
+ 'slideOpen', 'slideOpenEnd', 'slideClose', 'slideCloseEnd',
+ 'buttonTouchStart', 'buttonTouchEnd', 'mainTouchStart',
+ 'scrollTouchStart', 'scrollTouchMove', 'scrollTouchEnd', 'scrollInertiaMove', 'scrollOverBack', 'scrollOver',
+ 'itemClick'
+ ];
+ funcs.forEach(function(func) {
+ _this[func] = bind(_this[func], _this);
+ });
+ };
+ SpSlidemenu.prototype.setElement = function(main, slidemenu, button) {
+ var _this = this;
+ _this.main = main;
+ if (typeof main === 'string') {
+ _this.main = document.querySelector(main);
+ }
+ _this.slidemenu = slidemenu;
+ if (typeof slidemenu === 'string') {
+ _this.slidemenu = document.querySelector(slidemenu);
+ }
+ _this.button = button;
+ if (typeof button === 'string') {
+ _this.button = document.querySelector(button);
+ }
+ if (!_this.slidemenu) {
+ return;
+ }
+ _this.slidemenuBody = _this.slidemenu.querySelector('.body');
+ _this.slidemenuContent = _this.slidemenu.querySelector('.content');
+ _this.slidemenuHeader = _this.slidemenu.querySelector('.header');
+ };
+ SpSlidemenu.prototype.setDefaultStyle = function() {
+ var _this = this;
+ if (support.msPointer) {
+ _this.slidemenuContent.style.msTouchAction = 'none';
+ }
+ _this.setHeight();
+ if (_this.useCssAnimation) {
+ setStyles(_this.main, {
+ transitionProperty: getCSSName('transform'),
+ transitionTimingFunction: 'ease-in-out',
+ transitionDuration: ANIME_SPEED.slider + 'ms',
+ transitionDelay: '0ms',
+ transform: _this.getTranslateX(0)
+ });
+ setStyles(_this.slidemenu, {
+ transitionProperty: 'visibility',
+ transitionTimingFunction: 'linear',
+ transitionDuration: '0ms',
+ transitionDelay: ANIME_SPEED.slider + 'ms'
+ });
+ setStyles(_this.slidemenuContent, {
+ transitionProperty: getCSSName('transform'),
+ transitionTimingFunction: 'ease-in-out',
+ transitionDuration: '0ms',
+ transitionDelay: '0ms',
+ transform: _this.getTranslateY(0)
+ });
+ } else {
+ setStyles(_this.main, {
+ position: 'relative',
+ left: '0px'
+ });
+ setStyles(_this.slidemenuContent, {
+ top: '0px'
+ });
+ }
+ };
+ SpSlidemenu.prototype.setHeight = function(event) {
+ var _this, browserHeight;
+ _this = this;
+ browserHeight = getBrowserHeight();
+ setStyles(_this.main, {
+ minHeight: browserHeight + 'px'
+ });
+ setStyles(_this.slidemenu, {
+ height: browserHeight + 'px'
+ });
+ };
+ SpSlidemenu.prototype.buttonTouchStart = function(event) {
+ var _this = this;
+ event.preventDefault();
+ event.stopPropagation();
+ switch (_this.main.SpSlidemenuStatus) {
+ case SLIDE_STATUS.progress:
+ break;
+ case SLIDE_STATUS.open:
+ case SLIDE_STATUS.close:
+ _this.buttonStartPageX = getPage(event, 'pageX');
+ _this.buttonStartPageY = getPage(event, 'pageY');
+ break;
+ }
+ };
+ SpSlidemenu.prototype.buttonTouchEnd = function(event) {
+ var _this = this;
+ event.preventDefault();
+ event.stopPropagation();
+ if (_this.shouldTrigerNext(event)) {
+ switch (_this.main.SpSlidemenuStatus) {
+ case SLIDE_STATUS.progress:
+ break;
+ case SLIDE_STATUS.open:
+ _this.slideClose(event);
+ break;
+ case SLIDE_STATUS.close:
+ _this.slideOpen(event);
+ break;
+ }
+ }
+ };
+ SpSlidemenu.prototype.mainTouchStart = function(event) {
+ var _this = this;
+ event.preventDefault();
+ event.stopPropagation();
+ _this.slideClose(event);
+ };
+ SpSlidemenu.prototype.shouldTrigerNext = function(event) {
+ var _this = this,
+ buttonEndPageX = getPage(event, 'pageX'),
+ buttonEndPageY = getPage(event, 'pageY'),
+ deltaX = Math.abs(buttonEndPageX - _this.buttonStartPageX),
+ deltaY = Math.abs(buttonEndPageY - _this.buttonStartPageY);
+ return deltaX < 20 && deltaY < 20;
+ };
+ SpSlidemenu.prototype.slideOpen = function(event) {
+ var _this = this, toX;
+
+ /// Misskey Original
+ document.body.setAttribute('data-nav-open', 'true');
+
+ if (_this.direction === 'left') {
+ toX = _this.slideWidth;
+ } else {
+ toX = -_this.slideWidth;
+ }
+ _this.main.SpSlidemenuStatus = SLIDE_STATUS.progress;
+ //set event
+ addTouchEvent('move', document, blockEvent, false);
+ // change style
+ _this.htmlOverflowX = document.documentElement.style['overflowX'];
+ _this.bodyOverflowX = document.body.style['overflowX'];
+ document.documentElement.style['overflowX'] = document.body.style['overflowX'] = 'hidden';
+ if (_this.useCssAnimation) {
+ setStyles(_this.main, {
+ transform: _this.getTranslateX(toX)
+ });
+ setStyles(_this.slidemenu, {
+ transitionProperty: 'z-index',
+ visibility: 'visible',
+ zIndex: '1'
+ });
+ } else {
+ animate(_this.main, _this.direction, toX, ANIME_SPEED.slider);
+ setStyles(_this.slidemenu, {
+ visibility: 'visible'
+ });
+ }
+ // set callback
+ setTimeout(_this.slideOpenEnd, ANIME_SPEED.slider + EVENT_MOE_TIME);
+ };
+ SpSlidemenu.prototype.slideOpenEnd = function() {
+ var _this = this;
+ _this.main.SpSlidemenuStatus = SLIDE_STATUS.open;
+ // change style
+ if (_this.useCssAnimation) {
+ } else {
+ setStyles(_this.slidemenu, {
+ zIndex: '1'
+ });
+ }
+ // add event
+ addTouchEvent('start', _this.main, _this.mainTouchStart, false);
+ };
+ SpSlidemenu.prototype.slideClose = function(event) {
+ var _this = this;
+ _this.main.SpSlidemenuStatus = SLIDE_STATUS.progress;
+
+ /// Misskey Original
+ document.body.setAttribute('data-nav-open', 'false');
+
+ //event
+ removeTouchEvent('start', _this.main, _this.mainTouchStart, false);
+ // change style
+ if (_this.useCssAnimation) {
+ setStyles(_this.main, {
+ transform: _this.getTranslateX(0)
+ });
+ setStyles(_this.slidemenu, {
+ transitionProperty: 'visibility',
+ visibility: 'hidden',
+ zIndex: '-1'
+ });
+ } else {
+ animate(_this.main, _this.direction, 0, ANIME_SPEED.slider);
+ setStyles(_this.slidemenu, {
+ zIndex: '-1'
+ });
+ }
+ // set callback
+ setTimeout(_this.slideCloseEnd, ANIME_SPEED.slider + EVENT_MOE_TIME);
+ };
+ SpSlidemenu.prototype.slideCloseEnd = function() {
+ var _this = this;
+ _this.main.SpSlidemenuStatus = SLIDE_STATUS.close;
+ // change style
+ document.documentElement.style['overflowX'] = _this.htmlOverflowX;
+ document.body.style['overflowX'] = _this.bodyOverflowX;
+ if (_this.useCssAnimation) {
+ } else {
+ setStyles(_this.slidemenu, {
+ visibility: 'hidden'
+ });
+ }
+ // set event
+ removeTouchEvent('move', document, blockEvent, false);
+ };
+ SpSlidemenu.prototype.scrollTouchStart = function(event) {
+ var _this = this;
+ if (gestureStart) {
+ return;
+ }
+ if (_this.scrollOverTimer !== false) {
+ clearTimeout(_this.scrollOverTimer);
+ }
+ _this.scrollCurrentY = _this.getScrollCurrentY();
+ if (_this.useCssAnimation) {
+ setStyles(_this.slidemenuContent, {
+ transitionTimingFunction: 'ease-in-out',
+ transitionDuration: '0ms',
+ transform: _this.getTranslateY(_this.scrollCurrentY)
+ });
+ } else {
+ _this.stopScrollAnimate();
+ setStyles(_this.slidemenuContent, {
+ top: _this.scrollCurrentY + 'px'
+ });
+ }
+ _this.scrollOverTimer = false;
+ _this.scrollAnimationTimer = false;
+ _this.scrollTouchStarted = true;
+ _this.scrollMoveReady = false;
+ _this.scrollMoveEventCnt = 0;
+ _this.scrollMaxY = _this.calcMaxY();
+ _this.scrollStartPageX = getPage(event, 'pageX');
+ _this.scrollStartPageY = getPage(event, 'pageY');
+ _this.scrollBasePageY = _this.scrollStartPageY;
+ _this.scrollTimeForVelocity = event.timeStamp;
+ _this.scrollPageYForVelocity = _this.scrollStartPageY;
+ _this.slidemenuContent.removeEventListener('click', blockEvent, true);
+ };
+ SpSlidemenu.prototype.scrollTouchMove = function(event) {
+ var _this, pageX, pageY, distY, newY, deltaX, deltaY;
+ _this = this;
+ if (!_this.scrollTouchStarted || gestureStart) {
+ return;
+ }
+ pageX = getPage(event, 'pageX');
+ pageY = getPage(event, 'pageY');
+ if (_this.scrollMoveReady) {
+ event.preventDefault();
+ event.stopPropagation();
+ distY = pageY - _this.scrollBasePageY;
+ newY = _this.scrollCurrentY + distY;
+ if (newY > 0 || newY < _this.scrollMaxY) {
+ newY = Math.round(_this.scrollCurrentY + distY / 3);
+ }
+ _this.scrollSetY(newY);
+ if (_this.scrollMoveEventCnt % THRESHOLD === 0) {
+ _this.scrollPageYForVelocity = pageY;
+ _this.scrollTimeForVelocity = event.timeStamp;
+ }
+ _this.scrollMoveEventCnt++;
+ } else {
+ deltaX = Math.abs(pageX - _this.scrollStartPageX);
+ deltaY = Math.abs(pageY - _this.scrollStartPageY);
+ if (deltaX > 5 || deltaY > 5) {
+ _this.scrollMoveReady = true;
+ _this.slidemenuContent.addEventListener('click', blockEvent, true);
+ }
+ }
+ _this.scrollBasePageY = pageY;
+ };
+ SpSlidemenu.prototype.scrollTouchEnd = function(event) {
+ var _this, speed, deltaY, deltaTime;
+ _this = this;
+ if (!_this.scrollTouchStarted) {
+ return;
+ }
+ _this.scrollTouchStarted = false;
+ _this.scrollMaxY = _this.calcMaxY();
+ if (_this.scrollCurrentY > 0 || _this.scrollCurrentY < _this.scrollMaxY) {
+ _this.scrollOverBack();
+ return;
+ }
+ deltaY = getPage(event, 'pageY') - _this.scrollPageYForVelocity;
+ deltaTime = event.timeStamp - _this.scrollTimeForVelocity;
+ speed = deltaY / deltaTime;
+ if (Math.abs(speed) >= 0.01) {
+ _this.scrollInertia(speed);
+ }
+ };
+ SpSlidemenu.prototype.scrollInertia = function(speed) {
+ var _this, directionToTop, maxTo, distanceMaxTo, stopTime, canMove, to, duration, speedAtboundary, nextTo;
+ _this = this;
+ if (speed > 0) {
+ directionToTop = true;
+ maxTo = 0;
+ } else {
+ directionToTop = false;
+ maxTo = _this.scrollMaxY;
+ }
+ distanceMaxTo = Math.abs(_this.scrollCurrentY - maxTo);
+ speed = Math.abs(750 * speed);
+ if (speed > 1000) {
+ speed = 1000;
+ }
+ stopTime = speed / 500;
+ canMove = (speed * stopTime) - ((500 * Math.pow(stopTime, 2)) / 2);
+ if (canMove <= distanceMaxTo) {
+ if (directionToTop) {
+ to = _this.scrollCurrentY + canMove;
+ } else {
+ to = _this.scrollCurrentY - canMove;
+ }
+ duration = stopTime * 1000;
+ _this.scrollInertiaMove(to, duration, false);
+ } else {
+ to = maxTo;
+ speedAtboundary = Math.sqrt((2 * 500 * distanceMaxTo) + Math.pow(speed, 2));
+ duration = (speedAtboundary - speed) / 500 * 1000;
+ _this.scrollInertiaMove(to, duration, true, speedAtboundary, directionToTop);
+ }
+ };
+ SpSlidemenu.prototype.scrollInertiaMove = function(to, duration, isOver, speed, directionToTop) {
+ var _this = this, stopTime, canMove;
+ _this.scrollCurrentY = to;
+ if (_this.useCssAnimation) {
+ setStyles(_this.slidemenuContent, {
+ transitionTimingFunction: 'cubic-bezier(0.33, 0.66, 0.66, 1)',
+ transitionDuration: duration + 'ms',
+ transform: _this.getTranslateY(to)
+ });
+ } else {
+ _this.scrollAnimate(to, duration);
+ }
+ if (!isOver) {
+ return;
+ }
+ stopTime = speed / 7500;
+ canMove = (speed * stopTime) - ((7500 * Math.pow(stopTime, 2)) / 2);
+ if (directionToTop) {
+ to = _this.scrollCurrentY + canMove;
+ } else {
+ to = _this.scrollCurrentY - canMove;
+ }
+ duration = stopTime * 1000;
+ _this.scrollOver(to, duration);
+ };
+ SpSlidemenu.prototype.scrollOver = function(to, duration) {
+ var _this;
+ _this = this;
+ _this.scrollCurrentY = to;
+ if (_this.useCssAnimation) {
+ setStyles(_this.slidemenuContent, {
+ transitionTimingFunction: 'cubic-bezier(0.33, 0.66, 0.66, 1)',
+ transitionDuration: duration + 'ms',
+ transform: _this.getTranslateY(to)
+ });
+ } else {
+ _this.scrollAnimate(to, duration);
+ }
+ _this.scrollOverTimer = setTimeout(_this.scrollOverBack, duration);
+ };
+ SpSlidemenu.prototype.scrollOverBack = function() {
+ var _this, to;
+ _this = this;
+ if (_this.scrollCurrentY >= 0) {
+ to = 0;
+ } else {
+ to = _this.scrollMaxY;
+ }
+ _this.scrollCurrentY = to;
+ if (_this.useCssAnimation) {
+ setStyles(_this.slidemenuContent, {
+ transitionTimingFunction: 'ease-out',
+ transitionDuration: ANIME_SPEED.scrollOverBack + 'ms',
+ transform: _this.getTranslateY(to)
+ });
+ } else {
+ _this.scrollAnimate(to, ANIME_SPEED.scrollOverBack);
+ }
+ };
+ SpSlidemenu.prototype.scrollSetY = function(y) {
+ var _this = this;
+ _this.scrollCurrentY = y;
+ if (_this.useCssAnimation) {
+ setStyles(_this.slidemenuContent, {
+ transitionTimingFunction: 'ease-in-out',
+ transitionDuration: '0ms',
+ transform: _this.getTranslateY(y)
+ });
+ } else {
+ _this.slidemenuContent.style.top = y + 'px';
+ }
+ };
+ SpSlidemenu.prototype.scrollAnimate = function(to, transitionDuration) {
+ var _this = this;
+ _this.stopScrollAnimate();
+ _this.scrollAnimationTimer = animate(_this.slidemenuContent, 'top', to, transitionDuration);
+ };
+ SpSlidemenu.prototype.stopScrollAnimate = function() {
+ var _this = this;
+ if (_this.scrollAnimationTimer !== false) {
+ clearInterval(_this.scrollAnimationTimer);
+ }
+ };
+ SpSlidemenu.prototype.itemClick = function(event) {
+ var elem = event.target || event.srcElement;
+ if (hasClass(elem, ITEM_CLICK_CLASS_NAME)) {
+ this.slideClose();
+ }
+ };
+ SpSlidemenu.prototype.calcMaxY = function(x) {
+ var _this, contentHeight, bodyHeight, headerHeight;
+ _this = this;
+ contentHeight = _this.slidemenuContent.offsetHeight;
+ bodyHeight = _this.slidemenuBody.offsetHeight;
+ headerHeight = 0;
+ if (_this.slidemenuHeader) {
+ headerHeight = _this.slidemenuHeader.offsetHeight;
+ }
+ if (contentHeight > bodyHeight) {
+ return -(contentHeight - bodyHeight + headerHeight);
+ } else {
+ return 0;
+ }
+ };
+ SpSlidemenu.prototype.getScrollCurrentY = function() {
+ var ret = 0;
+ if (this.useCssAnimation) {
+ getStyle(window.getComputedStyle(this.slidemenuContent, ''), 'transform').split(',').forEach(function(value) {
+ var number = parseInt(value, 10);
+ if (!isNaN(number) && number !== 0 && number !== 1) {
+ ret = number;
+ }
+ });
+ } else {
+ var number = parseInt(getStyle(window.getComputedStyle(this.slidemenuContent, ''), 'top'), 10);
+ if (!isNaN(number) && number !== 0 && number !== 1) {
+ ret = number;
+ }
+ }
+ return ret;
+ };
+ SpSlidemenu.prototype.getTranslateX = function(x) {
+ var _this = this;
+ return _this.use3d ? 'translate3d(' + x + 'px, 0px, 0px)' : 'translate(' + x + 'px, 0px)';
+ };
+ SpSlidemenu.prototype.getTranslateY = function(y) {
+ var _this = this;
+ return _this.use3d ? 'translate3d(0px, ' + y + 'px, 0px)' : 'translate(0px, ' + y + 'px)';
+ };
+ //Utility Function
+ function hasProp(props) {
+ return some(props, function(prop) {
+ return div.style[prop] !== undefined;
+ });
+ }
+ function upperCaseFirst(str) {
+ return str.charAt(0).toUpperCase() + str.substr(1);
+ }
+ function some(ary, callback) {
+ var i, len;
+ for (i = 0, len = ary.length; i < len; i++) {
+ if (callback(ary[i], i)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ function setStyle(elem, prop, val) {
+ var style = elem.style;
+ if (!setStyle.cache) {
+ setStyle.cache = {};
+ }
+ if (setStyle.cache[prop] !== undefined) {
+ style[setStyle.cache[prop]] = val;
+ return;
+ }
+ if (style[prop] !== undefined) {
+ setStyle.cache[prop] = prop;
+ style[prop] = val;
+ return;
+ }
+ some(PREFIX, function(_prefix) {
+ var _prop = upperCaseFirst(_prefix) + upperCaseFirst(prop);
+ if (style[_prop] !== undefined) {
+ //setStyle.cache[prop] = _prop;
+ style[_prop] = val;
+ return true;
+ }
+ });
+ }
+ function setStyles(elem, styles) {
+ var style, prop;
+ for (prop in styles) {
+ if (styles.hasOwnProperty(prop)) {
+ setStyle(elem, prop, styles[prop]);
+ }
+ }
+ }
+ function getStyle(style, prop) {
+ var ret;
+ if (style[prop] !== undefined) {
+ return style[prop];
+ }
+ some(PREFIX, function(_prefix) {
+ var _prop = upperCaseFirst(_prefix) + upperCaseFirst(prop);
+ if (style[_prop] !== undefined) {
+ ret = style[_prop];
+ return true;
+ }
+ });
+ return ret;
+ }
+ function getCSSName(prop) {
+ var ret;
+ if (!getCSSName.cache) {
+ getCSSName.cache = {};
+ }
+ if (getCSSName.cache[prop] !== undefined) {
+ return getCSSName.cache[prop];
+ }
+ if (div.style[prop] !== undefined) {
+ getCSSName.cache[prop] = prop;
+ return prop;
+ }
+ some(PREFIX, function(_prefix) {
+ var _prop = upperCaseFirst(_prefix) + upperCaseFirst(prop);
+ if (div.style[_prop] !== undefined) {
+ ret = '-' + _prefix + '-' + prop;
+ return true;
+ }
+ });
+ getCSSName.cache[prop] = ret;
+ return ret;
+ }
+ function bind(func, context) {
+ var nativeBind, slice, args;
+ nativeBind = Function.prototype.bind;
+ slice = Array.prototype.slice;
+ if (func.bind === nativeBind && nativeBind) {
+ return nativeBind.apply(func, slice.call(arguments, 1));
+ }
+ args = slice.call(arguments, 2);
+ return function() {
+ return func.apply(context, args.concat(slice.call(arguments)));
+ };
+ }
+ function blockEvent(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ function getDimentions(element) {
+ var previous, key, properties, result;
+ previous = {};
+ properties = {
+ position: 'absolute',
+ visibility: 'hidden',
+ display: 'block'
+ };
+ for (key in properties) {
+ previous[key] = element.style[key];
+ element.style[key] = properties[key];
+ }
+ result = {
+ width: element.offsetWidth,
+ height: element.offsetHeight
+ };
+ for (key in properties) {
+ element.style[key] = previous[key];
+ }
+ return result;
+ }
+ function getPage(event, page) {
+ return event.changedTouches ? event.changedTouches[0][page] : event[page];
+ }
+ function addTouchEvent(eventType, element, listener, useCapture) {
+ useCapture = useCapture || false;
+ if (support.touch) {
+ element.addEventListener(EVENTS[eventType].touch, listener, { passive: useCapture });
+ } else {
+ element.addEventListener(EVENTS[eventType].mouse, listener, { passive: useCapture });
+ }
+ }
+ function removeTouchEvent(eventType, element, listener, useCapture) {
+ useCapture = useCapture || false;
+ if (support.touch) {
+ element.removeEventListener(EVENTS[eventType].touch, listener, useCapture);
+ } else {
+ element.removeEventListener(EVENTS[eventType].mouse, listener, useCapture);
+ }
+ }
+ function hasClass(elem, className) {
+ className = " " + className + " ";
+ if (elem.nodeType === 1 && (" " + elem.className + " ").replace(rclass, " ").indexOf(className) >= 0) {
+ return true;
+ }
+ return false;
+ }
+ function animate(elem, prop, to, transitionDuration) {
+ var begin, from, duration, easing, timer;
+ begin = +new Date();
+ from = parseInt(elem.style[prop], 10);
+ to = parseInt(to, 10);
+ duration = parseInt(transitionDuration, 10);
+ easing = function(time, duration) {
+ return -(time /= duration) * (time - 2);
+ };
+ timer = setInterval(function() {
+ var time, pos, now;
+ time = new Date() - begin;
+ if (time > duration) {
+ clearInterval(timer);
+ now = to;
+ } else {
+ pos = easing(time, duration);
+ now = pos * (to - from) + from;
+ }
+ elem.style[prop] = now + 'px';
+ }, 10);
+ return timer;
+ }
+ function getBrowserHeight() {
+ if (window.innerHeight) {
+ return window.innerHeight;
+ }
+ else if (document.documentElement && document.documentElement.clientHeight !== 0) {
+ return document.documentElement.clientHeight;
+ }
+ else if (document.body) {
+ return document.body.clientHeight;
+ }
+ return 0;
+ }
+ function debounce(func, wait, immediate) {
+ var timeout, result;
+ return function() {
+ var context = this, args = arguments;
+ var later = function() {
+ timeout = null;
+ if (!immediate) result = func.apply(context, args);
+ };
+ var callNow = immediate && !timeout;
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ if (callNow) result = func.apply(context, args);
+ return result;
+ };
+ }
+ window.SpSlidemenu = SpSlidemenu;
+})(window, window.document);
diff --git a/src/web/app/mobile/scripts/stream.ls b/src/web/app/mobile/scripts/stream.ls
new file mode 100644
index 0000000000..b7810b49ae
--- /dev/null
+++ b/src/web/app/mobile/scripts/stream.ls
@@ -0,0 +1,13 @@
+# Stream
+#================================
+
+stream = require '../../common/scripts/stream.ls'
+riot = require \riot
+
+module.exports = (me) ~>
+ s = stream me
+
+ riot.mixin \stream do
+ stream: s.event
+ get-stream-state: s.get-state
+ stream-state-ev: s.state-ev
diff --git a/src/web/app/mobile/scripts/ui.ls b/src/web/app/mobile/scripts/ui.ls
new file mode 100644
index 0000000000..aa94a8b052
--- /dev/null
+++ b/src/web/app/mobile/scripts/ui.ls
@@ -0,0 +1,6 @@
+riot = require \riot
+
+ui = riot.observable!
+
+riot.mixin \ui do
+ ui: ui
diff --git a/src/web/app/mobile/style.styl b/src/web/app/mobile/style.styl
new file mode 100644
index 0000000000..bc7859844a
--- /dev/null
+++ b/src/web/app/mobile/style.styl
@@ -0,0 +1,12 @@
+@import "../base"
+
+body[data-nav-open='true']
+ #hamburger
+ > i
+ -webkit-transform rotate(-90deg)
+ transform rotate(-90deg)
+
+#wait
+ top auto
+ bottom 15px
+ left 15px
diff --git a/src/web/app/mobile/tags.ls b/src/web/app/mobile/tags.ls
new file mode 100644
index 0000000000..5805cb6b28
--- /dev/null
+++ b/src/web/app/mobile/tags.ls
@@ -0,0 +1,44 @@
+require './tags/ui.tag'
+require './tags/ui-header.tag'
+require './tags/ui-nav.tag'
+require './tags/stream-indicator.tag'
+require './tags/page/entrance.tag'
+require './tags/page/entrance/signin.tag'
+require './tags/page/entrance/signup.tag'
+require './tags/page/home.tag'
+require './tags/page/drive.tag'
+require './tags/page/notifications.tag'
+require './tags/page/user.tag'
+require './tags/page/user-followers.tag'
+require './tags/page/user-following.tag'
+require './tags/page/post.tag'
+require './tags/page/new-post.tag'
+require './tags/page/search.tag'
+require './tags/home.tag'
+require './tags/home-timeline.tag'
+require './tags/timeline.tag'
+require './tags/timeline-post.tag'
+require './tags/timeline-post-sub.tag'
+require './tags/post-preview.tag'
+require './tags/sub-post-content.tag'
+require './tags/images-viewer.tag'
+require './tags/drive.tag'
+require './tags/drive-selector.tag'
+require './tags/drive/file.tag'
+require './tags/drive/folder.tag'
+require './tags/drive/file-viewer.tag'
+require './tags/post-form.tag'
+require './tags/notification.tag'
+require './tags/notifications.tag'
+require './tags/notify.tag'
+require './tags/notification-preview.tag'
+require './tags/search.tag'
+require './tags/search-posts.tag'
+require './tags/post-detail.tag'
+require './tags/user.tag'
+require './tags/user-timeline.tag'
+require './tags/follow-button.tag'
+require './tags/user-preview.tag'
+require './tags/users-list.tag'
+require './tags/user-following.tag'
+require './tags/user-followers.tag'
diff --git a/src/web/app/mobile/tags/drive-selector.tag b/src/web/app/mobile/tags/drive-selector.tag
new file mode 100644
index 0000000000..442299026e
--- /dev/null
+++ b/src/web/app/mobile/tags/drive-selector.tag
@@ -0,0 +1,75 @@
+mk-drive-selector
+ div.body
+ header
+ h1
+ | ファイルを選択
+ span.count(if={ files.length > 0 }) ({ files.length })
+ button.close(onclick={ cancel }): i.fa.fa-times
+ button.ok(onclick={ ok }): i.fa.fa-check
+ mk-drive@browser(select={ true }, multiple={ opts.multiple })
+
+style.
+ display block
+
+ > .body
+ position fixed
+ z-index 2048
+ top 0
+ left 0
+ right 0
+ margin 0 auto
+ width 100%
+ max-width 500px
+ height 100%
+ overflow hidden
+ background #fff
+ box-shadow 0 0 16px rgba(#000, 0.3)
+
+ > header
+ border-bottom solid 1px #eee
+
+ > h1
+ margin 0
+ padding 0
+ text-align center
+ line-height 42px
+ font-size 1em
+ font-weight normal
+
+ > .count
+ margin-left 4px
+ opacity 0.5
+
+ > .close
+ 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
+ height calc(100% - 42px)
+ overflow scroll
+
+script.
+ @files = []
+
+ @on \mount ~>
+ @refs.browser.on \change-selected (files) ~>
+ @files = files
+ @update!
+
+ @cancel = ~>
+ @trigger \canceled
+ @unmount!
+
+ @ok = ~>
+ @trigger \selected @files
+ @unmount!
diff --git a/src/web/app/mobile/tags/drive.tag b/src/web/app/mobile/tags/drive.tag
new file mode 100644
index 0000000000..fcc78d1e68
--- /dev/null
+++ b/src/web/app/mobile/tags/drive.tag
@@ -0,0 +1,338 @@
+mk-drive
+ nav
+ p(onclick={ go-root })
+ i.fa.fa-cloud
+ | ドライブ
+ virtual(each={ folder in hierarchy-folders })
+ span: i.fa.fa-angle-right
+ p(onclick={ _move }) { folder.name }
+ span(if={ folder != null }): i.fa.fa-angle-right
+ p(if={ folder != null }) { folder.name }
+ div.browser(if={ file == null }, class={ loading: loading })
+ div.folders(if={ folders.length > 0 })
+ virtual(each={ folder in folders })
+ mk-drive-folder(folder={ folder })
+ p(if={ more-folders })
+ | もっと読み込む
+ div.files(if={ files.length > 0 })
+ virtual(each={ file in files })
+ mk-drive-file(file={ file })
+ p(if={ more-files })
+ | もっと読み込む
+ div.empty(if={ files.length == 0 && folders.length == 0 && !loading })
+ p(if={ !folder == null })
+ | ドライブには何もありません。
+ p(if={ folder != null })
+ | このフォルダーは空です
+ div.loading(if={ loading }).
+ <div class="spinner">
+ <div class="dot1"></div>
+ <div class="dot2"></div>
+ </div>
+ mk-drive-file-viewer(if={ file != null }, file={ file })
+
+style.
+ display block
+ background #fff
+
+ > nav
+ display block
+ width 100%
+ padding 10px 12px
+ overflow auto
+ white-space nowrap
+ font-size 0.9em
+ color #555
+ background #fff
+ border-bottom solid 1px #dfdfdf
+
+ > p
+ display inline
+ margin 0
+ padding 0
+
+ &:last-child
+ font-weight bold
+
+ > i
+ margin-right 4px
+
+ > span
+ margin 0 8px
+ opacity 0.5
+
+ > .browser
+ &.loading
+ opacity 0.5
+
+ > .folders
+ > mk-drive-folder
+ border-bottom solid 1px #eee
+
+ > .files
+ > mk-drive-file
+ border-bottom solid 1px #eee
+
+ > .empty
+ padding 16px
+ text-align center
+ color #999
+ pointer-events none
+
+ > p
+ margin 0
+
+ > .loading
+ .spinner
+ margin 100px auto
+ width 40px
+ height 40px
+ text-align center
+
+ animation sk-rotate 2.0s infinite linear
+
+ .dot1, .dot2
+ width 60%
+ height 60%
+ display inline-block
+ position absolute
+ top 0
+ background-color rgba(0, 0, 0, 0.3)
+ border-radius 100%
+
+ animation sk-bounce 2.0s infinite ease-in-out
+
+ .dot2
+ top auto
+ bottom 0
+ animation-delay -1.0s
+
+ @keyframes sk-rotate { 100% { transform: rotate(360deg); }}
+
+ @keyframes sk-bounce {
+ 0%, 100% {
+ transform: scale(0.0);
+ } 50% {
+ transform: scale(1.0);
+ }
+ }
+
+script.
+ @mixin \api
+ @mixin \stream
+
+ @files = []
+ @folders = []
+ @hierarchy-folders = []
+ @selected-files = []
+
+ # 現在の階層(フォルダ)
+ # * null でルートを表す
+ @folder = null
+
+ @file = null
+
+ @is-select-mode = @opts.select? and @opts.select
+ @multiple = if @opts.multiple? then @opts.multiple else false
+
+ @on \mount ~>
+ @stream.on \drive_file_created @on-stream-drive-file-created
+ @stream.on \drive_file_updated @on-stream-drive-file-updated
+ @stream.on \drive_folder_created @on-stream-drive-folder-created
+ @stream.on \drive_folder_updated @on-stream-drive-folder-updated
+
+ # Riotのバグでnullを渡しても""になる
+ # https://github.com/riot/riot/issues/2080
+ #if @opts.folder?
+ if @opts.folder? and @opts.folder != ''
+ @cd @opts.folder
+ else
+ @load!
+
+ @on \unmount ~>
+ @stream.off \drive_file_created @on-stream-drive-file-created
+ @stream.off \drive_file_updated @on-stream-drive-file-updated
+ @stream.off \drive_folder_created @on-stream-drive-folder-created
+ @stream.off \drive_folder_updated @on-stream-drive-folder-updated
+
+ @on-stream-drive-file-created = (file) ~>
+ @add-file file, true
+
+ @on-stream-drive-file-updated = (file) ~>
+ current = if @folder? then @folder.id else null
+ if current != file.folder_id
+ @remove-file file
+ else
+ @add-file file, true
+
+ @on-stream-drive-folder-created = (folder) ~>
+ @add-folder folder, true
+
+ @on-stream-drive-folder-updated = (folder) ~>
+ current = if @folder? then @folder.id else null
+ if current != folder.parent_id
+ @remove-folder folder
+ else
+ @add-folder folder, true
+
+ @_move = (ev) ~>
+ @move ev.item.folder
+
+ @move = (target-folder) ~>
+ @cd target-folder, true
+
+ @cd = (target-folder, is-move) ~>
+ if target-folder? and typeof target-folder == \object
+ target-folder = target-folder.id
+
+ if target-folder == null
+ @go-root!
+ return
+
+ @loading = true
+ @update!
+
+ @api \drive/folders/show do
+ folder_id: target-folder
+ .then (folder) ~>
+ @folder = folder
+ @hierarchy-folders = []
+
+ x = (f) ~>
+ @hierarchy-folders.unshift f
+ if f.parent?
+ x f.parent
+
+ if folder.parent?
+ x folder.parent
+
+ @update!
+ if is-move then @trigger \move @folder
+ @trigger \cd @folder
+ @load!
+ .catch (err, text-status) ->
+ console.error err
+
+ @add-folder = (folder, unshift = false) ~>
+ current = if @folder? then @folder.id else null
+ if current != folder.parent_id
+ return
+
+ if (@folders.some (f) ~> f.id == folder.id)
+ return
+
+ if unshift
+ @folders.unshift folder
+ else
+ @folders.push folder
+
+ @update!
+
+ @add-file = (file, unshift = false) ~>
+ current = if @folder? then @folder.id else null
+ if current != file.folder_id
+ return
+
+ if (@files.some (f) ~> f.id == file.id)
+ exist = (@files.map (f) -> f.id).index-of file.id
+ @files[exist] = file
+ @update!
+ return
+
+ if unshift
+ @files.unshift file
+ else
+ @files.push file
+
+ @update!
+
+ @remove-folder = (folder) ~>
+ if typeof folder == \object
+ folder = folder.id
+ @folders = @folders.filter (f) -> f.id != folder
+ @update!
+
+ @remove-file = (file) ~>
+ if typeof file == \object
+ file = file.id
+ @files = @files.filter (f) -> f.id != file
+ @update!
+
+ @go-root = ~>
+ if @folder != null
+ @folder = null
+ @hierarchy-folders = []
+ @update!
+ @trigger \move-root
+ @load!
+
+ @load = ~>
+ @folders = []
+ @files = []
+ @more-folders = false
+ @more-files = false
+ @loading = true
+ @update!
+
+ @trigger \begin-load
+
+ load-folders = null
+ load-files = null
+
+ folders-max = 20
+ files-max = 20
+
+ # フォルダ一覧取得
+ @api \drive/folders do
+ folder_id: if @folder? then @folder.id else null
+ limit: folders-max + 1
+ .then (folders) ~>
+ if folders.length == folders-max + 1
+ @more-folders = true
+ folders.pop!
+ load-folders := folders
+ complete!
+ .catch (err, text-status) ~>
+ console.error err
+
+ # ファイル一覧取得
+ @api \drive/files do
+ folder_id: if @folder? then @folder.id else null
+ limit: files-max + 1
+ .then (files) ~>
+ if files.length == files-max + 1
+ @more-files = true
+ files.pop!
+ load-files := files
+ complete!
+ .catch (err, text-status) ~>
+ console.error err
+
+ flag = false
+ complete = ~>
+ if flag
+ load-folders.for-each (folder) ~>
+ @add-folder folder
+ load-files.for-each (file) ~>
+ @add-file file
+ @loading = false
+ @update!
+
+ @trigger \loaded
+ else
+ flag := true
+ @trigger \load-mid
+
+ @choose-file = (file) ~>
+ if @is-select-mode
+ exist = @selected-files.some (f) ~> f.id == file.id
+ if exist
+ @selected-files = (@selected-files.filter (f) ~> f.id != file.id)
+ else
+ @selected-files.push file
+ @update!
+ @trigger \change-selected @selected-files
+ else
+ @file = file
+ @update!
+ @trigger \open-file @file
diff --git a/src/web/app/mobile/tags/drive/file-viewer.tag b/src/web/app/mobile/tags/drive/file-viewer.tag
new file mode 100644
index 0000000000..8ce89a06f4
--- /dev/null
+++ b/src/web/app/mobile/tags/drive/file-viewer.tag
@@ -0,0 +1,8 @@
+mk-drive-file-viewer
+ p.name { file.name }
+
+style.
+ display block
+
+script.
+ @file = @opts.file
diff --git a/src/web/app/mobile/tags/drive/file.tag b/src/web/app/mobile/tags/drive/file.tag
new file mode 100644
index 0000000000..ec271441a5
--- /dev/null
+++ b/src/web/app/mobile/tags/drive/file.tag
@@ -0,0 +1,130 @@
+mk-drive-file(onclick={ onclick }, data-is-selected={ is-selected })
+ div.container
+ div.thumbnail(style={ 'background-image: url(' + file.url + '?thumbnail&size=128)' })
+ div.body
+ p.name { file.name }
+ //
+ if file.tags.length > 0
+ ul.tags
+ each tag in file.tags
+ li.tag(style={background: tag.color, color: contrast(tag.color)})= tag.name
+ footer
+ p.type
+ mk-file-type-icon(file={ file })
+ | { file.type }
+ p.separator
+ p.data-size { bytes-to-size(file.datasize) }
+ p.separator
+ p.created-at
+ i.fa.fa-clock-o
+ mk-time(time={ file.created_at })
+
+style.
+ display block
+
+ &, *
+ user-select none
+
+ *
+ pointer-events none
+
+ > .container
+ max-width 500px
+ margin 0 auto
+ padding 16px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .thumbnail
+ display block
+ float left
+ width 64px
+ height 64px
+ background-size cover
+ background-position center center
+
+ > .body
+ display block
+ float left
+ width calc(100% - 74px)
+ margin-left 10px
+
+ > .name
+ display block
+ margin 0
+ padding 0
+ font-size 0.9em
+ font-weight bold
+ color #555
+ text-overflow ellipsis
+ word-wrap break-word
+
+ > .tags
+ display block
+ margin 4px 0 0 0
+ padding 0
+ list-style none
+ font-size 0.5em
+
+ > .tag
+ display inline-block
+ margin 0 5px 0 0
+ padding 1px 5px
+ border-radius 2px
+
+ > footer
+ display block
+ margin 4px 0 0 0
+ font-size 0.7em
+
+ > .separator
+ display inline
+ margin 0
+ padding 0 4px
+ color #CDCDCD
+
+ > .type
+ display inline
+ margin 0
+ padding 0
+ color #9D9D9D
+
+ > mk-file-type-icon
+ margin-right 4px
+
+ > .data-size
+ display inline
+ margin 0
+ padding 0
+ color #9D9D9D
+
+ > .created-at
+ display inline
+ margin 0
+ padding 0
+ color #BDBDBD
+
+ > i
+ margin-right 2px
+
+ &[data-is-selected]
+ background $theme-color
+
+ &, *
+ color #fff !important
+
+script.
+ @mixin \bytes-to-size
+
+ @browser = @parent
+ @file = @opts.file
+ @is-selected = @browser.selected-files.some (f) ~> f.id == @file.id
+
+ @browser.on \change-selected (selects) ~>
+ @is-selected = selects.some (f) ~> f.id == @file.id
+
+ @onclick = ~>
+ @browser.choose-file @file
diff --git a/src/web/app/mobile/tags/drive/folder.tag b/src/web/app/mobile/tags/drive/folder.tag
new file mode 100644
index 0000000000..ef3a72ea93
--- /dev/null
+++ b/src/web/app/mobile/tags/drive/folder.tag
@@ -0,0 +1,45 @@
+mk-drive-folder(onclick={ onclick })
+ div.container
+ p.name
+ i.fa.fa-folder
+ | { folder.name }
+ i.fa.fa-angle-right
+
+style.
+ display block
+ color #777
+
+ &, *
+ user-select none
+
+ *
+ pointer-events none
+
+ > .container
+ max-width 500px
+ margin 0 auto
+ padding 16px
+
+ > .name
+ display block
+ margin 0
+ padding 0
+
+ > i
+ margin-right 6px
+
+ > i
+ position absolute
+ top 0
+ bottom 0
+ right 8px
+ margin auto 0 auto 0
+ width 1em
+ height 1em
+
+script.
+ @browser = @parent
+ @folder = @opts.folder
+
+ @onclick = ~>
+ @browser.move @folder
diff --git a/src/web/app/mobile/tags/follow-button.tag b/src/web/app/mobile/tags/follow-button.tag
new file mode 100644
index 0000000000..7cedbbee88
--- /dev/null
+++ b/src/web/app/mobile/tags/follow-button.tag
@@ -0,0 +1,108 @@
+mk-follow-button
+ button(if={ !init }, class={ wait: wait, follow: !user.is_following, unfollow: user.is_following },
+ onclick={ onclick },
+ disabled={ wait })
+ i.fa.fa-minus(if={ !wait && user.is_following })
+ i.fa.fa-plus(if={ !wait && !user.is_following })
+ i.fa.fa-spinner.fa-pulse.fa-fw(if={ wait })
+ | { user.is_following ? 'フォロー解除' : 'フォロー' }
+ div.init(if={ init }): i.fa.fa-spinner.fa-pulse.fa-fw
+
+style.
+ display block
+
+ > button
+ > .init
+ display block
+ user-select none
+ cursor pointer
+ padding 0 16px
+ margin 0
+ height inherit
+ font-size 16px
+ outline none
+ border solid 1px $theme-color
+ border-radius 4px
+
+ *
+ pointer-events none
+
+ &.follow
+ color $theme-color
+ background transparent
+
+ &:hover
+ background rgba($theme-color, 0.1)
+
+ &:active
+ background rgba($theme-color, 0.2)
+
+ &.unfollow
+ color $theme-color-foreground
+ background $theme-color
+
+ &.wait
+ cursor wait !important
+ opacity 0.7
+
+ &.init
+ cursor wait !important
+ opacity 0.7
+
+ > i
+ margin-right 4px
+
+script.
+ @mixin \api
+ @mixin \is-promise
+ @mixin \stream
+
+ @user = null
+ @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user
+ @init = true
+ @wait = false
+
+ @on \mount ~>
+ @user-promise.then (user) ~>
+ @user = user
+ @init = false
+ @update!
+ @stream.on \follow @on-stream-follow
+ @stream.on \unfollow @on-stream-unfollow
+
+ @on \unmount ~>
+ @stream.off \follow @on-stream-follow
+ @stream.off \unfollow @on-stream-unfollow
+
+ @on-stream-follow = (user) ~>
+ if user.id == @user.id
+ @user = user
+ @update!
+
+ @on-stream-unfollow = (user) ~>
+ if user.id == @user.id
+ @user = user
+ @update!
+
+ @onclick = ~>
+ @wait = true
+ if @user.is_following
+ @api \following/delete do
+ user_id: @user.id
+ .then ~>
+ @user.is_following = false
+ .catch (err) ->
+ console.error err
+ .then ~>
+ @wait = false
+ @update!
+ else
+ @api \following/create do
+ user_id: @user.id
+ .then ~>
+ @user.is_following = true
+ .catch (err) ->
+ console.error err
+ .then ~>
+ @wait = false
+ @update!
diff --git a/src/web/app/mobile/tags/home-timeline.tag b/src/web/app/mobile/tags/home-timeline.tag
new file mode 100644
index 0000000000..1754bb2b07
--- /dev/null
+++ b/src/web/app/mobile/tags/home-timeline.tag
@@ -0,0 +1,40 @@
+mk-home-timeline
+ mk-timeline@timeline(init={ init }, more={ more }, empty={ '表示する投稿がありません。誰かしらをフォローするなどしましょう。' })
+
+style.
+ display block
+
+script.
+ @mixin \api
+ @mixin \stream
+
+ @init = new Promise (res, rej) ~>
+ @api \posts/timeline
+ .then (posts) ~>
+ res posts
+ @trigger \loaded
+
+ @on \mount ~>
+ @stream.on \post @on-stream-post
+ @stream.on \follow @on-stream-follow
+ @stream.on \unfollow @on-stream-unfollow
+
+ @on \unmount ~>
+ @stream.off \post @on-stream-post
+ @stream.off \follow @on-stream-follow
+ @stream.off \unfollow @on-stream-unfollow
+
+ @more = ~>
+ @api \posts/timeline do
+ max_id: @refs.timeline.tail!.id
+
+ @on-stream-post = (post) ~>
+ @is-empty = false
+ @update!
+ @refs.timeline.add-post post
+
+ @on-stream-follow = ~>
+ @fetch!
+
+ @on-stream-unfollow = ~>
+ @fetch!
diff --git a/src/web/app/mobile/tags/home.tag b/src/web/app/mobile/tags/home.tag
new file mode 100644
index 0000000000..ebcf8f0bb2
--- /dev/null
+++ b/src/web/app/mobile/tags/home.tag
@@ -0,0 +1,17 @@
+mk-home
+ mk-home-timeline@tl
+
+style.
+ display block
+
+ > mk-home-timeline
+ max-width 600px
+ margin 0 auto
+
+ @media (min-width 500px)
+ padding 16px
+
+script.
+ @on \mount ~>
+ @refs.tl.on \loaded ~>
+ @trigger \loaded
diff --git a/src/web/app/mobile/tags/images-viewer.tag b/src/web/app/mobile/tags/images-viewer.tag
new file mode 100644
index 0000000000..f9d774a124
--- /dev/null
+++ b/src/web/app/mobile/tags/images-viewer.tag
@@ -0,0 +1,25 @@
+mk-images-viewer
+ div.image@view(onclick={ click })
+ img@img(src={ image.url + '?thumbnail&size=512' }, alt={ image.name }, title={ image.name })
+
+style.
+ display block
+ padding 8px
+ overflow hidden
+ box-shadow 0 0 4px rgba(0, 0, 0, 0.2)
+ border-radius 4px
+
+ > .image
+
+ > img
+ display block
+ max-height 256px
+ max-width 100%
+ margin 0 auto
+
+script.
+ @images = @opts.images
+ @image = @images.0
+
+ @click = ~>
+ window.open @image.url
diff --git a/src/web/app/mobile/tags/notification-preview.tag b/src/web/app/mobile/tags/notification-preview.tag
new file mode 100644
index 0000000000..ee936df7ab
--- /dev/null
+++ b/src/web/app/mobile/tags/notification-preview.tag
@@ -0,0 +1,117 @@
+mk-notification-preview(class={ notification.type })
+ div.main(if={ notification.type == 'like' })
+ img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-thumbs-o-up
+ | { notification.user.name }
+ p.post-ref { get-post-summary(notification.post) }
+
+ div.main(if={ notification.type == 'repost' })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-retweet
+ | { notification.post.user.name }
+ p.post-ref { get-post-summary(notification.post.repost) }
+
+ div.main(if={ notification.type == 'quote' })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-quote-left
+ | { notification.post.user.name }
+ p.post-preview { get-post-summary(notification.post) }
+
+ div.main(if={ notification.type == 'follow' })
+ img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-user-plus
+ | { notification.user.name }
+
+ div.main(if={ notification.type == 'reply' })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-reply
+ | { notification.post.user.name }
+ p.post-preview { get-post-summary(notification.post) }
+
+ div.main(if={ notification.type == 'mention' })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-at
+ | { notification.post.user.name }
+ p.post-preview { get-post-summary(notification.post) }
+
+style.
+ display block
+ margin 0
+ padding 8px
+ color #fff
+
+ > .main
+ word-wrap break-word
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ img
+ display block
+ float left
+ min-width 36px
+ min-height 36px
+ max-width 36px
+ max-height 36px
+ border-radius 6px
+
+ .text
+ float right
+ width calc(100% - 36px)
+ padding-left 8px
+
+ p
+ margin 0
+
+ i
+ margin-right 4px
+
+ .post-ref
+
+ &:before, &:after
+ font-family FontAwesome
+ font-size 1em
+ font-weight normal
+ font-style normal
+ display inline-block
+ margin-right 3px
+
+ &:before
+ content "\f10d"
+
+ &:after
+ content "\f10e"
+
+ &.like
+ .text p i
+ color #FFAC33
+
+ &.repost, &.quote
+ .text p i
+ color #77B255
+
+ &.follow
+ .text p i
+ color #53c7ce
+
+ &.reply, &.mention
+ .text p i
+ color #fff
+
+script.
+ @mixin \get-post-summary
+ @notification = @opts.notification
diff --git a/src/web/app/mobile/tags/notification.tag b/src/web/app/mobile/tags/notification.tag
new file mode 100644
index 0000000000..afcc7441b4
--- /dev/null
+++ b/src/web/app/mobile/tags/notification.tag
@@ -0,0 +1,142 @@
+mk-notification(class={ notification.type })
+ mk-time(time={ notification.created_at })
+
+ div.main(if={ notification.type == 'like' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username })
+ img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-thumbs-o-up
+ a(href={ CONFIG.url + '/' + notification.user.username }) { notification.user.name }
+ a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) }
+
+ div.main(if={ notification.type == 'repost' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-retweet
+ a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name }
+ a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post.repost) }
+
+ div.main(if={ notification.type == 'quote' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-quote-left
+ a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name }
+ a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) }
+
+ div.main(if={ notification.type == 'follow' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username })
+ img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-user-plus
+ a(href={ CONFIG.url + '/' + notification.user.username }) { notification.user.name }
+
+ div.main(if={ notification.type == 'reply' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-reply
+ a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name }
+ a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) }
+
+ div.main(if={ notification.type == 'mention' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-at
+ a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name }
+ a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) }
+
+style.
+ display block
+ margin 0
+ padding 16px
+
+ > mk-time
+ display inline
+ position absolute
+ top 16px
+ right 12px
+ vertical-align top
+ color rgba(0, 0, 0, 0.6)
+ font-size 12px
+
+ > .main
+ word-wrap break-word
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ .avatar-anchor
+ display block
+ float left
+
+ img
+ min-width 36px
+ min-height 36px
+ max-width 36px
+ max-height 36px
+ border-radius 6px
+
+ .text
+ float right
+ width calc(100% - 36px)
+ padding-left 8px
+
+ p
+ margin 0
+
+ i
+ margin-right 4px
+
+ .post-preview
+ color rgba(0, 0, 0, 0.7)
+
+ .post-ref
+ color rgba(0, 0, 0, 0.7)
+
+ &:before, &:after
+ font-family FontAwesome
+ font-size 1em
+ font-weight normal
+ font-style normal
+ display inline-block
+ margin-right 3px
+
+ &:before
+ content "\f10d"
+
+ &:after
+ content "\f10e"
+
+ &.like
+ .text p i
+ color #FFAC33
+
+ &.repost, &.quote
+ .text p i
+ color #77B255
+
+ &.follow
+ .text p i
+ color #53c7ce
+
+ &.reply, &.mention
+ .text p i
+ color #555
+
+ .post-preview
+ color rgba(0, 0, 0, 0.7)
+
+script.
+ @mixin \get-post-summary
+ @notification = @opts.notification
diff --git a/src/web/app/mobile/tags/notifications.tag b/src/web/app/mobile/tags/notifications.tag
new file mode 100644
index 0000000000..7510d59967
--- /dev/null
+++ b/src/web/app/mobile/tags/notifications.tag
@@ -0,0 +1,98 @@
+mk-notifications
+ div.notifications(if={ notifications.length != 0 })
+ virtual(each={ notification, i in notifications })
+ mk-notification(notification={ notification })
+
+ p.date(if={ i != notifications.length - 1 && notification._date != notifications[i + 1]._date })
+ span
+ i.fa.fa-angle-up
+ | { notification._datetext }
+ span
+ i.fa.fa-angle-down
+ | { notifications[i + 1]._datetext }
+
+ p.empty(if={ notifications.length == 0 && !loading })
+ | ありません!
+ p.loading(if={ loading })
+ i.fa.fa-spinner.fa-pulse.fa-fw
+ | 読み込んでいます
+ mk-ellipsis
+
+style.
+ display block
+ background #fff
+
+ > .notifications
+ margin 0 auto
+ max-width 500px
+
+ > mk-notification
+ border-bottom solid 1px rgba(0, 0, 0, 0.05)
+
+ &:last-child
+ border-bottom none
+
+ > .date
+ display block
+ margin 0
+ line-height 32px
+ text-align center
+ font-size 0.8em
+ color #aaa
+ background #fdfdfd
+ border-bottom solid 1px rgba(0, 0, 0, 0.05)
+
+ span
+ margin 0 16px
+
+ i
+ margin-right 8px
+
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > .loading
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+script.
+ @mixin \api
+ @mixin \stream
+ @mixin \get-post-summary
+
+ @notifications = []
+ @loading = true
+
+ @on \mount ~>
+ @api \i/notifications
+ .then (notifications) ~>
+ @notifications = notifications
+ @loading = false
+ @update!
+ @trigger \loaded
+ .catch (err, text-status) ->
+ console.error err
+
+ @stream.on \notification @on-notification
+
+ @on \unmount ~>
+ @stream.off \notification @on-notification
+
+ @on-notification = (notification) ~>
+ @notifications.unshift notification
+ @update!
+
+ @on \update ~>
+ @notifications.for-each (notification) ~>
+ date = (new Date notification.created_at).get-date!
+ month = (new Date notification.created_at).get-month! + 1
+ notification._date = date
+ notification._datetext = month + '月 ' + date + '日'
diff --git a/src/web/app/mobile/tags/notify.tag b/src/web/app/mobile/tags/notify.tag
new file mode 100644
index 0000000000..9dd93ccf25
--- /dev/null
+++ b/src/web/app/mobile/tags/notify.tag
@@ -0,0 +1,35 @@
+mk-notify
+ mk-notification-preview(notification={ opts.notification })
+
+style.
+ display block
+ position fixed
+ z-index 1024
+ bottom -64px
+ left 0
+ width 100%
+ height 64px
+ pointer-events none
+ -webkit-backdrop-filter blur(2px)
+ backdrop-filter blur(2px)
+ background-color rgba(#000, 0.5)
+
+script.
+ @on \mount ~>
+ Velocity @root, {
+ bottom: \0px
+ } {
+ duration: 500ms
+ easing: \ease-out
+ }
+
+ set-timeout ~>
+ Velocity @root, {
+ bottom: \-64px
+ } {
+ duration: 500ms
+ easing: \ease-out
+ complete: ~>
+ @unmount!
+ }
+ , 6000ms
diff --git a/src/web/app/mobile/tags/page/drive.tag b/src/web/app/mobile/tags/page/drive.tag
new file mode 100644
index 0000000000..9bef7e6648
--- /dev/null
+++ b/src/web/app/mobile/tags/page/drive.tag
@@ -0,0 +1,46 @@
+mk-drive-page
+ mk-ui@ui: mk-drive@browser(folder={ parent.opts.folder }, file={ parent.opts.file })
+
+style.
+ display block
+
+script.
+ @mixin \ui
+ @mixin \ui-progress
+
+ @on \mount ~>
+ document.title = 'Misskey Drive'
+ @ui.trigger \title '<i class="fa fa-cloud"></i>ドライブ'
+
+ @refs.ui.refs.browser.on \begin-load ~>
+ @Progress.start!
+
+ @refs.ui.refs.browser.on \loaded-mid ~>
+ @Progress.set 0.5
+
+ @refs.ui.refs.browser.on \loaded ~>
+ @Progress.done!
+
+ @refs.ui.refs.browser.on \move-root ~>
+ @ui.trigger \title '<i class="fa fa-cloud"></i>ドライブ'
+
+ # Rewrite URL
+ history.push-state null null '/i/drive'
+
+ @refs.ui.refs.browser.on \cd (folder) ~>
+ # TODO: escape html characters in folder.name
+ @ui.trigger \title '<i class="fa fa-folder-open"></i>' + folder.name
+
+ @refs.ui.refs.browser.on \move (folder) ~>
+ # Rewrite URL
+ history.push-state null null '/i/drive/folder/' + folder.id
+
+ @refs.ui.refs.browser.on \open-file (file) ~>
+ # TODO: escape html characters in file.name
+ @ui.trigger \title '<mk-file-type-icon class="icon"></mk-file-type-icon>' + file.name
+
+ # Rewrite URL
+ history.push-state null null '/i/drive/file/' + file.id
+
+ riot.mount \mk-file-type-icon do
+ file: file
diff --git a/src/web/app/mobile/tags/page/entrance.tag b/src/web/app/mobile/tags/page/entrance.tag
new file mode 100644
index 0000000000..67d8bc9bbf
--- /dev/null
+++ b/src/web/app/mobile/tags/page/entrance.tag
@@ -0,0 +1,57 @@
+mk-entrance
+ main
+ img(src='/_/resources/title.svg', alt='Misskey')
+
+ mk-entrance-signin(if={ mode == 'signin' })
+ mk-entrance-signup(if={ mode == 'signup' })
+ div.introduction(if={ mode == 'introduction' })
+ mk-introduction
+ button(onclick={ signin }) わかった
+
+ footer
+ mk-copyright
+
+style.
+ display block
+ height 100%
+
+ > main
+ display block
+
+ > img
+ display block
+ width 130px
+ height 120px
+ margin 0 auto
+
+ > .introduction
+ max-width 300px
+ margin 0 auto
+ color #666
+
+ > button
+ display block
+ margin 16px auto 0 auto
+
+ > footer
+ > mk-copyright
+ margin 0
+ text-align center
+ line-height 64px
+ font-size 10px
+ color rgba(#000, 0.5)
+
+script.
+ @mode = \signin
+
+ @signup = ~>
+ @mode = \signup
+ @update!
+
+ @signin = ~>
+ @mode = \signin
+ @update!
+
+ @introduction = ~>
+ @mode = \introduction
+ @update!
diff --git a/src/web/app/mobile/tags/page/entrance/signin.tag b/src/web/app/mobile/tags/page/entrance/signin.tag
new file mode 100644
index 0000000000..484c414e8e
--- /dev/null
+++ b/src/web/app/mobile/tags/page/entrance/signin.tag
@@ -0,0 +1,45 @@
+mk-entrance-signin
+ mk-signin
+ div.divider: span or
+ button.signup(onclick={ parent.signup }) 新規登録
+ a.introduction(onclick={ parent.introduction }) Misskeyについて
+
+style.
+ display block
+ margin 0 auto
+ padding 0 8px
+ max-width 350px
+ text-align center
+
+ > .signup
+ padding 16px
+ width 100%
+ font-size 1em
+ color #fff
+ background $theme-color
+ border-radius 3px
+
+ > .divider
+ padding 16px 0
+ text-align center
+
+ &:after
+ content ""
+ display block
+ position absolute
+ top 50%
+ width 100%
+ height 1px
+ border-top solid 1px rgba(0, 0, 0, 0.1)
+
+ > *
+ z-index 1
+ padding 0 8px
+ color rgba(0, 0, 0, 0.5)
+ background #fdfdfd
+
+ > .introduction
+ display inline-block
+ margin-top 16px
+ font-size 12px
+ color #666
diff --git a/src/web/app/mobile/tags/page/entrance/signup.tag b/src/web/app/mobile/tags/page/entrance/signup.tag
new file mode 100644
index 0000000000..a28f85e634
--- /dev/null
+++ b/src/web/app/mobile/tags/page/entrance/signup.tag
@@ -0,0 +1,35 @@
+mk-entrance-signup
+ mk-signup
+ button.cancel(type='button', onclick={ parent.signin }, title='キャンセル'): i.fa.fa-times
+
+style.
+ display block
+ margin 0 auto
+ padding 0 8px
+ max-width 350px
+
+ > .cancel
+ cursor pointer
+ display block
+ position absolute
+ top 0
+ right 0
+ z-index 1
+ margin 0
+ padding 0
+ font-size 1.2em
+ color #999
+ border none
+ outline none
+ box-shadow none
+ background transparent
+ transition opacity 0.1s ease
+
+ &:hover
+ color #555
+
+ &:active
+ color #222
+
+ > i
+ padding 14px
diff --git a/src/web/app/mobile/tags/page/home.tag b/src/web/app/mobile/tags/page/home.tag
new file mode 100644
index 0000000000..c8d7729652
--- /dev/null
+++ b/src/web/app/mobile/tags/page/home.tag
@@ -0,0 +1,40 @@
+mk-home-page
+ mk-ui@ui: mk-home@home
+
+style.
+ display block
+
+script.
+ @mixin \i
+ @mixin \ui
+ @mixin \ui-progress
+ @mixin \stream
+ @mixin \get-post-summary
+
+ @unread-count = 0
+
+ @on \mount ~>
+ document.title = 'Misskey'
+ @ui.trigger \title '<i class="fa fa-home"></i>ホーム'
+
+ @Progress.start!
+
+ @stream.on \post @on-stream-post
+ document.add-event-listener \visibilitychange @window-on-visibilitychange, false
+
+ @refs.ui.refs.home.on \loaded ~>
+ @Progress.done!
+
+ @on \unmount ~>
+ @stream.off \post @on-stream-post
+ document.remove-event-listener \visibilitychange @window-on-visibilitychange
+
+ @on-stream-post = (post) ~>
+ if document.hidden and post.user_id !== @I.id
+ @unread-count++
+ document.title = '(' + @unread-count + ') ' + @get-post-summary post
+
+ @window-on-visibilitychange = ~>
+ if !document.hidden
+ @unread-count = 0
+ document.title = 'Misskey'
diff --git a/src/web/app/mobile/tags/page/new-post.tag b/src/web/app/mobile/tags/page/new-post.tag
new file mode 100644
index 0000000000..21e00fc1f9
--- /dev/null
+++ b/src/web/app/mobile/tags/page/new-post.tag
@@ -0,0 +1,5 @@
+mk-new-post-page
+ mk-post-form@form
+
+style.
+ display block
diff --git a/src/web/app/mobile/tags/page/notifications.tag b/src/web/app/mobile/tags/page/notifications.tag
new file mode 100644
index 0000000000..9fb34dcd75
--- /dev/null
+++ b/src/web/app/mobile/tags/page/notifications.tag
@@ -0,0 +1,18 @@
+mk-notifications-page
+ mk-ui@ui: mk-notifications@notifications
+
+style.
+ display block
+
+script.
+ @mixin \ui
+ @mixin \ui-progress
+
+ @on \mount ~>
+ document.title = 'Misskey | 通知'
+ @ui.trigger \title '<i class="fa fa-bell-o"></i>通知'
+
+ @Progress.start!
+
+ @refs.ui.refs.notifications.on \loaded ~>
+ @Progress.done!
diff --git a/src/web/app/mobile/tags/page/post.tag b/src/web/app/mobile/tags/page/post.tag
new file mode 100644
index 0000000000..1dc74d267a
--- /dev/null
+++ b/src/web/app/mobile/tags/page/post.tag
@@ -0,0 +1,31 @@
+mk-post-page
+ mk-ui@ui: main: mk-post-detail@post(post={ parent.post })
+
+style.
+ display block
+
+ main
+ background #fff
+
+ > mk-post-detail
+ width 100%
+ max-width 500px
+ margin 0 auto
+
+script.
+ @mixin \ui
+ @mixin \ui-progress
+
+ @post = @opts.post
+
+ @on \mount ~>
+ document.title = 'Misskey'
+ @ui.trigger \title '<i class="fa fa-sticky-note-o"></i>投稿'
+
+ @Progress.start!
+
+ @refs.ui.refs.post.on \post-fetched ~>
+ @Progress.set 0.5
+
+ @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
new file mode 100644
index 0000000000..20de271f73
--- /dev/null
+++ b/src/web/app/mobile/tags/page/search.tag
@@ -0,0 +1,19 @@
+mk-search-page
+ mk-ui@ui: mk-search@search(query={ parent.opts.query })
+
+style.
+ display block
+
+script.
+ @mixin \ui
+ @mixin \ui-progress
+
+ @on \mount ~>
+ document.title = '検索: ' + @opts.query + ' | Misskey'
+ # TODO: クエリをHTMLエスケープ
+ @ui.trigger \title '<i class="fa fa-search"></i>' + @opts.query
+
+ @Progress.start!
+
+ @refs.ui.refs.search.on \loaded ~>
+ @Progress.done!
diff --git a/src/web/app/mobile/tags/page/user-followers.tag b/src/web/app/mobile/tags/page/user-followers.tag
new file mode 100644
index 0000000000..e7e9a6fd1e
--- /dev/null
+++ b/src/web/app/mobile/tags/page/user-followers.tag
@@ -0,0 +1,31 @@
+mk-user-followers-page
+ mk-ui@ui: mk-user-followers@list(if={ !parent.fetching }, user={ parent.user })
+
+style.
+ display block
+
+script.
+ @mixin \ui
+ @mixin \ui-progress
+ @mixin \api
+
+ @fetching = true
+ @user = null
+
+ @on \mount ~>
+ @Progress.start!
+
+ @api \users/show do
+ username: @opts.user
+ .then (user) ~>
+ @user = user
+ @fetching = false
+
+ document.title = user.name + 'のフォロワー | Misskey'
+ # TODO: ユーザー名をエスケープ
+ @ui.trigger \title '<img src="' + user.avatar_url + '?thumbnail&size=64">' + user.name + 'のフォロー'
+
+ @update!
+
+ @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
new file mode 100644
index 0000000000..a74ba97b72
--- /dev/null
+++ b/src/web/app/mobile/tags/page/user-following.tag
@@ -0,0 +1,31 @@
+mk-user-following-page
+ mk-ui@ui: mk-user-following@list(if={ !parent.fetching }, user={ parent.user })
+
+style.
+ display block
+
+script.
+ @mixin \ui
+ @mixin \ui-progress
+ @mixin \api
+
+ @fetching = true
+ @user = null
+
+ @on \mount ~>
+ @Progress.start!
+
+ @api \users/show do
+ username: @opts.user
+ .then (user) ~>
+ @user = user
+ @fetching = false
+
+ document.title = user.name + 'のフォロー | Misskey'
+ # TODO: ユーザー名をエスケープ
+ @ui.trigger \title '<img src="' + user.avatar_url + '?thumbnail&size=64">' + user.name + 'のフォロー'
+
+ @update!
+
+ @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
new file mode 100644
index 0000000000..9667abfd14
--- /dev/null
+++ b/src/web/app/mobile/tags/page/user.tag
@@ -0,0 +1,20 @@
+mk-user-page
+ mk-ui@ui: mk-user@user(user={ parent.user }, page={ parent.opts.page })
+
+style.
+ display block
+
+script.
+ @mixin \ui
+ @mixin \ui-progress
+
+ @user = @opts.user
+
+ @on \mount ~>
+ @Progress.start!
+
+ @refs.ui.refs.user.on \loaded (user) ~>
+ @Progress.done!
+ document.title = user.name + ' | Misskey'
+ # TODO: ユーザー名をエスケープ
+ @ui.trigger \title '<i class="fa fa-user"></i>' + user.name
diff --git a/src/web/app/mobile/tags/post-detail.tag b/src/web/app/mobile/tags/post-detail.tag
new file mode 100644
index 0000000000..c7eb091ce6
--- /dev/null
+++ b/src/web/app/mobile/tags/post-detail.tag
@@ -0,0 +1,415 @@
+mk-post-detail
+
+ div.fetching(if={ fetching })
+ mk-ellipsis-icon
+
+ div.main(if={ !fetching })
+
+ button.read-more(if={ p.reply_to && p.reply_to.reply_to_id && context == null }, onclick={ load-context }, disabled={ loading-context })
+ i.fa.fa-ellipsis-v(if={ !loading-context })
+ i.fa.fa-spinner.fa-pulse(if={ loading-context })
+
+ div.context
+ virtual(each={ post in context })
+ mk-post-preview(post={ post })
+
+ div.reply-to(if={ p.reply_to })
+ mk-post-preview(post={ p.reply_to })
+
+ div.repost(if={ is-repost })
+ p
+ a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=32' }, alt='avatar')
+ i.fa.fa-retweet
+ a.name(href={ CONFIG.url + '/' + post.user.username }) { post.user.name }
+ | がRepost
+
+ article
+ a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username })
+ img.avatar(src={ p.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ header
+ a.name(href={ CONFIG.url + '/' + p.user.username })
+ | { p.user.name }
+ span.username
+ | @{ p.user.username }
+ div.body
+ div.text@text
+ div.media(if={ p.media })
+ virtual(each={ file in p.media })
+ img(src={ file.url + '?thumbnail&size=512' }, alt={ file.name }, title={ file.name })
+ a.time(href={ url })
+ mk-time(time={ p.created_at }, mode='detail')
+ footer
+ button(onclick={ reply }, title='返信')
+ i.fa.fa-reply
+ p.count(if={ p.replies_count > 0 }) { p.replies_count }
+ button(onclick={ repost }, title='Repost')
+ i.fa.fa-retweet
+ p.count(if={ p.repost_count > 0 }) { p.repost_count }
+ button(class={ liked: p.is_liked }, onclick={ like }, title='善哉')
+ i.fa.fa-thumbs-o-up
+ p.count(if={ p.likes_count > 0 }) { p.likes_count }
+ button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h
+ div.reposts-and-likes
+ div.reposts(if={ reposts && reposts.length > 0 })
+ header
+ a { p.repost_count }
+ p Repost
+ ol.users
+ li.user(each={ reposts })
+ a.avatar-anchor(href={ CONFIG.url + '/' + user.username }, title={ user.name })
+ img.avatar(src={ user.avatar_url + '?thumbnail&size=32' }, alt='')
+ div.likes(if={ likes && likes.length > 0 })
+ header
+ a { p.likes_count }
+ p いいね
+ ol.users
+ li.user(each={ likes })
+ a.avatar-anchor(href={ CONFIG.url + '/' + username }, title={ name })
+ img.avatar(src={ avatar_url + '?thumbnail&size=32' }, alt='')
+
+ div.replies
+ virtual(each={ post in replies })
+ mk-post-detail-sub(post={ post })
+
+style.
+ display block
+ margin 0
+ padding 0
+
+ > .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
+
+ &:hover
+ background #f6f6f6
+
+ &:active
+ background #f0f0f0
+
+ &: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
+ 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
+
+ > .avatar-anchor
+ display block
+
+ > .avatar
+ display block
+ width 54px
+ height 54px
+ margin 0
+ border-radius 8px
+ vertical-align bottom
+
+ @media (min-width 500px)
+ width 60px
+ height 60px
+
+ > header
+ position absolute
+ top 18px
+ left 80px
+ width calc(100% - 80px)
+
+ @media (min-width 500px)
+ top 28px
+ left 108px
+ width calc(100% - 108px)
+
+ > .name
+ display inline-block
+ margin 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
+ display block
+ margin 0
+ padding 0
+ word-wrap break-word
+ font-size 16px
+ color #717171
+
+ @media (min-width 500px)
+ font-size 24px
+
+ > 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
+
+ &:hover
+ color #666
+
+ > .count
+ display inline
+ margin 0 0 0 8px
+ color #999
+
+ &.liked
+ color $theme-color
+
+ > .reposts-and-likes
+ display flex
+ justify-content center
+ padding 0
+ margin 16px 0
+
+ &:empty
+ display none
+
+ > .reposts
+ > .likes
+ display flex
+ flex 1 1
+ padding 0
+ border-top solid 1px #F2EFEE
+
+ > header
+ flex 1 1 80px
+ max-width 80px
+ padding 8px 5px 0px 10px
+
+ > a
+ display block
+ font-size 1.5em
+ line-height 1.4em
+
+ > p
+ display block
+ margin 0
+ font-size 0.7em
+ line-height 1em
+ font-weight normal
+ color #a0a2a5
+
+ > .users
+ display block
+ flex 1 1
+ margin 0
+ padding 10px 10px 10px 5px
+ list-style none
+
+ > .user
+ display block
+ float left
+ margin 4px
+ padding 0
+
+ > .avatar-anchor
+ display:block
+
+ > .avatar
+ vertical-align bottom
+ width 24px
+ height 24px
+ border-radius 4px
+
+ > .reposts + .likes
+ margin-left 16px
+
+ > .replies
+ > *
+ border-top 1px solid #eef0f2
+
+script.
+ @mixin \api
+ @mixin \text
+ @mixin \get-post-summary
+ @mixin \open-post-form
+
+ @fetching = true
+ @loading-context = false
+ @content = null
+ @post = null
+
+ @on \mount ~>
+ @api \posts/show do
+ post_id: @opts.post
+ .then (post) ~>
+ @post = post
+ @is-repost = @post.repost?
+ @p = if @is-repost then @post.repost else @post
+ @summary = @get-post-summary @p
+ @trigger \loaded
+ @fetching = false
+ @update!
+
+ if @p.text?
+ tokens = @analyze @p.text
+ @refs.text.innerHTML = @compile tokens
+
+ @refs.text.children.for-each (e) ~>
+ if e.tag-name == \MK-URL
+ riot.mount e
+
+ # URLをプレビュー
+ tokens
+ .filter (t) -> t.type == \link
+ .map (t) ~>
+ @preview = @refs.text.append-child document.create-element \mk-url-preview
+ riot.mount @preview, do
+ url: t.content
+
+ # Get likes
+ @api \posts/likes do
+ post_id: @p.id
+ limit: 8
+ .then (likes) ~>
+ @likes = likes
+ @update!
+
+ # Get reposts
+ @api \posts/reposts do
+ post_id: @p.id
+ limit: 8
+ .then (reposts) ~>
+ @reposts = reposts
+ @update!
+
+ # Get replies
+ @api \posts/replies do
+ post_id: @p.id
+ limit: 8
+ .then (replies) ~>
+ @replies = replies
+ @update!
+
+ @reply = ~>
+ @open-post-form do
+ reply: @p
+
+ @repost = ~>
+ text = window.prompt '「' + @summary + '」をRepost'
+ if text?
+ @api \posts/create do
+ repost_id: @p.id
+ text: if text == '' then undefined else text
+
+ @like = ~>
+ if @p.is_liked
+ @api \posts/likes/delete do
+ post_id: @p.id
+ .then ~>
+ @p.is_liked = false
+ @update!
+ else
+ @api \posts/likes/create do
+ post_id: @p.id
+ .then ~>
+ @p.is_liked = true
+ @update!
+
+ @load-context = ~>
+ @loading-context = true
+
+ # Get context
+ @api \posts/context do
+ post_id: @p.reply_to_id
+ .then (context) ~>
+ @context = context.reverse!
+ @loading-context = false
+ @update!
diff --git a/src/web/app/mobile/tags/post-form.tag b/src/web/app/mobile/tags/post-form.tag
new file mode 100644
index 0000000000..759a0820b8
--- /dev/null
+++ b/src/web/app/mobile/tags/post-form.tag
@@ -0,0 +1,254 @@
+mk-post-form
+ header: div
+ button.cancel(onclick={ cancel }): i.fa.fa-times
+ div
+ span.text-count(class={ over: refs.text.value.length > 300 }) { 300 - refs.text.value.length }
+ button.submit(onclick={ post }) 投稿
+ div.form
+ mk-post-preview(if={ opts.reply }, post={ opts.reply })
+ textarea@text(disabled={ wait }, oninput={ update }, onkeypress={ onkeypress }, onpaste={ onpaste }, placeholder={ opts.reply ? 'この投稿への返信...' : 'いまどうしてる?' })
+ div.attaches(if={ files.length != 0 })
+ ul.files@attaches
+ li.file(each={ files })
+ div.img(style='background-image: url({ url + "?thumbnail&size=64" })', title={ name })
+ li.add(if={ files.length < 4 }, title='PCからファイルを添付', onclick={ select-file }): i.fa.fa-plus
+ mk-uploader@uploader
+ button@upload(onclick={ select-file }): i.fa.fa-upload
+ button@drive(onclick={ select-file-from-drive }): i.fa.fa-cloud
+ input@file(type='file', accept='image/*', multiple, onchange={ change-file })
+
+style.
+ display block
+ padding-top 50px
+
+ > header
+ position fixed
+ z-index 1000
+ top 0
+ left 0
+ width 100%
+ height 50px
+ background #fff
+
+ > div
+ max-width 500px
+ margin 0 auto
+
+ > .cancel
+ width 50px
+ line-height 50px
+ font-size 24px
+ color #555
+
+ > div
+ position absolute
+ top 0
+ right 0
+
+ > .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
+
+ > .form
+ max-width 500px
+ margin 0 auto
+
+ > mk-post-preview
+ padding 16px
+
+ > .attaches
+
+ > .files
+ display block
+ margin 0
+ padding 4px
+ list-style none
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .file
+ display block
+ float left
+ margin 4px
+ padding 0
+ cursor move
+
+ &:hover > .remove
+ display block
+
+ > .img
+ width 64px
+ height 64px
+ background-size cover
+ background-position center center
+
+ > .remove
+ display none
+ position absolute
+ top -6px
+ right -6px
+ width 16px
+ height 16px
+ cursor pointer
+
+ > .add
+ display block
+ float left
+ margin 4px
+ padding 0
+ border dashed 2px rgba($theme-color, 0.2)
+ cursor pointer
+
+ &:hover
+ border-color rgba($theme-color, 0.3)
+
+ > i
+ color rgba($theme-color, 0.4)
+
+ > i
+ display block
+ width 60px
+ height 60px
+ line-height 60px
+ text-align center
+ font-size 1.2em
+ color rgba($theme-color, 0.2)
+
+ > mk-uploader
+ margin 8px 0 0 0
+ padding 8px
+
+ > [ref='file']
+ display none
+
+ > [ref='text']
+ display block
+ padding 12px
+ margin 0
+ width 100%
+ max-width 100%
+ min-width 100%
+ min-height 80px
+ font-size 16px
+ color #333
+ border none
+ border-bottom solid 1px #ddd
+ border-radius 0
+
+ &:disabled
+ opacity 0.5
+
+ > [ref='upload']
+ > [ref='drive']
+ display inline-block
+ padding 0
+ margin 0
+ width 48px
+ height 48px
+ font-size 20px
+ color #657786
+ background transparent
+ outline none
+ border none
+ border-radius 0
+ box-shadow none
+
+script.
+ @mixin \api
+
+ @wait = false
+ @uploadings = []
+ @files = []
+
+ @on \mount ~>
+ @refs.uploader.on \uploaded (file) ~>
+ @add-file file
+
+ @refs.uploader.on \change-uploads (uploads) ~>
+ @trigger \change-uploading-files uploads
+
+ @refs.text.focus!
+
+ @onkeypress = (e) ~>
+ if (e.char-code == 10 || e.char-code == 13) && e.ctrl-key
+ @post!
+ else
+ return true
+
+ @onpaste = (e) ~>
+ data = e.clipboard-data
+ items = data.items
+ for i from 0 to items.length - 1
+ item = items[i]
+ switch (item.kind)
+ | \file =>
+ @upload item.get-as-file!
+ return true
+
+ @select-file = ~>
+ @refs.file.click!
+
+ @select-file-from-drive = ~>
+ browser = document.body.append-child document.create-element \mk-drive-selector
+ browser = riot.mount browser, do
+ multiple: true
+ .0
+ browser.on \selected (files) ~>
+ files.for-each @add-file
+
+ @change-file = ~>
+ files = @refs.file.files
+ for i from 0 to files.length - 1
+ file = files.item i
+ @upload file
+
+ @upload = (file) ~>
+ @refs.uploader.upload file
+
+ @add-file = (file) ~>
+ file._remove = ~>
+ @files = @files.filter (x) -> x.id != file.id
+ @trigger \change-files @files
+ @update!
+
+ @files.push file
+ @trigger \change-files @files
+ @update!
+
+ @post = ~>
+ @wait = true
+
+ files = if @files? and @files.length > 0
+ then @files.map (f) -> f.id
+ else undefined
+
+ @api \posts/create do
+ text: @refs.text.value
+ media_ids: files
+ reply_to_id: if @opts.reply? then @opts.reply.id else undefined
+ .then (data) ~>
+ @trigger \post
+ @unmount!
+ .catch (err) ~>
+ console.error err
+ #@opts.ui.trigger \notification 'Error!'
+ @wait = false
+ @update!
+
+ @cancel = ~>
+ @trigger \cancel
+ @unmount!
diff --git a/src/web/app/mobile/tags/post-preview.tag b/src/web/app/mobile/tags/post-preview.tag
new file mode 100644
index 0000000000..e15b2be244
--- /dev/null
+++ b/src/web/app/mobile/tags/post-preview.tag
@@ -0,0 +1,89 @@
+mk-post-preview
+ article
+ a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username })
+ img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.main
+ header
+ a.name(href={ CONFIG.url + '/' + post.user.username })
+ | { post.user.name }
+ span.username
+ | @{ post.user.username }
+ a.time(href={ CONFIG.url + '/' + post.user.username + '/' + post.id })
+ mk-time(time={ post.created_at })
+ div.body
+ mk-sub-post-content.text(post={ post })
+
+style.
+ display block
+ margin 0
+ padding 0
+ font-size 0.9em
+ background #fff
+
+ > 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
+ margin-bottom 4px
+ white-space nowrap
+
+ > .name
+ display inline
+ margin 0
+ padding 0
+ color #607073
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 0 0 8px
+ color #d1d8da
+
+ > .time
+ position absolute
+ top 0
+ right 0
+ color #b2b8bb
+
+ > .body
+
+ > .text
+ cursor default
+ margin 0
+ padding 0
+ font-size 1.1em
+ color #717171
+
+script.
+ @post = @opts.post
diff --git a/src/web/app/mobile/tags/search-posts.tag b/src/web/app/mobile/tags/search-posts.tag
new file mode 100644
index 0000000000..4b1b12af27
--- /dev/null
+++ b/src/web/app/mobile/tags/search-posts.tag
@@ -0,0 +1,29 @@
+mk-search-posts
+ mk-timeline(init={ init }, more={ more }, empty={ '「' + query + '」に関する投稿は見つかりませんでした。' })
+
+style.
+ display block
+ background #fff
+
+script.
+ @mixin \api
+
+ @max = 30
+ @offset = 0
+
+ @query = @opts.query
+ @with-media = @opts.with-media
+
+ @init = new Promise (res, rej) ~>
+ @api \posts/search do
+ query: @query
+ .then (posts) ~>
+ res posts
+ @trigger \loaded
+
+ @more = ~>
+ @offset += @max
+ @api \posts/search do
+ query: @query
+ max: @max
+ offset: @offset
diff --git a/src/web/app/mobile/tags/search.tag b/src/web/app/mobile/tags/search.tag
new file mode 100644
index 0000000000..bf2299cc9b
--- /dev/null
+++ b/src/web/app/mobile/tags/search.tag
@@ -0,0 +1,12 @@
+mk-search
+ mk-search-posts@posts(query={ query })
+
+style.
+ display block
+
+script.
+ @query = @opts.query
+
+ @on \mount ~>
+ @refs.posts.on \loaded ~>
+ @trigger \loaded
diff --git a/src/web/app/mobile/tags/stream-indicator.tag b/src/web/app/mobile/tags/stream-indicator.tag
new file mode 100644
index 0000000000..2eb5889ca6
--- /dev/null
+++ b/src/web/app/mobile/tags/stream-indicator.tag
@@ -0,0 +1,59 @@
+mk-stream-indicator
+ p(if={ state == 'initializing' })
+ i.fa.fa-spinner.fa-spin
+ span
+ | 接続中
+ mk-ellipsis
+ p(if={ state == 'reconnecting' })
+ i.fa.fa-spinner.fa-spin
+ span
+ | 切断されました 接続中
+ mk-ellipsis
+ p(if={ state == 'connected' })
+ i.fa.fa-check
+ span 接続完了
+
+style.
+ display block
+ pointer-events none
+ position fixed
+ z-index 16384
+ bottom 8px
+ right 8px
+ margin 0
+ padding 6px 12px
+ font-size 0.9em
+ color #fff
+ background rgba(0, 0, 0, 0.8)
+
+ > p
+ display block
+ margin 0
+
+ > i
+ margin-right 0.25em
+
+script.
+ @mixin \stream
+
+ @on \before-mount ~>
+ @state = @get-stream-state!
+
+ if @state == \connected
+ @root.style.opacity = 0
+
+ @stream-state-ev.on \connected ~>
+ @state = @get-stream-state!
+ @update!
+ set-timeout ~>
+ Velocity @root, {
+ opacity: 0
+ } 200ms \linear
+ , 1000ms
+
+ @stream-state-ev.on \closed ~>
+ @state = @get-stream-state!
+ @update!
+ Velocity @root, {
+ opacity: 1
+ } 0ms
diff --git a/src/web/app/mobile/tags/sub-post-content.tag b/src/web/app/mobile/tags/sub-post-content.tag
new file mode 100644
index 0000000000..595f63d794
--- /dev/null
+++ b/src/web/app/mobile/tags/sub-post-content.tag
@@ -0,0 +1,36 @@
+mk-sub-post-content
+ div.body
+ a.reply(if={ post.reply_to_id }): i.fa.fa-reply
+ span@text
+ a.quote(if={ post.repost_id }, href={ '/post:' + post.repost_id }) RP: ...
+ details(if={ post.media })
+ summary ({ post.media.length }枚の画像)
+ mk-images-viewer(images={ post.media })
+
+style.
+ display block
+ word-wrap break-word
+
+ > .body
+ > .reply
+ margin-right 6px
+ color #717171
+
+ > .quote
+ margin-left 4px
+ font-style oblique
+ color #a0bf46
+
+script.
+ @mixin \text
+
+ @post = @opts.post
+
+ @on \mount ~>
+ if @post.text?
+ tokens = @analyze @post.text
+ @refs.text.innerHTML = @compile tokens, false
+
+ @refs.text.children.for-each (e) ~>
+ if e.tag-name == \MK-URL
+ riot.mount e
diff --git a/src/web/app/mobile/tags/timeline-post-sub.tag b/src/web/app/mobile/tags/timeline-post-sub.tag
new file mode 100644
index 0000000000..920503ebcc
--- /dev/null
+++ b/src/web/app/mobile/tags/timeline-post-sub.tag
@@ -0,0 +1,99 @@
+mk-timeline-post-sub
+ article
+ a.avatar-anchor(href={ '/' + post.user.username })
+ img.avatar(src={ post.user.avatar_url + '?thumbnail&size=96' }, alt='avatar')
+ div.main
+ header
+ a.name(href={ '/' + post.user.username })
+ | { post.user.name }
+ span.username
+ | @{ post.user.username }
+ a.created-at(href={ '/' + post.user.username + '/' + post.id })
+ mk-time(time={ post.created_at })
+ div.body
+ mk-sub-post-content.text(post={ post })
+
+style.
+ 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
+ margin-bottom 4px
+ white-space nowrap
+
+ > .name
+ display inline
+ margin 0
+ padding 0
+ color #607073
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 0 0 8px
+ color #d1d8da
+
+ > .created-at
+ position absolute
+ top 0
+ right 0
+ color #b2b8bb
+
+ > .body
+
+ > .text
+ cursor default
+ margin 0
+ padding 0
+ font-size 1.1em
+ color #717171
+
+script.
+ @post = @opts.post
diff --git a/src/web/app/mobile/tags/timeline-post.tag b/src/web/app/mobile/tags/timeline-post.tag
new file mode 100644
index 0000000000..a71fab26f0
--- /dev/null
+++ b/src/web/app/mobile/tags/timeline-post.tag
@@ -0,0 +1,296 @@
+mk-timeline-post(class={ repost: is-repost })
+
+ div.reply-to(if={ p.reply_to })
+ mk-timeline-post-sub(post={ p.reply_to })
+
+ div.repost(if={ is-repost })
+ p
+ a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ i.fa.fa-retweet
+ a.name(href={ CONFIG.url + '/' + post.user.username }) { post.user.name }
+ | がRepost
+ mk-time(time={ post.created_at })
+
+ article
+ a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username })
+ img.avatar(src={ p.user.avatar_url + '?thumbnail&size=96' }, alt='avatar')
+ div.main
+ header
+ a.name(href={ CONFIG.url + '/' + p.user.username })
+ | { p.user.name }
+ span.username
+ | @{ p.user.username }
+ a.created-at(href={ url })
+ mk-time(time={ p.created_at })
+ div.body
+ div.text
+ a.reply(if={ p.reply_to }): i.fa.fa-reply
+ soan@text
+ a.quote(if={ p.repost != null }) RP:
+ div.media(if={ p.media })
+ mk-images-viewer(images={ p.media })
+ div.repost(if={ p.repost })
+ i.fa.fa-quote-right.fa-flip-horizontal
+ mk-post-preview.repost(post={ p.repost })
+ footer
+ button(onclick={ reply })
+ i.fa.fa-reply
+ p.count(if={ p.replies_count > 0 }) { p.replies_count }
+ button(onclick={ repost }, title='Repost')
+ i.fa.fa-retweet
+ p.count(if={ p.repost_count > 0 }) { p.repost_count }
+ button(class={ liked: p.is_liked }, onclick={ like })
+ i.fa.fa-thumbs-o-up
+ p.count(if={ p.likes_count > 0 }) { p.likes_count }
+
+style.
+ 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 0 0
+
+ @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
+ white-space nowrap
+
+ @media (min-width 500px)
+ margin-bottom 2px
+
+ > .name
+ display inline
+ margin 0
+ padding 0
+ color #777
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 0 0 8px
+ color #ccc
+
+ > .created-at
+ position absolute
+ top 0
+ right 0
+ font-size 0.9em
+ color #c0c0c0
+
+ > .body
+
+ > .text
+ cursor default
+ display block
+ margin 0
+ padding 0
+ word-wrap break-word
+ font-size 1.1em
+ color #717171
+
+ mk-url-preview
+ margin-top 8px
+
+ > .reply
+ margin-right 8px
+ color #717171
+
+ > .quote
+ margin-left 4px
+ font-style oblique
+ color #a0bf46
+
+ > .media
+ > img
+ display block
+ max-width 100%
+
+ > .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
+
+ &.liked
+ color $theme-color
+
+script.
+ @mixin \api
+ @mixin \text
+ @mixin \get-post-summary
+ @mixin \open-post-form
+
+ @post = @opts.post
+ @is-repost = @post.repost? and !@post.text?
+ @p = if @is-repost then @post.repost else @post
+ @summary = @get-post-summary @p
+ @url = CONFIG.url + '/' + @p.user.username + '/' + @p.id
+
+ @on \mount ~>
+ if @p.text?
+ tokens = if @p._highlight?
+ then @analyze @p._highlight
+ else @analyze @p.text
+
+ @refs.text.innerHTML = if @p._highlight?
+ then @compile tokens, true, false
+ else @compile tokens
+
+ @refs.text.children.for-each (e) ~>
+ if e.tag-name == \MK-URL
+ riot.mount e
+
+ # URLをプレビュー
+ tokens
+ .filter (t) -> t.type == \link
+ .map (t) ~>
+ @preview = @refs.text.append-child document.create-element \mk-url-preview
+ riot.mount @preview, do
+ url: t.content
+
+ @reply = ~>
+ @open-post-form do
+ reply: @p
+
+ @repost = ~>
+ text = window.prompt '「' + @summary + '」をRepost'
+ if text?
+ @api \posts/create do
+ repost_id: @p.id
+ text: if text == '' then undefined else text
+
+ @like = ~>
+ if @p.is_liked
+ @api \posts/likes/delete do
+ post_id: @p.id
+ .then ~>
+ @p.is_liked = false
+ @update!
+ else
+ @api \posts/likes/create do
+ post_id: @p.id
+ .then ~>
+ @p.is_liked = true
+ @update!
diff --git a/src/web/app/mobile/tags/timeline.tag b/src/web/app/mobile/tags/timeline.tag
new file mode 100644
index 0000000000..7114824872
--- /dev/null
+++ b/src/web/app/mobile/tags/timeline.tag
@@ -0,0 +1,128 @@
+mk-timeline
+ div.init(if={ init })
+ i.fa.fa-spinner.fa-pulse
+ | 読み込んでいます
+ div.empty(if={ !init && posts.length == 0 })
+ i.fa.fa-comments-o
+ | { opts.empty || '表示するものがありません' }
+ virtual(each={ post, i in posts })
+ mk-timeline-post(post={ post })
+ p.date(if={ i != posts.length - 1 && post._date != posts[i + 1]._date })
+ span
+ i.fa.fa-angle-up
+ | { post._datetext }
+ span
+ i.fa.fa-angle-down
+ | { posts[i + 1]._datetext }
+ footer(if={ !init })
+ button(if={ can-fetch-more }, onclick={ more }, disabled={ fetching })
+ span(if={ !fetching }) もっとみる
+ span(if={ fetching })
+ | 読み込み中
+ mk-ellipsis
+
+style.
+ display block
+ background #fff
+ background-clip content-box
+ overflow hidden
+
+ > .init
+ padding 64px 0
+ text-align center
+ color #999
+
+ > i
+ margin-right 4px
+
+ > .empty
+ margin 0 auto
+ padding 32px
+ max-width 400px
+ text-align center
+ color #999
+
+ > i
+ display block
+ margin-bottom 16px
+ font-size 3em
+ color #ccc
+
+ > mk-timeline-post
+ border-bottom solid 1px #eaeaea
+
+ &:last-of-type
+ border-bottom none
+
+ > .date
+ display block
+ margin 0
+ line-height 32px
+ text-align center
+ font-size 0.9em
+ color #aaa
+ background #fdfdfd
+ border-bottom solid 1px #eaeaea
+
+ span
+ margin 0 16px
+
+ i
+ margin-right 8px
+
+ > footer
+ text-align center
+ border-top solid 1px #eaeaea
+ border-bottom-left-radius 4px
+ border-bottom-right-radius 4px
+
+ > button
+ margin 0
+ padding 16px
+ width 100%
+ color $theme-color
+
+ &:disabled
+ opacity 0.7
+
+script.
+ @posts = []
+ @init = true
+ @fetching = false
+ @can-fetch-more = true
+
+ @on \mount ~>
+ @opts.init.then (posts) ~>
+ @init = false
+ @set-posts posts
+
+ @on \update ~>
+ @posts.for-each (post) ~>
+ date = (new Date post.created_at).get-date!
+ month = (new Date post.created_at).get-month! + 1
+ post._date = date
+ post._datetext = month + '月 ' + date + '日'
+
+ @more = ~>
+ if @init or @fetching or @posts.length == 0 then return
+ @fetching = true
+ @update!
+ @opts.more!.then (posts) ~>
+ @fetching = false
+ @prepend-posts posts
+
+ @set-posts = (posts) ~>
+ @posts = posts
+ @update!
+
+ @prepend-posts = (posts) ~>
+ posts.for-each (post) ~>
+ @posts.push post
+ @update!
+
+ @add-post = (post) ~>
+ @posts.unshift post
+ @update!
+
+ @tail = ~>
+ @posts[@posts.length - 1]
diff --git a/src/web/app/mobile/tags/ui-header.tag b/src/web/app/mobile/tags/ui-header.tag
new file mode 100644
index 0000000000..7105d065f8
--- /dev/null
+++ b/src/web/app/mobile/tags/ui-header.tag
@@ -0,0 +1,98 @@
+mk-ui-header
+ mk-special-message
+ div.main
+ div.backdrop
+ div.content
+ button.nav#hamburger: i.fa.fa-bars
+ h1@title Misskey
+ button.post(onclick={ post }): i.fa.fa-pencil
+
+style.
+ $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(#000, 0.6)
+
+ > .backdrop
+ position absolute
+ top 0
+ z-index 1023
+ width 100%
+ height $height
+ -webkit-backdrop-filter blur(12px)
+ backdrop-filter blur(12px)
+ background-color rgba(#fff, 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
+ 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
+
+ > .post
+ 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)
+
+script.
+ @mixin \ui
+ @mixin \open-post-form
+
+ @on \mount ~>
+ @opts.ready!
+
+ @ui.one \title (title) ~>
+ if @refs.title?
+ @refs.title.innerHTML = title
+
+ @post = ~>
+ @open-post-form!
diff --git a/src/web/app/mobile/tags/ui-nav.tag b/src/web/app/mobile/tags/ui-nav.tag
new file mode 100644
index 0000000000..2c551b30ad
--- /dev/null
+++ b/src/web/app/mobile/tags/ui-nav.tag
@@ -0,0 +1,169 @@
+mk-ui-nav
+ div.body: div.content
+ a.me(if={ SIGNIN }, href={ CONFIG.url + '/' + I.username })
+ img.avatar(src={ I.avatar_url + '?thumbnail&size=128' }, alt='avatar')
+ p.name { I.name }
+ div.links
+ ul
+ li.post: a(href='/i/post')
+ i.icon.fa.fa-pencil-square-o
+ | 新規投稿
+ i.angle.fa.fa-angle-right
+ ul
+ li.home: a(href='/')
+ i.icon.fa.fa-home
+ | ホーム
+ i.angle.fa.fa-angle-right
+ li.mentions: a(href='/i/mentions')
+ i.icon.fa.fa-at
+ | あなた宛て
+ i.angle.fa.fa-angle-right
+ li.notifications: a(href='/i/notifications')
+ i.icon.fa.fa-bell-o
+ | 通知
+ i.angle.fa.fa-angle-right
+ li.messaging: a
+ i.icon.fa.fa-comments-o
+ | メッセージ
+ i.angle.fa.fa-angle-right
+ ul
+ li.settings: a(onclick={ search })
+ i.icon.fa.fa-search
+ | 検索
+ i.angle.fa.fa-angle-right
+ ul
+ li.settings: a(href='/i/drive')
+ i.icon.fa.fa-cloud
+ | ドライブ
+ i.angle.fa.fa-angle-right
+ li.settings: a(href='/i/upload')
+ i.icon.fa.fa-upload
+ | アップロード
+ i.angle.fa.fa-angle-right
+ ul
+ li.settings: a(href='/i/settings')
+ i.icon.fa.fa-cog
+ | 設定
+ i.angle.fa.fa-angle-right
+ p.about
+ a Misskeyについて
+
+style.
+ display block
+ position fixed
+ top 0
+ left 0
+ z-index -1
+ width 240px
+ color #fff
+ background #313538
+ visibility hidden
+
+ .body
+ height 100%
+ overflow hidden
+
+ .content
+ min-height 100%
+
+ .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 #fff
+ 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
+ border-top solid 1px rgba(0, 0, 0, 0.2)
+ background #353A3E
+ background-clip content-box
+
+ &:last-child
+ border-bottom solid 1px rgba(0, 0, 0, 0.2)
+
+ a
+ display block
+ padding 0 20px
+ line-height 3rem
+ line-height calc(1rem + 30px)
+ color #eee
+ text-decoration none
+
+ > .icon
+ margin-right 0.5em
+
+ > .angle
+ position absolute
+ top 0
+ right 0
+ padding 0 20px
+ font-size 1.2em
+ line-height calc(1rem + 30px)
+ color #ccc
+
+ > .unread-count
+ position absolute
+ height calc(0.9em + 10px)
+ line-height calc(0.9em + 10px)
+ top 0
+ bottom 0
+ right 38px
+ margin auto 0
+ padding 0px 8px
+ min-width 2em
+ font-size 0.9em
+ text-align center
+ color #fff
+ background rgba(255, 255, 255, 0.1)
+ border-radius 1em
+
+ .about
+ margin 1em 1em 2em 1em
+ text-align center
+ font-size 0.6em
+ opacity 0.3
+
+ a
+ color #fff
+
+script.
+ @mixin \i
+ @mixin \page
+
+ @on \mount ~>
+ @opts.ready!
+
+ @search = ~>
+ query = window.prompt \検索
+ if query? and query != ''
+ @page '/search:' + query
diff --git a/src/web/app/mobile/tags/ui.tag b/src/web/app/mobile/tags/ui.tag
new file mode 100644
index 0000000000..81dfac80ca
--- /dev/null
+++ b/src/web/app/mobile/tags/ui.tag
@@ -0,0 +1,50 @@
+mk-ui
+ div.global@global
+ mk-ui-header@header(ready={ ready })
+ mk-ui-nav@nav(ready={ ready })
+
+ div.content@main
+ <yield />
+
+ mk-stream-indicator
+
+style.
+ display block
+
+ > .global
+ > .content
+ background #fff
+
+script.
+ @mixin \stream
+
+ @ready-count = 0
+
+ #@ui.on \notification (text) ~>
+ # alert text
+
+ @on \mount ~>
+ @stream.on \notification @on-stream-notification
+ @ready!
+
+ @on \unmount ~>
+ @stream.off \notification @on-stream-notification
+ @slide.slide-close!
+
+ @ready = ~>
+ @ready-count++
+
+ if @ready-count == 2
+ @slide = SpSlidemenu @refs.main, @refs.nav.root, \#hamburger {direction: \left}
+ @init-view-position!
+
+ @init-view-position = ~>
+ top = @refs.header.root.offset-height
+ @refs.main.style.padding-top = top + \px
+ @refs.nav.root.style.margin-top = top + \px
+ @refs.nav.root.query-selector '.body > .content' .style.padding-bottom = top + \px
+
+ @on-stream-notification = (notification) ~>
+ el = document.body.append-child document.create-element \mk-notify
+ riot.mount el, do
+ notification: notification
diff --git a/src/web/app/mobile/tags/user-followers.tag b/src/web/app/mobile/tags/user-followers.tag
new file mode 100644
index 0000000000..7004398268
--- /dev/null
+++ b/src/web/app/mobile/tags/user-followers.tag
@@ -0,0 +1,22 @@
+mk-user-followers
+ mk-users-list@list(fetch={ fetch }, count={ user.followers_count }, you-know-count={ user.followers_you_know_count }, no-users={ 'フォロワーはいないようです。' })
+
+style.
+ display block
+
+script.
+ @mixin \api
+
+ @user = @opts.user
+
+ @fetch = (iknow, limit, cursor, cb) ~>
+ @api \users/followers do
+ user_id: @user.id
+ iknow: iknow
+ limit: limit
+ cursor: if cursor? then cursor else undefined
+ .then cb
+
+ @on \mount ~>
+ @refs.list.on \loaded ~>
+ @trigger \loaded
diff --git a/src/web/app/mobile/tags/user-following.tag b/src/web/app/mobile/tags/user-following.tag
new file mode 100644
index 0000000000..c122acd607
--- /dev/null
+++ b/src/web/app/mobile/tags/user-following.tag
@@ -0,0 +1,22 @@
+mk-user-following
+ mk-users-list@list(fetch={ fetch }, count={ user.following_count }, you-know-count={ user.following_you_know_count }, no-users={ 'フォロー中のユーザーはいないようです。' })
+
+style.
+ display block
+
+script.
+ @mixin \api
+
+ @user = @opts.user
+
+ @fetch = (iknow, limit, cursor, cb) ~>
+ @api \users/following do
+ user_id: @user.id
+ iknow: iknow
+ limit: limit
+ cursor: if cursor? then cursor else undefined
+ .then cb
+
+ @on \mount ~>
+ @refs.list.on \loaded ~>
+ @trigger \loaded
diff --git a/src/web/app/mobile/tags/user-preview.tag b/src/web/app/mobile/tags/user-preview.tag
new file mode 100644
index 0000000000..4f5fbc1520
--- /dev/null
+++ b/src/web/app/mobile/tags/user-preview.tag
@@ -0,0 +1,103 @@
+mk-user-preview
+ a.avatar-anchor(href={ CONFIG.url + '/' + user.username })
+ img.avatar(src={ user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.main
+ header
+ div.left
+ a.name(href={ CONFIG.url + '/' + user.username })
+ | { user.name }
+ span.username
+ | @{ user.username }
+ div.body
+ div.bio { user.bio }
+
+style.
+ display block
+ margin 0
+ padding 16px
+ font-size 12px
+
+ @media (min-width 350px)
+ font-size 14px
+
+ @media (min-width 500px)
+ font-size 16px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .avatar-anchor
+ display block
+ float left
+ margin 0 10px 0 0
+
+ @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
+ white-space nowrap
+
+ @media (min-width 500px)
+ margin-bottom 2px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .left
+ float left
+
+ > .name
+ display inline
+ margin 0
+ padding 0
+ color #777
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 0 0 8px
+ color #ccc
+
+ > .body
+
+ > .bio
+ cursor default
+ display block
+ margin 0
+ padding 0
+ word-wrap break-word
+ font-size 1.1em
+ color #717171
+
+script.
+ @user = @opts.user
diff --git a/src/web/app/mobile/tags/user-timeline.tag b/src/web/app/mobile/tags/user-timeline.tag
new file mode 100644
index 0000000000..7aa23d2150
--- /dev/null
+++ b/src/web/app/mobile/tags/user-timeline.tag
@@ -0,0 +1,28 @@
+mk-user-timeline
+ mk-timeline(init={ init }, more={ more }, empty={ with-media ? 'メディア付き投稿はありません。' : 'このユーザーはまだ投稿していないようです。' })
+
+style.
+ display block
+ max-width 600px
+ margin 0 auto
+ background #fff
+
+script.
+ @mixin \api
+
+ @user = @opts.user
+ @with-media = @opts.with-media
+
+ @init = new Promise (res, rej) ~>
+ @api \users/posts do
+ user_id: @user.id
+ with_media: @with-media
+ .then (posts) ~>
+ res posts
+ @trigger \loaded
+
+ @more = ~>
+ @api \users/posts do
+ user_id: @user.id
+ with_media: @with-media
+ max_id: @refs.timeline.tail!.id
diff --git a/src/web/app/mobile/tags/user.tag b/src/web/app/mobile/tags/user.tag
new file mode 100644
index 0000000000..8f4c04cf9c
--- /dev/null
+++ b/src/web/app/mobile/tags/user.tag
@@ -0,0 +1,198 @@
+mk-user
+ div.user(if={ !fetching })
+ header
+ div.banner(style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=1024)' : '' })
+ div.body
+ div.top
+ a.avatar: img(src={ user.avatar_url + '?thumbnail&size=160' }, alt='avatar')
+ mk-follow-button(if={ SIGNIN && I.id != user.id }, user={ user })
+
+ div.title
+ h1 { user.name }
+ span.username @{ user.username }
+ span.followed(if={ user.is_followed }) フォローされています
+
+ div.bio { user.bio }
+
+ div.info
+ p.location(if={ user.location })
+ i.fa.fa-map-marker
+ | { user.location }
+
+ div.friends
+ a(href='{ user.username }/following')
+ b { user.following_count }
+ i フォロー
+ a(href='{ user.username }/followers')
+ b { user.followers_count }
+ i フォロワー
+ nav
+ a(data-is-active={ page == 'posts' }, onclick={ go-posts }) 投稿
+ a(data-is-active={ page == 'media' }, onclick={ go-media }) メディア
+ a(data-is-active={ page == 'graphs' }, onclick={ go-graphs }) グラフ
+ a(data-is-active={ page == 'likes' }, onclick={ go-likes }) いいね
+
+ div.body
+ mk-user-timeline(if={ page == 'posts' }, user={ user })
+ mk-user-timeline(if={ page == 'media' }, user={ user }, with-media={ true })
+ mk-user-graphs(if={ page == 'graphs' }, user={ user })
+
+style.
+ display block
+
+ > .user
+ > header
+ > .banner
+ padding-bottom 33.3%
+ background-color #f5f5f5
+ background-size cover
+ background-position center
+
+ > .body
+ padding 8px
+ margin 0 auto
+ max-width 600px
+
+ > .top
+ &:after
+ content ''
+ display block
+ clear both
+
+ > .avatar
+ display block
+ float left
+ width 25%
+ height 40px
+
+ > img
+ display block
+ position absolute
+ left -2px
+ bottom -2px
+ width 100%
+ border 2px solid #fff
+ border-radius 6px
+
+ @media (min-width 500px)
+ left -4px
+ bottom -4px
+ border 4px solid #fff
+ border-radius 12px
+
+ > mk-follow-button
+ float right
+ height 40px
+
+ > .title
+ margin 8px 0
+
+ > h1
+ margin 0
+ line-height 22px
+ font-size 20px
+ color #222
+
+ > .username
+ display inline-block
+ line-height 20px
+ font-size 16px
+ font-weight bold
+ color #657786
+
+ > .followed
+ margin-left 8px
+ padding 2px 4px
+ font-size 12px
+ color #657786
+ background #f8f8f8
+ border-radius 4px
+
+ > .bio
+ margin 8px 0
+ color #333
+
+ > .info
+ margin 8px 0
+
+ > .location
+ display inline
+ margin 0
+ color #555
+
+ > i
+ margin-right 4px
+
+ > .friends
+ > a
+ color #657786
+
+ &:first-child
+ margin-right 16px
+
+ > b
+ margin-right 4px
+ font-size 16px
+ color #14171a
+
+ > i
+ font-size 14px
+
+ > nav
+ display flex
+ justify-content center
+ margin 0 auto
+ max-width 600px
+ border-bottom solid 1px #ddd
+
+ > a
+ display block
+ flex 1 1
+ text-align center
+ line-height 52px
+ font-size 14px
+ text-decoration none
+ color #657786
+ border-bottom solid 2px transparent
+
+ &[data-is-active]
+ font-weight bold
+ color $theme-color
+ border-color $theme-color
+
+ > .body
+ @media (min-width 500px)
+ padding 16px 0 0 0
+
+script.
+ @mixin \i
+ @mixin \api
+
+ @username = @opts.user
+ @page = if @opts.page? then @opts.page else \posts
+ @fetching = true
+
+ @on \mount ~>
+ @api \users/show do
+ username: @username
+ .then (user) ~>
+ @fetching = false
+ @user = user
+ @trigger \loaded user
+ @update!
+
+ @go-posts = ~>
+ @page = \posts
+ @update!
+
+ @go-media = ~>
+ @page = \media
+ @update!
+
+ @go-graphs = ~>
+ @page = \graphs
+ @update!
+
+ @go-likes = ~>
+ @page = \likes
+ @update!
diff --git a/src/web/app/mobile/tags/users-list.tag b/src/web/app/mobile/tags/users-list.tag
new file mode 100644
index 0000000000..3e29a0a4cc
--- /dev/null
+++ b/src/web/app/mobile/tags/users-list.tag
@@ -0,0 +1,125 @@
+mk-users-list
+ nav
+ span(data-is-active={ mode == 'all' }, onclick={ set-mode.bind(this, 'all') })
+ | すべて
+ span { opts.count }
+ // ↓ https://github.com/riot/riot/issues/2080
+ span(if={ SIGNIN && opts.you-know-count != '' }, data-is-active={ mode == 'iknow' }, onclick={ set-mode.bind(this, 'iknow') })
+ | 知り合い
+ span { opts.you-know-count }
+
+ div.users(if={ !fetching && users.length != 0 })
+ mk-user-preview(each={ users }, user={ this })
+
+ button.more(if={ !fetching && next != null }, onclick={ more }, disabled={ more-fetching })
+ span(if={ !more-fetching }) もっと
+ span(if={ more-fetching })
+ | 読み込み中
+ mk-ellipsis
+
+ p.no(if={ !fetching && users.length == 0 })
+ | { opts.no-users }
+ p.fetching(if={ fetching })
+ i.fa.fa-spinner.fa-pulse.fa-fw
+ | 読み込んでいます
+ mk-ellipsis
+
+style.
+ display block
+ background #fff
+
+ > nav
+ display flex
+ justify-content center
+ margin 0 auto
+ max-width 600px
+ border-bottom solid 1px #ddd
+
+ > span
+ display block
+ flex 1 1
+ text-align center
+ line-height 52px
+ font-size 14px
+ color #657786
+ border-bottom solid 2px transparent
+
+ &[data-is-active]
+ font-weight bold
+ color $theme-color
+ border-color $theme-color
+
+ > span
+ display inline-block
+ margin-left 4px
+ padding 2px 5px
+ font-size 12px
+ line-height 1
+ color #888
+ background #eee
+ border-radius 20px
+
+ > .users
+ > *
+ max-width 600px
+ margin 0 auto
+ border-bottom solid 1px rgba(0, 0, 0, 0.05)
+
+ > .no
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > .fetching
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+script.
+ @mixin \i
+
+ @limit = 30users
+ @mode = \all
+
+ @fetching = true
+ @more-fetching = false
+
+ @on \mount ~>
+ @fetch ~>
+ @trigger \loaded
+
+ @fetch = (cb) ~>
+ @fetching = true
+ @update!
+ obj <~ @opts.fetch do
+ @mode == \iknow
+ @limit
+ null
+ @users = obj.users
+ @next = obj.next
+ @fetching = false
+ @update!
+ if cb? then cb!
+
+ @more = ~>
+ @more-fetching = true
+ @update!
+ obj <~ @opts.fetch do
+ @mode == \iknow
+ @limit
+ @cursor
+ @users = @users.concat obj.users
+ @next = obj.next
+ @more-fetching = false
+ @update!
+
+ @set-mode = (mode) ~>
+ @update do
+ mode: mode
+
+ @fetch!
diff --git a/src/web/app/reset.styl b/src/web/app/reset.styl
new file mode 100644
index 0000000000..cf872337cf
--- /dev/null
+++ b/src/web/app/reset.styl
@@ -0,0 +1,27 @@
+*
+ position relative
+ box-sizing border-box
+ background-clip padding-box !important
+
+input:not([type])
+input[type='text']
+input[type='password']
+input[type='email']
+textarea
+button
+progress
+ -webkit-appearance none
+ -moz-appearance none
+ appearance none
+ box-shadow none
+
+button
+ margin 0
+ padding 0
+ background transparent
+ border none
+ cursor pointer
+ color inherit
+
+ *
+ pointer-events none
diff --git a/src/web/apple-touch-icon.ts b/src/web/apple-touch-icon.ts
new file mode 100644
index 0000000000..32e1840405
--- /dev/null
+++ b/src/web/apple-touch-icon.ts
@@ -0,0 +1,8 @@
+import * as path from 'path';
+import * as express from 'express';
+
+const app = express.Router();
+app.get('/apple-touch-icon.png', (req, res) =>
+ res.sendFile(path.resolve(__dirname + '/../../resources/apple-touch-icon.png')));
+
+module.exports = app;
diff --git a/src/web/manifest.ts b/src/web/manifest.ts
new file mode 100644
index 0000000000..e87b0ee328
--- /dev/null
+++ b/src/web/manifest.ts
@@ -0,0 +1,6 @@
+import * as express from 'express';
+
+const app = express.Router();
+app.get('/manifest.json', (req, res) => res.sendFile(__dirname + '/../../resources/manifest.json'));
+
+module.exports = app;
diff --git a/src/web/meta.ts b/src/web/meta.ts
new file mode 100644
index 0000000000..9729faa9ea
--- /dev/null
+++ b/src/web/meta.ts
@@ -0,0 +1,13 @@
+import * as express from 'express';
+const git = require('git-last-commit');
+
+module.exports = async (req: express.Request, res: express.Response) => {
+ // Get commit info
+ git.getLastCommit((err, commit) => {
+ res.send({
+ commit: commit
+ });
+ }, {
+ dst: `${__dirname}/../../misskey`
+ });
+};
diff --git a/src/web/serve-app.ts b/src/web/serve-app.ts
new file mode 100644
index 0000000000..3292cfde37
--- /dev/null
+++ b/src/web/serve-app.ts
@@ -0,0 +1,9 @@
+import * as path from 'path';
+import * as express from 'express';
+import * as ms from 'ms';
+
+export default (name: string) => (req: express.Request, res: express.Response) => {
+ res.sendFile(path.resolve(`${__dirname}/app/${name}/view.html`), {
+ maxAge: ms('7 days')
+ });
+};
diff --git a/src/web/server.ts b/src/web/server.ts
new file mode 100644
index 0000000000..d30680f68c
--- /dev/null
+++ b/src/web/server.ts
@@ -0,0 +1,77 @@
+/**
+ * Web Server
+ */
+
+import * as ms from 'ms';
+
+// express modules
+import * as express from 'express';
+import * as bodyParser from 'body-parser';
+import * as favicon from 'serve-favicon';
+import * as compression from 'compression';
+const subdomain = require('subdomain');
+import serveApp from './serve-app';
+
+/**
+ * Init app
+ */
+const app = express();
+app.disable('x-powered-by');
+app.set('view engine', 'pug');
+
+app.use(bodyParser.urlencoded({ extended: true }));
+app.use(compression());
+
+/**
+ * Initialize requests
+ */
+app.use((req, res, next) => {
+ res.header('X-Frame-Options', 'DENY');
+ next();
+});
+
+/**
+ * Static resources
+ */
+app.use(favicon(`${__dirname}/resources/favicon.ico`));
+app.use(require('./manifest'));
+app.use(require('./apple-touch-icon'));
+app.use('/_/resources', express.static(`${__dirname}/resources`, {
+ maxAge: ms('7 days')
+}));
+
+/**
+ * Common API
+ */
+app.get(/\/api:meta/, require('./meta'));
+app.get(/\/api:url/, require('./service/url-preview'));
+app.post(/\/api:rss/, require('./service/rss-proxy'));
+
+/**
+ * Subdomain
+ */
+app.use(subdomain({
+ base: config.host,
+ prefix: '@'
+}));
+
+/**
+ * Routing
+ */
+
+app.use('/@/about/resources', express.static(`${__dirname}/about/resources`, {
+ maxAge: ms('7 days')
+}));
+
+app.get('/@/about/:page(*)', (req, res) => {
+ res.render(`${__dirname}/about/pages/${req.params.page}`, {
+ path: req.params.page,
+ config: config
+ });
+});
+
+app.get('/@/auth/*', serveApp('auth')); // authorize form
+app.get('/@/dev/*', serveApp('dev')); // developer center
+app.get('*', serveApp('client')); // client
+
+module.exports = app;
diff --git a/src/web/service/proxy/proxy.ts b/src/web/service/proxy/proxy.ts
new file mode 100644
index 0000000000..34c1deafad
--- /dev/null
+++ b/src/web/service/proxy/proxy.ts
@@ -0,0 +1,30 @@
+import * as url from 'url';
+import * as express from 'express';
+import * as request from 'request';
+
+module.exports = (req: express.Request, res: express.Response) => {
+ const _url = req.params.url;
+
+ if (!_url) {
+ return;
+ }
+
+ request({
+ url: _url + url.parse(req.url, true).search,
+ encoding: null
+ }, (err, response, content) => {
+ if (err) {
+ console.error(err);
+ return;
+ }
+
+ const contentType = response.headers['content-type'];
+
+ if (/^text\//.test(contentType) || contentType === 'application/javascript') {
+ content = content.toString().replace(/http:\/\//g, `${config.secondary_scheme}://proxy.${config.secondary_host}/http://`);
+ }
+
+ res.header('Content-Type', contentType);
+ res.send(content);
+ });
+};
diff --git a/src/web/service/proxy/server.ts b/src/web/service/proxy/server.ts
new file mode 100644
index 0000000000..5b1b8d106c
--- /dev/null
+++ b/src/web/service/proxy/server.ts
@@ -0,0 +1,17 @@
+/**
+ * Forward Proxy Service
+ */
+
+import * as express from 'express';
+import * as cors from 'cors';
+
+/**
+ * Init app
+ */
+const app = express();
+app.disable('x-powered-by');
+app.use(cors());
+
+app.get('/:url(*)', require('./proxy'));
+
+module.exports = app;
diff --git a/src/web/service/rss-proxy.ts b/src/web/service/rss-proxy.ts
new file mode 100644
index 0000000000..8cc3711e70
--- /dev/null
+++ b/src/web/service/rss-proxy.ts
@@ -0,0 +1,16 @@
+import * as express from 'express';
+import * as request from 'request';
+const xml2json = require('xml2json');
+
+module.exports = (req: express.Request, res: express.Response) => {
+ const url: string = req.body.url;
+
+ request(url, (err, response, xml) => {
+ if (err) {
+ console.error(err);
+ return;
+ }
+
+ res.send(xml2json.toJson(xml));
+ });
+};
diff --git a/src/web/service/url-preview.ts b/src/web/service/url-preview.ts
new file mode 100644
index 0000000000..d1a345ef17
--- /dev/null
+++ b/src/web/service/url-preview.ts
@@ -0,0 +1,13 @@
+import * as express from 'express';
+import summaly from 'summaly';
+
+module.exports = async (req: express.Request, res: express.Response) => {
+ const summary = await summaly(req.query.url);
+ summary.icon = wrap(summary.icon);
+ summary.thumbnail = wrap(summary.thumbnail);
+ res.send(summary);
+};
+
+function wrap(url: string): string {
+ return `${config.proxy_url}/${url}`;
+}