summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.eslintrc1
-rw-r--r--.gitignore1
-rw-r--r--README.md32
-rw-r--r--cli/clean-cached-remote-files.js101
-rw-r--r--cli/clean-unused-drive-files.js80
-rw-r--r--cli/init.js10
-rw-r--r--cli/suspend.js18
-rw-r--r--crowdin.yml3
-rw-r--r--docs/manage.ja.md13
-rw-r--r--docs/setup.ja.md12
-rw-r--r--gulpfile.ts14
-rw-r--r--locales/de.yml550
-rw-r--r--locales/en.yml126
-rw-r--r--locales/es.yml550
-rw-r--r--locales/fr.yml123
-rw-r--r--locales/index.ts3
-rw-r--r--locales/it.yml550
-rw-r--r--locales/ja.yml22
-rw-r--r--locales/ko.yml550
-rw-r--r--locales/pl.yml550
-rw-r--r--locales/ru.yml550
-rw-r--r--locales/zh.yml550
-rw-r--r--migration/2.0.0.js57
-rw-r--r--migration/2.4.0.js71
-rw-r--r--package.json88
-rw-r--r--src/build/i18n.ts2
-rw-r--r--src/cafy-id.ts29
-rw-r--r--src/client/app/auth/views/form.vue4
-rw-r--r--src/client/app/auth/views/index.vue2
-rw-r--r--src/client/app/base.pug12
-rw-r--r--src/client/app/boot.js35
-rw-r--r--src/client/app/common/define-widget.ts62
-rw-r--r--src/client/app/common/scripts/check-for-update.ts4
-rw-r--r--src/client/app/common/scripts/streaming/channel.ts2
-rw-r--r--src/client/app/common/scripts/streaming/drive.ts2
-rw-r--r--src/client/app/common/scripts/streaming/global-timeline.ts2
-rw-r--r--src/client/app/common/scripts/streaming/home.ts25
-rw-r--r--src/client/app/common/scripts/streaming/local-timeline.ts2
-rw-r--r--src/client/app/common/scripts/streaming/messaging-index.ts2
-rw-r--r--src/client/app/common/scripts/streaming/messaging.ts2
-rw-r--r--src/client/app/common/scripts/streaming/othello-game.ts2
-rw-r--r--src/client/app/common/scripts/streaming/othello.ts2
-rw-r--r--src/client/app/common/scripts/streaming/server.ts2
-rw-r--r--src/client/app/common/scripts/streaming/stream.ts2
-rw-r--r--src/client/app/common/scripts/streaming/user-list.ts17
-rw-r--r--src/client/app/common/views/components/autocomplete.vue12
-rw-r--r--src/client/app/common/views/components/avatar.vue42
-rw-r--r--src/client/app/common/views/components/google.vue67
-rw-r--r--src/client/app/common/views/components/index.ts2
-rw-r--r--src/client/app/common/views/components/media-list.vue17
-rw-r--r--src/client/app/common/views/components/messaging-room.message.vue32
-rw-r--r--src/client/app/common/views/components/messaging-room.vue16
-rw-r--r--src/client/app/common/views/components/messaging.vue45
-rw-r--r--src/client/app/common/views/components/nav.vue10
-rw-r--r--src/client/app/common/views/components/note-html.ts21
-rw-r--r--src/client/app/common/views/components/note-menu.vue12
-rw-r--r--src/client/app/common/views/components/othello.vue20
-rw-r--r--src/client/app/common/views/components/poll-editor.vue10
-rw-r--r--src/client/app/common/views/components/poll.vue17
-rw-r--r--src/client/app/common/views/components/reaction-picker.vue21
-rw-r--r--src/client/app/common/views/components/reactions-viewer.vue33
-rw-r--r--src/client/app/common/views/components/signin.vue4
-rw-r--r--src/client/app/common/views/components/signup.vue4
-rw-r--r--src/client/app/common/views/components/stream-indicator.vue2
-rw-r--r--src/client/app/common/views/components/switch.vue25
-rw-r--r--src/client/app/common/views/components/twitter-setting.vue2
-rw-r--r--src/client/app/common/views/components/url-preview.vue127
-rw-r--r--src/client/app/common/views/components/visibility-chooser.vue223
-rw-r--r--src/client/app/common/views/components/welcome-timeline.vue17
-rw-r--r--src/client/app/common/views/widgets/access-log.vue3
-rw-r--r--src/client/app/common/views/widgets/broadcast.vue1
-rw-r--r--src/client/app/common/views/widgets/calendar.vue21
-rw-r--r--src/client/app/common/views/widgets/donation.vue18
-rw-r--r--src/client/app/common/views/widgets/nav.vue29
-rw-r--r--src/client/app/common/views/widgets/photo-stream.vue2
-rw-r--r--src/client/app/common/views/widgets/rss.vue69
-rw-r--r--src/client/app/common/views/widgets/server.cpu-memory.vue11
-rw-r--r--src/client/app/common/views/widgets/server.cpu.vue10
-rw-r--r--src/client/app/common/views/widgets/server.disk.vue10
-rw-r--r--src/client/app/common/views/widgets/server.memory.vue10
-rw-r--r--src/client/app/common/views/widgets/server.pie.vue10
-rw-r--r--src/client/app/common/views/widgets/server.vue2
-rw-r--r--src/client/app/common/views/widgets/slideshow.vue6
-rw-r--r--src/client/app/common/views/widgets/tips.vue2
-rw-r--r--src/client/app/desktop/api/update-avatar.ts2
-rw-r--r--src/client/app/desktop/api/update-banner.ts4
-rw-r--r--src/client/app/desktop/script.ts5
-rw-r--r--src/client/app/desktop/style.styl20
-rw-r--r--src/client/app/desktop/ui.styl56
-rw-r--r--src/client/app/desktop/views/components/activity.calendar.vue2
-rw-r--r--src/client/app/desktop/views/components/activity.vue76
-rw-r--r--src/client/app/desktop/views/components/calendar.vue24
-rw-r--r--src/client/app/desktop/views/components/context-menu.menu.vue16
-rw-r--r--src/client/app/desktop/views/components/context-menu.vue12
-rw-r--r--src/client/app/desktop/views/components/dialog.vue2
-rw-r--r--src/client/app/desktop/views/components/drive.file.vue14
-rw-r--r--src/client/app/desktop/views/components/drive.vue18
-rw-r--r--src/client/app/desktop/views/components/ellipsis-icon.vue2
-rw-r--r--src/client/app/desktop/views/components/follow-button.vue28
-rw-r--r--src/client/app/desktop/views/components/friends-maker.vue17
-rw-r--r--src/client/app/desktop/views/components/home.vue104
-rw-r--r--src/client/app/desktop/views/components/index.ts2
-rw-r--r--src/client/app/desktop/views/components/media-image-dialog.vue2
-rw-r--r--src/client/app/desktop/views/components/media-image.vue14
-rw-r--r--src/client/app/desktop/views/components/media-video-dialog.vue2
-rw-r--r--src/client/app/desktop/views/components/media-video.vue1
-rw-r--r--src/client/app/desktop/views/components/mentions.vue8
-rw-r--r--src/client/app/desktop/views/components/note-detail.sub.vue59
-rw-r--r--src/client/app/desktop/views/components/note-detail.vue112
-rw-r--r--src/client/app/desktop/views/components/note-preview.vue39
-rw-r--r--src/client/app/desktop/views/components/notes.note.sub.vue69
-rw-r--r--src/client/app/desktop/views/components/notes.note.vue352
-rw-r--r--src/client/app/desktop/views/components/notes.vue249
-rw-r--r--src/client/app/desktop/views/components/notifications.vue331
-rw-r--r--src/client/app/desktop/views/components/post-form.vue200
-rw-r--r--src/client/app/desktop/views/components/renote-form.vue59
-rw-r--r--src/client/app/desktop/views/components/repost-form.vue131
-rw-r--r--src/client/app/desktop/views/components/settings.api.vue2
-rw-r--r--src/client/app/desktop/views/components/settings.vue103
-rw-r--r--src/client/app/desktop/views/components/sub-note-content.vue1
-rw-r--r--src/client/app/desktop/views/components/timeline.core.vue136
-rw-r--r--src/client/app/desktop/views/components/timeline.vue117
-rw-r--r--src/client/app/desktop/views/components/ui.header.account.vue76
-rw-r--r--src/client/app/desktop/views/components/ui.header.nav.vue12
-rw-r--r--src/client/app/desktop/views/components/ui.header.notifications.vue21
-rw-r--r--src/client/app/desktop/views/components/ui.header.search.vue4
-rw-r--r--src/client/app/desktop/views/components/ui.header.vue13
-rw-r--r--src/client/app/desktop/views/components/user-list-timeline.vue93
-rw-r--r--src/client/app/desktop/views/components/user-lists-window.vue69
-rw-r--r--src/client/app/desktop/views/components/user-preview.vue38
-rw-r--r--src/client/app/desktop/views/components/users-list.item.vue17
-rw-r--r--src/client/app/desktop/views/components/users-list.vue8
-rw-r--r--src/client/app/desktop/views/components/widget-container.vue33
-rw-r--r--src/client/app/desktop/views/components/window.vue57
-rw-r--r--src/client/app/desktop/views/pages/favorites.vue73
-rw-r--r--src/client/app/desktop/views/pages/note.vue21
-rw-r--r--src/client/app/desktop/views/pages/search.vue2
-rw-r--r--src/client/app/desktop/views/pages/user-list.users.vue124
-rw-r--r--src/client/app/desktop/views/pages/user-list.vue71
-rw-r--r--src/client/app/desktop/views/pages/user/user.followers-you-know.vue4
-rw-r--r--src/client/app/desktop/views/pages/user/user.friends.vue21
-rw-r--r--src/client/app/desktop/views/pages/user/user.header.vue61
-rw-r--r--src/client/app/desktop/views/pages/user/user.home.vue4
-rw-r--r--src/client/app/desktop/views/pages/user/user.photos.vue4
-rw-r--r--src/client/app/desktop/views/pages/user/user.profile.vue69
-rw-r--r--src/client/app/desktop/views/pages/user/user.timeline.vue73
-rw-r--r--src/client/app/desktop/views/pages/welcome.vue27
-rw-r--r--src/client/app/desktop/views/widgets/activity.vue2
-rw-r--r--src/client/app/desktop/views/widgets/channel.vue5
-rw-r--r--src/client/app/desktop/views/widgets/messaging.vue33
-rw-r--r--src/client/app/desktop/views/widgets/notifications.vue55
-rw-r--r--src/client/app/desktop/views/widgets/polls.vue69
-rw-r--r--src/client/app/desktop/views/widgets/post-form.vue5
-rw-r--r--src/client/app/desktop/views/widgets/profile.vue34
-rw-r--r--src/client/app/desktop/views/widgets/timemachine.vue1
-rw-r--r--src/client/app/desktop/views/widgets/trends.vue104
-rw-r--r--src/client/app/desktop/views/widgets/users.vue165
-rw-r--r--src/client/app/init.css7
-rw-r--r--src/client/app/init.ts58
-rw-r--r--src/client/app/mios.ts (renamed from src/client/app/common/mios.ts)73
-rw-r--r--src/client/app/mobile/script.ts8
-rw-r--r--src/client/app/mobile/style.styl4
-rw-r--r--src/client/app/mobile/views/components/drive-file-chooser.vue2
-rw-r--r--src/client/app/mobile/views/components/drive-folder-chooser.vue2
-rw-r--r--src/client/app/mobile/views/components/drive.file-detail.vue4
-rw-r--r--src/client/app/mobile/views/components/drive.vue6
-rw-r--r--src/client/app/mobile/views/components/friends-maker.vue2
-rw-r--r--src/client/app/mobile/views/components/index.ts4
-rw-r--r--src/client/app/mobile/views/components/media-image.vue12
-rw-r--r--src/client/app/mobile/views/components/note-card.vue14
-rw-r--r--src/client/app/mobile/views/components/note-detail.sub.vue44
-rw-r--r--src/client/app/mobile/views/components/note-detail.vue127
-rw-r--r--src/client/app/mobile/views/components/note-preview.vue39
-rw-r--r--src/client/app/mobile/views/components/note.sub.vue75
-rw-r--r--src/client/app/mobile/views/components/note.vue392
-rw-r--r--src/client/app/mobile/views/components/notes.vue257
-rw-r--r--src/client/app/mobile/views/components/notification.vue139
-rw-r--r--src/client/app/mobile/views/components/notifications.vue55
-rw-r--r--src/client/app/mobile/views/components/post-form.vue186
-rw-r--r--src/client/app/mobile/views/components/sub-note-content.vue1
-rw-r--r--src/client/app/mobile/views/components/timeline.vue113
-rw-r--r--src/client/app/mobile/views/components/ui.header.vue18
-rw-r--r--src/client/app/mobile/views/components/ui.nav.vue50
-rw-r--r--src/client/app/mobile/views/components/user-list-timeline.vue93
-rw-r--r--src/client/app/mobile/views/components/user-preview.vue25
-rw-r--r--src/client/app/mobile/views/components/user-timeline.vue73
-rw-r--r--src/client/app/mobile/views/components/users-list.vue14
-rw-r--r--src/client/app/mobile/views/components/widget-container.vue2
-rw-r--r--src/client/app/mobile/views/pages/dashboard.vue195
-rw-r--r--src/client/app/mobile/views/pages/followers.vue3
-rw-r--r--src/client/app/mobile/views/pages/following.vue3
-rw-r--r--src/client/app/mobile/views/pages/home.timeline.vue149
-rw-r--r--src/client/app/mobile/views/pages/home.vue308
-rw-r--r--src/client/app/mobile/views/pages/note.vue44
-rw-r--r--src/client/app/mobile/views/pages/notifications.vue23
-rw-r--r--src/client/app/mobile/views/pages/profile-setting.vue5
-rw-r--r--src/client/app/mobile/views/pages/search.vue3
-rw-r--r--src/client/app/mobile/views/pages/selectdrive.vue2
-rw-r--r--src/client/app/mobile/views/pages/settings.vue5
-rw-r--r--src/client/app/mobile/views/pages/signup.vue2
-rw-r--r--src/client/app/mobile/views/pages/user.vue100
-rw-r--r--src/client/app/mobile/views/pages/user/home.vue27
-rw-r--r--src/client/app/mobile/views/pages/welcome.vue231
-rw-r--r--src/client/app/mobile/views/widgets/activity.vue1
-rw-r--r--src/client/app/mobile/views/widgets/profile.vue6
-rw-r--r--src/client/app/store.ts92
-rw-r--r--src/client/docs/api/endpoints/view.pug2
-rw-r--r--src/client/docs/api/entities/note.yaml6
-rw-r--r--src/client/docs/api/entities/post.yaml6
-rw-r--r--src/client/docs/api/entities/view.pug2
-rw-r--r--src/client/docs/api/mixins.pug4
-rw-r--r--src/client/docs/follow.ja.pug9
-rw-r--r--src/config/load.ts2
-rw-r--r--src/config/types.ts6
-rw-r--r--src/const.json2
-rw-r--r--src/crypto_key.cc4
-rw-r--r--src/drive/gen-thumbnail.ts25
-rw-r--r--src/models/drive-file-thumbnail.ts61
-rw-r--r--src/models/drive-file.ts23
-rw-r--r--src/models/favorite.ts35
-rw-r--r--src/models/following.ts11
-rw-r--r--src/models/messaging-message.ts1
-rw-r--r--src/models/note-reaction.ts13
-rw-r--r--src/models/note.ts136
-rw-r--r--src/models/sw-subscription.ts1
-rw-r--r--src/models/user-list.ts67
-rw-r--r--src/models/user.ts85
-rw-r--r--src/publishers/stream.ts5
-rw-r--r--src/queue/index.ts8
-rw-r--r--src/queue/processors/http/deliver.ts3
-rw-r--r--src/queue/processors/http/process-inbox.ts5
-rw-r--r--src/remote/activitypub/kernel/announce/note.ts15
-rw-r--r--src/remote/activitypub/kernel/delete/note.ts1
-rw-r--r--src/remote/activitypub/kernel/follow.ts5
-rw-r--r--src/remote/activitypub/kernel/like.ts15
-rw-r--r--src/remote/activitypub/kernel/undo/follow.ts5
-rw-r--r--src/remote/activitypub/misc/get-note-html.ts23
-rw-r--r--src/remote/activitypub/models/image.ts7
-rw-r--r--src/remote/activitypub/models/note.ts104
-rw-r--r--src/remote/activitypub/models/person.ts62
-rw-r--r--src/remote/activitypub/renderer/like.ts5
-rw-r--r--src/remote/activitypub/renderer/note.ts3
-rw-r--r--src/remote/activitypub/renderer/person.ts1
-rw-r--r--src/remote/activitypub/request.ts5
-rw-r--r--src/remote/activitypub/type.ts3
-rw-r--r--src/remote/resolve-user.ts9
-rw-r--r--src/renderers/get-note-summary.ts6
-rw-r--r--src/server/activitypub.ts19
-rw-r--r--src/server/api/common/get-friends.ts29
-rw-r--r--src/server/api/common/read-messaging-message.ts2
-rw-r--r--src/server/api/common/read-notification.ts2
-rw-r--r--src/server/api/endpoints.ts53
-rw-r--r--src/server/api/endpoints/aggregation/posts.ts5
-rw-r--r--src/server/api/endpoints/aggregation/users.ts5
-rw-r--r--src/server/api/endpoints/aggregation/users/activity.ts9
-rw-r--r--src/server/api/endpoints/aggregation/users/followers.ts7
-rw-r--r--src/server/api/endpoints/aggregation/users/following.ts7
-rw-r--r--src/server/api/endpoints/aggregation/users/post.ts7
-rw-r--r--src/server/api/endpoints/aggregation/users/reaction.ts4
-rw-r--r--src/server/api/endpoints/app/create.ts10
-rw-r--r--src/server/api/endpoints/app/name_id/available.ts2
-rw-r--r--src/server/api/endpoints/app/show.ts6
-rw-r--r--src/server/api/endpoints/auth/accept.ts2
-rw-r--r--src/server/api/endpoints/auth/session/generate.ts2
-rw-r--r--src/server/api/endpoints/auth/session/show.ts2
-rw-r--r--src/server/api/endpoints/auth/session/userkey.ts4
-rw-r--r--src/server/api/endpoints/channels.ts8
-rw-r--r--src/server/api/endpoints/channels/create.ts6
-rw-r--r--src/server/api/endpoints/channels/notes.ts14
-rw-r--r--src/server/api/endpoints/channels/show.ts8
-rw-r--r--src/server/api/endpoints/channels/unwatch.ts8
-rw-r--r--src/server/api/endpoints/channels/watch.ts8
-rw-r--r--src/server/api/endpoints/drive/files.ts17
-rw-r--r--src/server/api/endpoints/drive/files/create.ts9
-rw-r--r--src/server/api/endpoints/drive/files/find.ts10
-rw-r--r--src/server/api/endpoints/drive/files/show.ts8
-rw-r--r--src/server/api/endpoints/drive/files/update.ts12
-rw-r--r--src/server/api/endpoints/drive/files/upload_from_url.ts6
-rw-r--r--src/server/api/endpoints/drive/folders.ts15
-rw-r--r--src/server/api/endpoints/drive/folders/create.ts10
-rw-r--r--src/server/api/endpoints/drive/folders/find.ts10
-rw-r--r--src/server/api/endpoints/drive/folders/show.ts8
-rw-r--r--src/server/api/endpoints/drive/folders/update.ts12
-rw-r--r--src/server/api/endpoints/drive/stream.ts14
-rw-r--r--src/server/api/endpoints/following/create.ts4
-rw-r--r--src/server/api/endpoints/following/delete.ts4
-rw-r--r--src/server/api/endpoints/following/stalk.ts35
-rw-r--r--src/server/api/endpoints/following/unstalk.ts35
-rw-r--r--src/server/api/endpoints/i/2fa/done.ts2
-rw-r--r--src/server/api/endpoints/i/2fa/register.ts2
-rw-r--r--src/server/api/endpoints/i/2fa/unregister.ts2
-rw-r--r--src/server/api/endpoints/i/authorized_apps.ts10
-rw-r--r--src/server/api/endpoints/i/change_password.ts8
-rw-r--r--src/server/api/endpoints/i/favorites.ts63
-rw-r--r--src/server/api/endpoints/i/notifications.ts18
-rw-r--r--src/server/api/endpoints/i/pin.ts8
-rw-r--r--src/server/api/endpoints/i/regenerate_token.ts6
-rw-r--r--src/server/api/endpoints/i/signin_history.ts12
-rw-r--r--src/server/api/endpoints/i/update.ts41
-rw-r--r--src/server/api/endpoints/i/update_client_setting.ts27
-rw-r--r--src/server/api/endpoints/i/update_home.ts18
-rw-r--r--src/server/api/endpoints/i/update_mobile_home.ts15
-rw-r--r--src/server/api/endpoints/messaging/history.ts6
-rw-r--r--src/server/api/endpoints/messaging/messages.ts12
-rw-r--r--src/server/api/endpoints/messaging/messages/create.ts15
-rw-r--r--src/server/api/endpoints/messaging/unread.ts4
-rw-r--r--src/server/api/endpoints/meta.ts9
-rw-r--r--src/server/api/endpoints/mute/create.ts8
-rw-r--r--src/server/api/endpoints/mute/delete.ts4
-rw-r--r--src/server/api/endpoints/mute/list.ts16
-rw-r--r--src/server/api/endpoints/my/apps.ts8
-rw-r--r--src/server/api/endpoints/notes.ts18
-rw-r--r--src/server/api/endpoints/notes/context.ts8
-rw-r--r--src/server/api/endpoints/notes/create.ts120
-rw-r--r--src/server/api/endpoints/notes/favorites/create.ts8
-rw-r--r--src/server/api/endpoints/notes/favorites/delete.ts8
-rw-r--r--src/server/api/endpoints/notes/global-timeline.ts12
-rw-r--r--src/server/api/endpoints/notes/local-timeline.ts12
-rw-r--r--src/server/api/endpoints/notes/mentions.ts14
-rw-r--r--src/server/api/endpoints/notes/polls/recommendation.ts8
-rw-r--r--src/server/api/endpoints/notes/polls/vote.ts12
-rw-r--r--src/server/api/endpoints/notes/reactions.ts10
-rw-r--r--src/server/api/endpoints/notes/reactions/create.ts17
-rw-r--r--src/server/api/endpoints/notes/reactions/delete.ts9
-rw-r--r--src/server/api/endpoints/notes/replies.ts10
-rw-r--r--src/server/api/endpoints/notes/reposts.ts10
-rw-r--r--src/server/api/endpoints/notes/search.ts36
-rw-r--r--src/server/api/endpoints/notes/show.ts4
-rw-r--r--src/server/api/endpoints/notes/timeline.ts148
-rw-r--r--src/server/api/endpoints/notes/trend.ts21
-rw-r--r--src/server/api/endpoints/notes/user-list-timeline.ts179
-rw-r--r--src/server/api/endpoints/notifications/get_unread_count.ts4
-rw-r--r--src/server/api/endpoints/notifications/mark_as_read_all.ts4
-rw-r--r--src/server/api/endpoints/othello/games.ts10
-rw-r--r--src/server/api/endpoints/othello/games/show.ts4
-rw-r--r--src/server/api/endpoints/othello/match.ts4
-rw-r--r--src/server/api/endpoints/stats.ts52
-rw-r--r--src/server/api/endpoints/sw/register.ts6
-rw-r--r--src/server/api/endpoints/username/available.ts2
-rw-r--r--src/server/api/endpoints/users.ts10
-rw-r--r--src/server/api/endpoints/users/followers.ts18
-rw-r--r--src/server/api/endpoints/users/following.ts14
-rw-r--r--src/server/api/endpoints/users/get_frequently_replied_users.ts6
-rw-r--r--src/server/api/endpoints/users/lists/create.ts25
-rw-r--r--src/server/api/endpoints/users/lists/list.ts13
-rw-r--r--src/server/api/endpoints/users/lists/push.ts61
-rw-r--r--src/server/api/endpoints/users/lists/show.ts23
-rw-r--r--src/server/api/endpoints/users/notes.ts22
-rw-r--r--src/server/api/endpoints/users/recommendation.ts18
-rw-r--r--src/server/api/endpoints/users/search.ts6
-rw-r--r--src/server/api/endpoints/users/search_by_username.ts10
-rw-r--r--src/server/api/endpoints/users/show.ts66
-rw-r--r--src/server/api/stream/home.ts14
-rw-r--r--src/server/api/stream/user-list.ts14
-rw-r--r--src/server/api/streaming.ts2
-rw-r--r--src/server/file/assets/cache-expired.pngbin0 -> 15025 bytes
-rw-r--r--src/server/file/assets/tombstone.pngbin0 -> 6387 bytes
-rw-r--r--src/server/file/index.ts7
-rw-r--r--src/server/file/pour.ts88
-rw-r--r--src/server/file/send-drive-file.ts60
-rw-r--r--src/server/index.ts19
-rw-r--r--src/server/web/index.ts67
-rw-r--r--src/server/web/url-preview.ts4
-rw-r--r--src/server/web/views/note.pug25
-rw-r--r--src/server/web/views/user.pug20
-rw-r--r--src/server/webfinger.ts38
-rw-r--r--src/services/drive/add-file.ts97
-rw-r--r--src/services/drive/upload-from-url.ts2
-rw-r--r--src/services/following/create.ts13
-rw-r--r--src/services/note/create.ts367
-rw-r--r--src/services/note/reaction/create.ts19
-rw-r--r--src/text/html.ts13
-rw-r--r--src/text/parse/elements/search.ts13
-rw-r--r--src/text/parse/elements/title.ts14
-rw-r--r--src/text/parse/index.ts4
-rw-r--r--src/version.ts7
-rw-r--r--test/api.ts13
-rw-r--r--tslint.json1
-rw-r--r--webpack.config.ts47
379 files changed, 11824 insertions, 4508 deletions
diff --git a/.eslintrc b/.eslintrc
index 7a74d6ef9b..0943cb4b64 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -14,6 +14,7 @@
"vue/no-unused-vars": false,
"vue/attributes-order": false,
"vue/require-prop-types": false,
+ "vue/require-default-prop": false,
"no-console": 0,
"no-unused-vars": 0,
"no-empty": 0
diff --git a/.gitignore b/.gitignore
index 2568aa11c2..53808240b7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,5 +10,4 @@ npm-debug.log
run.bat
api-docs.json
package-lock.json
-version.json
yarn.lock
diff --git a/README.md b/README.md
index e8130e6513..a44c7e16ee 100644
--- a/README.md
+++ b/README.md
@@ -14,15 +14,18 @@
**[Misskey](https://misskey.xyz)** is a completely open source,
ultimately sophisticated new type of mini-blog based SNS.
+<a href="https://www.patreon.com/syuilo"><img src="https://c5.patreon.com/external/logo/become_a_patron_button@2x.png" alt="Become a Patron!" width="160" /></a>
+
:sparkles: Features
----------------------------------------------------------------
-* Automatically updated timeline
* Reactions
+* User lists
* Private messages
-* ServiceWorker support
+* Mute
+* Real time contents
* ActivityPub compatible
-and more! You can touch with your own eyes at https://misskey.xyz/.
+and more! You can touch with your own eyes at [misskey.xyz](https://misskey.xyz).
:package: Create your instance
----------------------------------------------------------------
@@ -33,27 +36,27 @@ please see [Setup and installation guide](./docs/setup.en.md).
----------------------------------------------------------------
**[PR](https://github.com/syuilo/misskey/pulls)s welcome!**
-If you want to translate Misskey, please see [Translation guide](./docs/translate.en.md).
+If you want to...
+* i18n ... please see [Translation guide](./docs/translate.en.md).
+* l10n ... please visit https://crowdin.com/project/misskey
:heart: Backers & Sponsors
----------------------------------------------------------------
[![Backers][backers-image]][support-url]
[![Sponsors][sponsors-image]][support-url]
-<a href="https://www.patreon.com/syuilo"><img src="http://i.imgur.com/xEO164Z.png" alt="Become a Patron!" width="200" /></a>
-
:mortar_board: Notable contributors
----------------------------------------------------------------
-| ![syuilo][syuilo-icon] | ![Morisawa Aya][ayamorisawa-icon] | ![otofune][otofune-icon] | ![akihikodaki][akihikodaki-icon] | ![rinsuki][rinsuki-icon] |
-|:-:|:-:|:-:|:-:|:-:|
-| [syuilo][syuilo-link]<br>Owner | [Aya Morisawa][ayamorisawa-link]<br>Collaborator | [otofune][otofune-link]<br>Collaborator | [akihikodaki][akihikodaki-link] | [rinsuki][rinsuki-link] |
+| ![syuilo][syuilo-icon] | ![Morisawa Aya][ayamorisawa-icon] | ![otofune][otofune-icon] | ![akihikodaki][akihikodaki-icon] | ![tamaina][tamaina-icon] | ![rinsuki][rinsuki-icon] |
+|:-:|:-:|:-:|:-:|:-:|:-:|
+| [syuilo][syuilo-link]<br>Owner | [Aya Morisawa][ayamorisawa-link]<br>Collaborator | [otofune][otofune-link]<br>Collaborator | [akihikodaki][akihikodaki-link] | [tamaina][tamaina-link] | [rinsuki][rinsuki-link] |
[List of all contributors](https://github.com/syuilo/misskey/graphs/contributors)
### :earth_americas: Translators
-| ![][mirro-san-icon] | ![][Conan-kun-icon] |
-|:-:|:-:|
-| [Mirro][mirro-san-link]<br>English, French | [Asriel][Conan-kun-link]<br>English, French |
+| ![][mirro-san-icon] | ![][Conan-kun-icon] | ![][m4sk1n-icon] |
+|:-:|:-:|:-:|
+| [Mirro][mirro-san-link]<br>English, French | [Asriel][Conan-kun-link]<br>English, French | [Marcin Mikołajczak][m4sk1n-link]<br>Polish |
:four_leaf_clover: Copyright
----------------------------------------------------------------
@@ -92,9 +95,12 @@ Misskey is an open-source software licensed under [GNU AGPLv3](LICENSE).
[akihikodaki-icon]: https://avatars2.githubusercontent.com/u/17036990?s=70&v=4
[rinsuki-link]: https://github.com/rinsuki
[rinsuki-icon]: https://avatars0.githubusercontent.com/u/6533808?s=70&v=4
+[tamaina-link]: https://github.com/tamaina
+[tamaina-icon]: https://avatars1.githubusercontent.com/u/7973572?s=70&v=4
[mirro-san-link]: https://github.com/mirro-san
[mirro-san-icon]: https://avatars1.githubusercontent.com/u/17948612?s=70&v=4
[Conan-kun-link]: https://github.com/Conan-kun
[Conan-kun-icon]: https://avatars3.githubusercontent.com/u/30003708?s=70&v=4
-
+[m4sk1n-link]: https://github.com/m4sk1n
+[m4sk1n-icon]: https://avatars3.githubusercontent.com/u/21127288?s=70&v=4
diff --git a/cli/clean-cached-remote-files.js b/cli/clean-cached-remote-files.js
new file mode 100644
index 0000000000..e4db37ef97
--- /dev/null
+++ b/cli/clean-cached-remote-files.js
@@ -0,0 +1,101 @@
+const chalk = require('chalk');
+const log = require('single-line-log').stdout;
+const sequential = require('promise-sequential');
+const { default: DriveFile, DriveFileChunk } = require('../built/models/drive-file');
+const { default: DriveFileThumbnail, DriveFileThumbnailChunk } = require('../built/models/drive-file-thumbnail');
+const { default: User } = require('../built/models/user');
+
+const q = {
+ 'metadata._user.host': {
+ $ne: null
+ }
+};
+
+async function main() {
+ const promiseGens = [];
+
+ const count = await DriveFile.count(q);
+
+ let prev;
+
+ for (let i = 0; i < count; i++) {
+ promiseGens.push(() => {
+ const promise = new Promise(async (res, rej) => {
+ const file = await DriveFile.findOne(prev ? Object.assign({
+ _id: { $lt: prev._id }
+ }, q) : q, {
+ sort: {
+ _id: -1
+ }
+ });
+
+ prev = file;
+
+ function skip() {
+ res([i, file, false]);
+ }
+
+ if (file == null) return skip();
+
+ log(chalk`{gray ${i}} scanning {bold ${file._id}} ${file.filename} ...`);
+
+ const attachingUsersCount = await User.count({
+ $or: [{
+ avatarId: file._id
+ }, {
+ bannerId: file._id
+ }]
+ }, { limit: 1 });
+ if (attachingUsersCount !== 0) return skip();
+
+ Promise.all([
+ // チャンクをすべて削除
+ DriveFileChunk.remove({
+ files_id: file._id
+ }),
+
+ DriveFile.update({ _id: file._id }, {
+ $set: {
+ 'metadata.deletedAt': new Date(),
+ 'metadata.isExpired': true
+ }
+ })
+ ]).then(async () => {
+ res([i, file, true]);
+
+ //#region サムネイルもあれば削除
+ const thumbnail = await DriveFileThumbnail.findOne({
+ 'metadata.originalId': file._id
+ });
+
+ if (thumbnail) {
+ DriveFileThumbnailChunk.remove({
+ files_id: thumbnail._id
+ });
+
+ DriveFileThumbnail.remove({ _id: thumbnail._id });
+ }
+ //#endregion
+ });
+ });
+
+ promise.then(([i, file, deleted]) => {
+ if (deleted) {
+ log(chalk`{gray ${i}} {red deleted: {bold ${file._id}} ${file.filename}}`);
+ } else {
+ log(chalk`{gray ${i}} {green skipped: {bold ${file._id}} ${file.filename}}`);
+ }
+ log.clear();
+ console.log();
+ });
+
+ return promise;
+ });
+ }
+
+ return await sequential(promiseGens);
+}
+
+main().then(() => {
+ console.log('ALL DONE');
+}).catch(console.error);
diff --git a/cli/clean-unused-drive-files.js b/cli/clean-unused-drive-files.js
new file mode 100644
index 0000000000..87b158b9ee
--- /dev/null
+++ b/cli/clean-unused-drive-files.js
@@ -0,0 +1,80 @@
+const chalk = require('chalk');
+const log = require('single-line-log').stdout;
+const sequential = require('promise-sequential');
+const { default: DriveFile, deleteDriveFile } = require('../built/models/drive-file');
+const { default: Note } = require('../built/models/note');
+const { default: MessagingMessage } = require('../built/models/messaging-message');
+const { default: User } = require('../built/models/user');
+
+async function main() {
+ const promiseGens = [];
+
+ const count = await DriveFile.count({});
+
+ let prev;
+
+ for (let i = 0; i < count; i++) {
+ promiseGens.push(() => {
+ const promise = new Promise(async (res, rej) => {
+ const file = await DriveFile.findOne(prev ? {
+ _id: { $lt: prev._id }
+ } : {}, {
+ sort: {
+ _id: -1
+ }
+ });
+
+ prev = file;
+
+ function skip() {
+ res([i, file, false]);
+ }
+
+ if (file == null) return skip();
+
+ log(chalk`{gray ${i}} scanning {bold ${file._id}} ${file.filename} ...`);
+
+ const attachingUsersCount = await User.count({
+ $or: [{
+ avatarId: file._id
+ }, {
+ bannerId: file._id
+ }]
+ }, { limit: 1 });
+ if (attachingUsersCount !== 0) return skip();
+
+ const attachingNotesCount = await Note.count({
+ mediaIds: file._id
+ }, { limit: 1 });
+ if (attachingNotesCount !== 0) return skip();
+
+ const attachingMessagesCount = await MessagingMessage.count({
+ fileId: file._id
+ }, { limit: 1 });
+ if (attachingMessagesCount !== 0) return skip();
+
+ deleteDriveFile(file).then(() => {
+ res([i, file, true]);
+ }).catch(rej);
+ });
+
+ promise.then(([i, file, deleted]) => {
+ if (deleted) {
+ log(chalk`{gray ${i}} {red deleted: {bold ${file._id}} ${file.filename}}`);
+ } else {
+ log(chalk`{gray ${i}} {green skipped: {bold ${file._id}} ${file.filename}}`);
+ }
+ log.clear();
+ console.log();
+ });
+
+ return promise;
+ });
+ }
+
+ return await sequential(promiseGens);
+}
+
+main().then(() => {
+ console.log('done');
+}).catch(console.error);
diff --git a/cli/init.js b/cli/init.js
index efa9911eef..5a36509574 100644
--- a/cli/init.js
+++ b/cli/init.js
@@ -18,7 +18,11 @@ const form = [{
}, {
type: 'input',
name: 'url',
- message: 'URL you want to run Misskey:'
+ message: 'URL you want to run Misskey:',
+ validate: function(wannabeurl) {
+ return wannabeurl.match('^http\(s?\)://') ? true :
+ 'URL needs to start with http:// or https://';
+ }
}, {
type: 'input',
name: 'port',
@@ -140,8 +144,8 @@ inquirer.prompt(form).then(as => {
pass: as['es_pass'] || null
},
recaptcha: {
- siteKey: as['recaptcha_site'],
- secretKey: as['recaptcha_secret']
+ site_key: as['recaptcha_site'],
+ secret_key: as['recaptcha_secret']
}
};
diff --git a/cli/suspend.js b/cli/suspend.js
new file mode 100644
index 0000000000..0f22bba477
--- /dev/null
+++ b/cli/suspend.js
@@ -0,0 +1,18 @@
+const mongo = require('mongodb');
+const User = require('../built/models/user').default;
+
+const args = process.argv.slice(2);
+
+const userId = new mongo.ObjectID(args[0]);
+
+console.log(`Suspending ${userId}...`);
+
+User.update({ _id: userId }, {
+ $set: {
+ isSuspended: true
+ }
+}).then(() => {
+ console.log(`Suspended ${userId}`);
+}, e => {
+ console.error(e);
+});
diff --git a/crowdin.yml b/crowdin.yml
new file mode 100644
index 0000000000..3f8dfdeca8
--- /dev/null
+++ b/crowdin.yml
@@ -0,0 +1,3 @@
+files:
+ - source: /locales/ja.yml
+ translation: /locales/%two_letters_code%.yml
diff --git a/docs/manage.ja.md b/docs/manage.ja.md
new file mode 100644
index 0000000000..d56ed4c19b
--- /dev/null
+++ b/docs/manage.ja.md
@@ -0,0 +1,13 @@
+# 運営ガイド
+
+## ジョブキューの状態を調べる
+Misskeyのディレクトリで:
+``` shell
+node_modules/kue/bin/kue-dashboard -p 3050
+```
+ポート3050にアクセスするとUIが表示されます
+
+## ユーザーを凍結する
+``` shell
+node cli/suspend (ユーザーID)
+```
diff --git a/docs/setup.ja.md b/docs/setup.ja.md
index a46c38cb21..c45ebcdca0 100644
--- a/docs/setup.ja.md
+++ b/docs/setup.ja.md
@@ -67,3 +67,15 @@ web-push generate-vapid-keys
1. `git reset --hard && git pull origin master`
2. `npm install`
3. `npm run build`
+
+## メモリが足りなくてビルドできない場合
+Misskeyの(クライアントの)ビルドには、目安として8GBくらいのメモリを必要とします。
+VPSなどでビルドする時は、もしかしたらメモリが足りなくなる可能性があります。
+そうなった場合、もしVPSではなくあなたのPCが十分なメモリを搭載しているなら、あなたのPC上でビルドし、生成されたファイルをVPSにFTPでアップロードする方法を採ることができます。
+
+1. あなたのPC上にMisskeyをインストールする
+2. 設定ファイルを用意する。設定ファイルは、サーバーに合わせた設定にします。
+3. npm run webpack
+4. built/client をサーバーにアップロードする
+5. サーバー上で、npm run gulp
+6. 完了 \ No newline at end of file
diff --git a/gulpfile.ts b/gulpfile.ts
index fe3b040237..a9ccbbdb5e 100644
--- a/gulpfile.ts
+++ b/gulpfile.ts
@@ -2,9 +2,7 @@
* Gulp tasks
*/
-import * as childProcess from 'child_process';
import * as fs from 'fs';
-import * as Path from 'path';
import * as gulp from 'gulp';
import * as gutil from 'gulp-util';
import * as ts from 'gulp-typescript';
@@ -23,7 +21,7 @@ import * as htmlmin from 'gulp-htmlmin';
const uglifyes = require('uglify-es');
import { fa } from './src/build/fa';
-import version from './src/version';
+const client = require('./built/client/meta.json');
import config from './src/config';
const uglify = uglifyComposer(uglifyes, console);
@@ -61,9 +59,15 @@ gulp.task('build:ts', () => {
.pipe(gulp.dest('./built/'));
});
-gulp.task('build:copy', () =>
+gulp.task('build:copy:views', () =>
+ gulp.src('./src/server/web/views/**/*').pipe(gulp.dest('./built/server/web/views'))
+);
+
+gulp.task('build:copy', ['build:copy:views'], () =>
gulp.src([
'./build/Release/crypto_key.node',
+ './src/const.json',
+ './src/server/web/views/**/*',
'./src/**/assets/**/*',
'!./src/client/app/**/assets/**/*'
]).pipe(gulp.dest('./built/'))
@@ -115,7 +119,7 @@ gulp.task('build:client', [
gulp.task('build:client:script', () =>
gulp.src(['./src/client/app/boot.js', './src/client/app/safe.js'])
- .pipe(replace('VERSION', JSON.stringify(version)))
+ .pipe(replace('VERSION', JSON.stringify(client.version)))
.pipe(replace('API', JSON.stringify(config.api_url)))
.pipe(replace('ENV', JSON.stringify(env)))
.pipe(isProduction ? uglify({
diff --git a/locales/de.yml b/locales/de.yml
new file mode 100644
index 0000000000..b6e7898a8e
--- /dev/null
+++ b/locales/de.yml
@@ -0,0 +1,550 @@
+---
+common:
+ misskey: "Misskeyで皆と共有しよう。"
+ time:
+ unknown: "なぞのじかん"
+ future: "未来"
+ just_now: "たった今"
+ seconds_ago: "{}秒前"
+ minutes_ago: "{}分前"
+ hours_ago: "{}時間前"
+ days_ago: "{}日前"
+ weeks_ago: "{}週間前"
+ months_ago: "{}ヶ月前"
+ years_ago: "{}年前"
+ weekday-short:
+ sunday: "日"
+ monday: "月"
+ tuesday: "火"
+ wednesday: "水"
+ thursday: "木"
+ friday: "金"
+ satruday: "土"
+ reactions:
+ like: "いいね"
+ love: "しゅき"
+ laugh: "笑"
+ hmm: "ふぅ~む"
+ surprise: "わお"
+ congrats: "おめでとう"
+ angry: "おこ"
+ confused: "こまこまのこまり"
+ pudding: "Pudding"
+ delete: "削除"
+ loading: "読み込み中"
+ ok: "わかった"
+ update-available: "Misskeyの新しいバージョンがあります({newer}。現在{current}を利用中)。ページを再度読み込みすると更新が適用されます。"
+ my-token-regenerated: "あなたのトークンが更新されたのでサインアウトします。"
+common/views/components/connect-failed.vue:
+ title: "サーバーに接続できません"
+ description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから{再度お試し}ください。"
+ thanks: "いつもMisskeyをご利用いただきありがとうございます。"
+ troubleshoot: "トラブルシュート"
+common/views/components/connect-failed.troubleshooter.vue:
+ title: "トラブルシューティング"
+ network: "ネットワーク接続"
+ checking-network: "ネットワーク接続を確認中"
+ internet: "インターネット接続"
+ checking-internet: "インターネット接続を確認中"
+ server: "サーバー接続"
+ checking-server: "サーバー接続を確認中"
+ finding: "問題を調べています"
+ no-network: "ネットワークに接続されていません"
+ no-network-desc: "お使いのPCのネットワーク接続が正常か確認してください。"
+ no-internet: "インターネットに接続されていません"
+ no-internet-desc: "ネットワークには接続されていますが、インターネットには接続されていないようです。お使いのPCのインターネット接続が正常か確認してください。"
+ no-server: "Misskeyのサーバーに接続できません"
+ no-server-desc: "お使いのPCのインターネット接続は正常ですが、Misskeyのサーバーには接続できませんでした。サーバーがダウンまたはメンテナンスしている可能性があるので、しばらくしてから再度御アクセスください。"
+ success: "Misskeyのサーバーに接続できました"
+ success-desc: "正常に接続できるようです。ページを再度読み込みしてください。"
+ flush: "キャッシュの削除"
+ set-version: "バージョン指定"
+common/views/components/messaging.vue:
+ search-user: "ユーザーを探す"
+ you: "あなた"
+ no-history: "履歴はありません"
+common/views/components/messaging-room.vue:
+ empty: "このユーザーと話したことはありません"
+ more: "もっと読む"
+ no-history: "これより過去の履歴はありません"
+ resize-form: "ドラッグしてフォームの広さを調整"
+ new-message: "新しいメッセージがあります"
+common/views/components/messaging-room.form.vue:
+ input-message-here: "ここにメッセージを入力"
+ send: "送信"
+ attach-from-local: "PCからファイルを添付する"
+ attach-from-drive: "ドライブからファイルを添付する"
+common/views/components/messaging-room.message.vue:
+ is-read: "既読"
+ deleted: "このメッセージは削除されました"
+common/views/components/nav.vue:
+ about: "Misskeyについて"
+ stats: "統計"
+ status: "ステータス"
+ wiki: "Wiki"
+ donors: "ドナー"
+ repository: "リポジトリ"
+ develop: "開発者"
+ feedback: "フィードバック"
+common/views/components/note-menu.vue:
+ favorite: "お気に入り"
+ pin: "ピン留め"
+ remote: "投稿元で見る"
+common/views/components/poll.vue:
+ vote-to: "「{}」に投票する"
+ vote-count: "{}票"
+ total-users: "{}人が投票"
+ vote: "投票する"
+ show-result: "結果を見る"
+ voted: "投票済み"
+common/views/components/poll-editor.vue:
+ no-only-one-choice: "投票には、選択肢が最低2つ必要です"
+ choice-n: "選択肢{}"
+ remove: "この選択肢を削除"
+ add: "+選択肢を追加"
+ destroy: "投票を破棄"
+common/views/components/reaction-picker.vue:
+ choose-reaction: "リアクションを選択"
+common/views/components/signin.vue:
+ username: "ユーザー名"
+ password: "パスワード"
+ token: "トークン"
+ signing-in: "やってます..."
+ signin: "サインイン"
+common/views/components/signup.vue:
+ username: "ユーザー名"
+ checking: "確認しています..."
+ available: "利用できます"
+ unavailable: "既に利用されています"
+ error: "通信エラー"
+ invalid-format: "a~z、A~Z、0~9、_が使えます"
+ too-short: "1文字以上でお願いします!"
+ too-long: "20文字以内でお願いします"
+ password: "パスワード"
+ password-placeholder: "8文字以上を推奨します"
+ weak-password: "弱いパスワード"
+ normal-password: "まあまあのパスワード"
+ strong-password: "強いパスワード"
+ retype: "再入力"
+ retype-placeholder: "確認のため再入力してください"
+ password-matched: "確認されました"
+ password-not-matched: "一致していません"
+ recaptcha: "認証"
+ create: "アカウント作成"
+ some-error: "何らかの原因によりアカウントの作成に失敗しました。再度お試しください。"
+common/views/components/special-message.vue:
+ new-year: "Happy New Year!"
+ christmas: "Merry Christmas!"
+common/views/components/stream-indicator.vue:
+ connecting: "接続中"
+ reconnecting: "再接続中"
+ connected: "接続完了"
+common/views/components/twitter-setting.vue:
+ description: "お使いのTwitterアカウントをお使いのMisskeyアカウントに接続しておくと、プロフィールでTwitterアカウント情報が表示されるようになったり、Twitterを用いた便利なサインインを利用できるようになります。"
+ connected-to: "次のTwitterアカウントに接続されています"
+ detail: "詳細..."
+ reconnect: "再接続する"
+ connect: "Twitterと接続する"
+ disconnect: "切断する"
+common/views/components/uploader.vue:
+ waiting: "待機中"
+common/views/widgets/broadcast.vue:
+ fetching: "確認中"
+ no-broadcasts: "お知らせはありません"
+ have-a-nice-day: "良い一日を!"
+ next: "次"
+common/views/widgets/donation.vue:
+ title: "寄付のお願い"
+ text: "Misskeyの運営にはドメイン、サーバー等のコストが掛かります。Misskeyは広告を掲載したりしないため、収入を皆様からの寄付に頼っています。もしご興味があれば、{}までご連絡ください。ご協力ありがとうございます。"
+common/views/widgets/photo-stream.vue:
+ title: "フォトストリーム"
+ no-photos: "写真はありません"
+common/views/widgets/server.vue:
+ title: "サーバー情報"
+ toggle: "表示を切り替え"
+desktop/views/components/activity.vue:
+ title: "アクティビティ"
+ toggle: "表示を切り替え"
+desktop/views/components/calendar.vue:
+ title: "{1}年 {2}月"
+ prev: "前の月"
+ next: "次の月"
+ go: "クリックして時間遡行"
+desktop/views/components/drive-window.vue:
+ used: "使用中"
+ drive: "ドライブ"
+desktop/views/components/drive.file.vue:
+ avatar: "アイコン"
+ banner: "バナー"
+ contextmenu:
+ rename: "名前を変更"
+ copy-url: "URLをコピー"
+ download: "ダウンロード"
+ else-files: "その他..."
+ set-as-avatar: "アイコンに設定"
+ set-as-banner: "バナーに設定"
+ open-in-app: "アプリで開く"
+ add-app: "アプリを追加"
+ rename-file: "ファイル名の変更"
+ input-new-file-name: "新しいファイル名を入力してください"
+ copied: "コピー完了"
+ copied-url-to-clipboard: "URLをクリップボードにコピーしました"
+desktop/views/components/drive.folder.vue:
+ unable-to-process: "操作を完了できません"
+ circular-reference-detected: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。"
+ unhandled-error: "不明なエラー"
+ contextmenu:
+ move-to-this-folder: "このフォルダへ移動"
+ show-in-new-window: "新しいウィンドウで表示"
+ rename: "名前を変更"
+ rename-folder: "フォルダ名の変更"
+ input-new-folder-name: "新しいフォルダ名を入力してください"
+desktop/views/components/drive.nav-folder.vue:
+ drive: "ドライブ"
+desktop/views/components/drive.vue:
+ search: "検索"
+ load-more: "もっと読み込む"
+ empty-draghover: "ドロップですか?いいですよ、ボクはカワイイですからね"
+ empty-drive: "ドライブには何もありません。"
+ empty-drive-description: "右クリックして「ファイルをアップロード」を選んだり、ファイルをドラッグ&ドロップすることでもアップロードできます。"
+ empty-folder: "このフォルダーは空です"
+ unable-to-process: "操作を完了できません"
+ circular-reference-detected: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。"
+ unhandled-error: "不明なエラー"
+ url-upload: "URLアップロード"
+ url-of-file: "アップロードしたいファイルのURL"
+ url-upload-requested: "アップロードをリクエストしました"
+ may-take-time: "アップロードが完了するまで時間がかかる場合があります。"
+ create-folder: "フォルダー作成"
+ folder-name: "フォルダー名"
+ contextmenu:
+ create-folder: "フォルダーを作成"
+ upload: "ファイルをアップロード"
+ url-upload: "URLからアップロード"
+desktop/views/components/messaging-window.vue:
+ title: "メッセージ"
+desktop/views/components/notes.note.vue:
+ reposted-by: "{}がRenote"
+ reply: "返信"
+ renote: "Renote"
+ add-reaction: "リアクション"
+ detail: "詳細"
+desktop/views/components/notifications.vue:
+ more: "もっと見る"
+ empty: "ありません!"
+desktop/views/components/post-form.vue:
+ note-placeholder: "いまどうしてる?"
+ reply-placeholder: "この投稿への返信..."
+ quote-placeholder: "この投稿を引用..."
+ note: "投稿"
+ reply: "返信"
+ renote: "Renote"
+ posted: "投稿しました!"
+ replied: "返信しました!"
+ reposted: "Renoteしました!"
+ note-failed: "投稿に失敗しました"
+ reply-failed: "返信に失敗しました"
+ renote-failed: "Renoteに失敗しました"
+ posting: "投稿中"
+ attach-media-from-local: "PCからメディアを添付"
+ attach-media-from-drive: "ドライブからメディアを添付"
+ attach-cancel: "添付取り消し"
+ insert-a-kao: "v(‘ω’)v"
+ create-poll: "投票を作成"
+ text-remain: "残り{}文字"
+desktop/views/components/post-form-window.vue:
+ note: "新規投稿"
+ reply: "返信"
+ attaches: "添付: {}メディア"
+ uploading-media: "{}個のメディアをアップロード中"
+desktop/views/components/renote-form.vue:
+ quote: "引用する..."
+ cancel: "キャンセル"
+ renote: "Renote"
+ reposting: "しています..."
+ success: "Renoteしました!"
+ failure: "Renoteに失敗しました"
+desktop/views/components/renote-form-window.vue:
+ title: "この投稿をRenoteしますか?"
+desktop/views/components/settings.vue:
+ profile: "プロフィール"
+ notification: "通知"
+ apps: "アプリ"
+ mute: "ミュート"
+ drive: "ドライブ"
+ security: "セキュリティ"
+ password: "パスワード"
+ 2fa: "二段階認証"
+ other: "その他"
+ license: "ライセンス"
+desktop/views/components/settings.2fa.vue:
+ intro: "二段階認証を設定すると、サインイン時にパスワードだけでなく、予め登録しておいた物理的なデバイス(例えばあなたのスマートフォンなど)も必要になり、よりセキュリティが向上します。"
+ detail: "詳細..."
+ url: "https://www.google.co.jp/intl/ja/landing/2step/"
+ caution: "登録したデバイスを紛失するなどした場合、Misskeyにサインインできなくなりますのでご注意ください。"
+ register: "デバイスを登録する"
+ already-registered: "既に設定は完了しています。"
+ unregister: "設定を解除"
+ unregistered: "二段階認証が無効になりました。"
+ enter-password: "パスワードを入力してください"
+ authenticator: "まず、Google Authenticatorをお使いのデバイスにインストールします:"
+ howtoinstall: "インストール方法はこちら"
+ scan: "次に、表示されているQRコードをスキャンします:"
+ done: "お使いのデバイスに表示されているトークンを入力して完了します:"
+ submit: "完了"
+ success: "設定が完了しました!"
+ failed: "設定に失敗しました。トークンに誤りがないかご確認ください。"
+ info: "次回サインインからは、同様にパスワードに加えてデバイスに表示されているトークンを入力します。"
+desktop/views/components/settings.api.vue:
+ intro: "APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。"
+ caution: "アカウントを不正利用される可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。"
+ regeneration-of-token: "万が一このトークンが漏れたりその可能性がある場合はトークンを再生成できます。"
+ regenerate-token: "トークンを再生成"
+ enter-password: "パスワードを入力してください"
+desktop/views/components/settings.app.vue:
+ no-apps: "連携しているアプリケーションはありません"
+desktop/views/components/settings.mute.vue:
+ no-users: "ミュートしているユーザーはいません"
+desktop/views/components/settings.password.vue:
+ reset: "パスワードを変更する"
+ enter-current-password: "現在のパスワードを入力してください"
+ enter-new-password: "新しいパスワードを入力してください"
+ enter-new-password-again: "もう一度新しいパスワードを入力してください"
+ not-match: "新しいパスワードが一致しません"
+ changed: "パスワードを変更しました"
+desktop/views/components/settings.profile.vue:
+ avatar: "アイコン"
+ choice-avatar: "画像を選択"
+ name: "名前"
+ location: "場所"
+ description: "自己紹介"
+ birthday: "誕生日"
+ save: "保存"
+desktop/views/components/ui.header.account.vue:
+ profile: "プロフィール"
+ drive: "ドライブ"
+ favorites: "お気に入り"
+ lists: "リスト"
+ customize: "カスタマイズ"
+ settings: "設定"
+ signout: "サインアウト"
+ dark: "闇に飲まれる"
+desktop/views/components/ui.header.nav.vue:
+ home: "ホーム"
+ messaging: "メッセージ"
+ game: "ゲーム"
+desktop/views/components/ui.header.notifications.vue:
+ title: "通知"
+desktop/views/components/ui.header.post.vue:
+ post: "新規投稿"
+desktop/views/components/ui.header.search.vue:
+ placeholder: "検索"
+desktop/views/pages/note.vue:
+ prev: "前の投稿"
+ next: "次の投稿"
+desktop/views/pages/selectdrive.vue:
+ title: "ファイルを選択してください"
+ ok: "決定"
+ cancel: "キャンセル"
+ upload: "PCからドライブにファイルをアップロード"
+desktop/views/pages/user/user.followers-you-know.vue:
+ title: "知り合いのフォロワー"
+ loading: "読み込み中"
+ no-users: "知り合いのフォロワーはいません"
+desktop/views/pages/user/user.friends.vue:
+ title: "よく話すユーザー"
+ loading: "読み込み中"
+ no-users: "よく話すユーザーはいません"
+desktop/views/pages/user/user.header.vue:
+ is-suspended: "このユーザーは凍結されています。"
+ is-remote: "このユーザーはリモートユーザーです。"
+ view-remote: "正確な情報を見る"
+desktop/views/pages/user/user.home.vue:
+ last-used-at: "最終アクセス"
+desktop/views/pages/user/user.photos.vue:
+ title: "フォト"
+ loading: "読み込み中"
+ no-photos: "写真はありません"
+desktop/views/pages/user/user.profile.vue:
+ follows-you: "フォローされています"
+ stalk: "ストークする"
+ stalking: "ストーキングしています"
+ unstalk: "ストーク解除"
+ mute: "ミュートする"
+ muted: "ミュートしています"
+ unmute: "ミュート解除"
+desktop/views/widgets/messaging.vue:
+ title: "メッセージ"
+desktop/views/widgets/notifications.vue:
+ title: "通知"
+ settings: "通知の設定"
+desktop/views/widgets/polls.vue:
+ title: "投票"
+ refresh: "他を見る"
+ nothing: "ありません!"
+desktop/views/widgets/post-form.vue:
+ title: "投稿"
+ note: "投稿"
+ placeholder: "いまどうしてる?"
+desktop/views/widgets/trends.vue:
+ title: "トレンド"
+ refresh: "他を見る"
+ nothing: "ありません!"
+desktop/views/widgets/users.vue:
+ title: "おすすめユーザー"
+ refresh: "他を見る"
+ no-one: "いません!"
+desktop/views/widgets/channel.vue:
+ title: "チャンネル"
+ settings: "ウィジェットの設定"
+ get-started: "右上の歯車をクリックして受信するチャンネルを指定してください"
+mobile/views/components/drive.vue:
+ drive: "ドライブ"
+ used: "使用中"
+ folder-count: "フォルダ"
+ count-separator: "、"
+ file-count: "ファイル"
+ load-more: "もっと読み込む"
+ nothing-in-drive: "ドライブには何もありません"
+ folder-is-empty: "このフォルダは空です"
+mobile/views/components/drive-file-chooser.vue:
+ select-file: "ファイルを選択"
+mobile/views/components/drive-folder-chooser.vue:
+ select-folder: "フォルダーを選択"
+mobile/views/components/drive.file-detail.vue:
+ download: "ダウンロード"
+ rename: "名前を変更"
+ move: "移動"
+ hash: "ハッシュ (md5)"
+ exif: "EXIF"
+mobile/views/components/follow-button.vue:
+ follow: "フォロー"
+ unfollow: "フォロー解除"
+mobile/views/components/note.vue:
+ reposted-by: "{}がRenote"
+mobile/views/components/note-detail.vue:
+ reply: "返信"
+ reaction: "リアクション"
+mobile/views/components/notifications.vue:
+ more: "もっと見る"
+ empty: "ありません!"
+mobile/views/components/post-form.vue:
+ submit: "投稿"
+ reply-placeholder: "この投稿への返信..."
+ note-placeholder: "いまどうしてる?"
+mobile/views/components/sub-note-content.vue:
+ media-count: "{}個のメディア"
+ poll: "投票"
+mobile/views/components/timeline.vue:
+ empty: "投稿がありません"
+ load-more: "もっと"
+mobile/views/components/ui.nav.vue:
+ home: "ホーム"
+ notifications: "通知"
+ messaging: "メッセージ"
+ search: "検索"
+ drive: "ドライブ"
+ settings: "設定"
+ about: "Misskeyについて"
+mobile/views/components/user-timeline.vue:
+ no-notes: "このユーザーは投稿していないようです。"
+ no-notes-with-media: "メディア付き投稿はありません。"
+ load-more: "もっと"
+mobile/views/components/users-list.vue:
+ all: "すべて"
+ known: "知り合い"
+ load-more: "もっと"
+mobile/views/pages/drive.vue:
+ drive: "ドライブ"
+mobile/views/pages/followers.vue:
+ followers-of: "{}のフォロワー"
+mobile/views/pages/following.vue:
+ following-of: "{}のフォロー"
+mobile/views/pages/home.vue:
+ timeline: "タイムライン"
+mobile/views/pages/messaging.vue:
+ messaging: "メッセージ"
+mobile/views/pages/messaging-room.vue:
+ messaging: "メッセージ"
+mobile/views/pages/note.vue:
+ title: "投稿"
+ prev: "前の投稿"
+ next: "次の投稿"
+mobile/views/pages/notifications.vue:
+ notifications: "通知"
+ read-all: "すべての通知を既読にしますか?"
+mobile/views/pages/profile-setting.vue:
+ title: "プロフィール設定"
+ will-be-published: "これらのプロフィールは公開されます。"
+ name: "名前"
+ location: "場所"
+ description: "自己紹介"
+ birthday: "誕生日"
+ avatar: "アイコン"
+ banner: "バナー"
+ avatar-saved: "アイコンを保存しました"
+ banner-saved: "バナーを保存しました"
+ set-avatar: "アイコンを選択する"
+ set-banner: "バナーを選択する"
+ save: "保存"
+ saved: "プロフィールを保存しました"
+mobile/views/pages/search.vue:
+ search: "検索"
+ empty: "「{}」に関する投稿は見つかりませんでした。"
+mobile/views/pages/selectdrive.vue:
+ select-file: "ファイルを選択"
+mobile/views/pages/settings.vue:
+ signed-in-as: "{}としてサインイン中"
+ profile: "プロフィール"
+ twitter: "Twitter連携"
+ signin-history: "サインイン履歴"
+ settings: "設定"
+ signout: "サインアウト"
+mobile/views/pages/user.vue:
+ follows-you: "フォローされています"
+ following: "フォロー"
+ followers: "フォロワー"
+ notes: "投稿"
+ overview: "概要"
+ timeline: "タイムライン"
+ media: "メディア"
+ is-suspended: "このユーザーは凍結されています。"
+ is-remote: "このユーザーはリモートユーザーです。"
+ view-remote: "正確な情報を見る"
+mobile/views/pages/user/home.vue:
+ recent-notes: "最近の投稿"
+ images: "画像"
+ activity: "アクティビティ"
+ keywords: "キーワード"
+ domains: "頻出ドメイン"
+ frequently-replied-users: "よく会話するユーザー"
+ followers-you-know: "知り合いのフォロワー"
+ last-used-at: "最終ログイン"
+mobile/views/pages/user/home.followers-you-know.vue:
+ loading: "読み込み中"
+ no-users: "知り合いのユーザーはいません"
+mobile/views/pages/user/home.friends.vue:
+ loading: "読み込み中"
+ no-users: "よく会話するユーザーはいません"
+mobile/views/pages/user/home.notes.vue:
+ loading: "読み込み中"
+ no-notes: "投稿はありません"
+mobile/views/pages/user/home.photos.vue:
+ loading: "読み込み中"
+ no-photos: "写真はありません"
+docs:
+ edit-this-page-on-github: "間違いや改善点を見つけましたか?"
+ edit-this-page-on-github-link: "このページをGitHubで編集"
+ api:
+ entities:
+ properties: "プロパティ"
+ endpoints:
+ params: "パラメータ"
+ res: "レスポンス"
+ props:
+ name: "名前"
+ type: "型"
+ optional: "オプション"
+ description: "説明"
+ yes: "はい"
+ no: "いいえ"
diff --git a/locales/en.yml b/locales/en.yml
index 446ef371be..78571918d3 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -1,6 +1,6 @@
+---
common:
misskey: "Share everything with others using Misskey."
-
time:
unknown: "unknown"
future: "future"
@@ -12,7 +12,6 @@ common:
weeks_ago: "{}week(s) ago"
months_ago: "{}month(s) ago"
years_ago: "{}year(s) ago"
-
weekday-short:
sunday: "S"
monday: "M"
@@ -21,7 +20,6 @@ common:
thursday: "T"
friday: "F"
satruday: "S"
-
reactions:
like: "Like"
love: "Love"
@@ -32,19 +30,16 @@ common:
angry: "Angry"
confused: "Confused"
pudding: "Pudding"
-
delete: "Delete"
loading: "Loading"
ok: "OK"
update-available: "A new version of Misskey is now available({newer}, current is {current}). Reload the page to apply the update."
my-token-regenerated: "Your token has been generated. You will now get logged out."
-
common/views/components/connect-failed.vue:
title: "Unable to connect to the server"
description: "There is a problem either with your internet connection, or the server may be down or under maintenance. Please {try again} later."
thanks: "Thank you for using Misskey."
troubleshoot: "Troubleshoot"
-
common/views/components/connect-failed.troubleshooter.vue:
title: "Troubleshooting"
network: "Network connection"
@@ -64,29 +59,24 @@ common/views/components/connect-failed.troubleshooter.vue:
success-desc: "It seems to be able to connect. Please reload the page."
flush: "Clean cache"
set-version: "Specify version"
-
common/views/components/messaging.vue:
search-user: "Find an user"
you: "You"
no-history: "No history"
-
common/views/components/messaging-room.vue:
empty: "No conversations"
more: "More"
no-history: "There is no more history"
resize-form: "Drag to resize"
new-message: "New message"
-
common/views/components/messaging-room.form.vue:
input-message-here: "Enter message here"
send: "Send"
attach-from-local: "Attach files from your pc"
attach-from-drive: "Attach files from your Drive"
-
common/views/components/messaging-room.message.vue:
is-read: "Read"
deleted: "This message has been deleted"
-
common/views/components/nav.vue:
about: "About"
stats: "Stats"
@@ -95,10 +85,11 @@ common/views/components/nav.vue:
donors: "Donators"
repository: "Repository"
develop: "Developers"
-
+ feedback: "Feedback"
common/views/components/note-menu.vue:
+ favorite: "Favorite this note"
pin: "Pin to profile page"
-
+ remote: "Show on origin"
common/views/components/poll.vue:
vote-to: "Vote for '{}'"
vote-count: "{} votes"
@@ -106,24 +97,20 @@ common/views/components/poll.vue:
vote: "Vote"
show-result: "Show results"
voted: "Voted"
-
common/views/components/poll-editor.vue:
no-only-one-choice: "You need to enter two or more choices."
choice-n: "Choice {}"
remove: "Remove this choice"
add: "+ Add a choice"
destroy: "Destroy this poll"
-
common/views/components/reaction-picker.vue:
choose-reaction: "Choose a reaction"
-
common/views/components/signin.vue:
username: "Username"
password: "Password"
token: "Token"
signing-in: "Signing in..."
signin: "Sign in"
-
common/views/components/signup.vue:
username: "Username"
checking: "Checking..."
@@ -145,16 +132,13 @@ common/views/components/signup.vue:
recaptcha: "Verify"
create: "Create an Account"
some-error: "Account creation failed for some reason. Please try again."
-
common/views/components/special-message.vue:
new-year: "Happy New Year!"
christmas: "Merry Christmas!"
-
common/views/components/stream-indicator.vue:
connecting: "Connecting"
reconnecting: "Reconnecting"
connected: "Connected"
-
common/views/components/twitter-setting.vue:
description: "If you connect your Twitter account to your Misskey account, you will be able to see your Twitter account information on your profile and you can sign-in using Twitter."
connected-to: "You are connected to this Twitter account"
@@ -162,42 +146,33 @@ common/views/components/twitter-setting.vue:
reconnect: "Reconnect"
connect: "Link your twitter account"
disconnect: "Disconnect"
-
common/views/components/uploader.vue:
waiting: "Waiting"
-
common/views/widgets/broadcast.vue:
fetching: "Fetching"
no-broadcasts: "No broadcasts"
have-a-nice-day: "Have a nice day!"
next: "Next"
-
common/views/widgets/donation.vue:
title: "Donation"
text: "To keep Misskey up and running we spend money for our domain name, servers and so on.. We don't get any money from it, and we would really appreciate it if you could donate. If you're interested contact {}. Thank you for your contribution!"
-
common/views/widgets/photo-stream.vue:
title: "Photostream"
no-photos: "No photos"
-
common/views/widgets/server.vue:
title: "Server info"
toggle: "Toggle views"
-
desktop/views/components/activity.vue:
title: "Activity"
toggle: "Toggle views"
-
desktop/views/components/calendar.vue:
title: "{1} / {2}"
prev: "Previous month"
next: "Next month"
go: "Click to naviguate"
-
desktop/views/components/drive-window.vue:
used: "used"
drive: "Drive"
-
desktop/views/components/drive.file.vue:
avatar: "Avatar"
banner: "Banner"
@@ -214,7 +189,6 @@ desktop/views/components/drive.file.vue:
input-new-file-name: "Enter new name"
copied: "Copied"
copied-url-to-clipboard: "Copied URL to clipboard"
-
desktop/views/components/drive.folder.vue:
unable-to-process: "The operation could not be completed."
circular-reference-detected: "The destination folder is a subfolder of the folder you wish to move."
@@ -225,10 +199,8 @@ desktop/views/components/drive.folder.vue:
rename: "Rename"
rename-folder: "Rename folder"
input-new-folder-name: "Enter new name"
-
desktop/views/components/drive.nav-folder.vue:
drive: "Drive"
-
desktop/views/components/drive.vue:
search: "Search"
load-more: "Load more"
@@ -249,21 +221,17 @@ desktop/views/components/drive.vue:
create-folder: "Create a folder"
upload: "Upload a file"
url-upload: "Upload from a URL"
-
desktop/views/components/messaging-window.vue:
title: "Messaging"
-
desktop/views/components/notes.note.vue:
reposted-by: "Reposted by {}"
reply: "Reply"
renote: "Renote"
add-reaction: "Add a reaction"
detail: "Show detail"
-
desktop/views/components/notifications.vue:
more: "More"
empty: "No notifications"
-
desktop/views/components/post-form.vue:
note-placeholder: "What's happening?"
reply-placeholder: "Reply to this note..."
@@ -284,13 +252,11 @@ desktop/views/components/post-form.vue:
insert-a-kao: "v(‘ω’)v"
create-poll: "Create a poll"
text-remain: "{} chars remaining"
-
desktop/views/components/post-form-window.vue:
note: "New note"
reply: "Reply"
attaches: "{} media attached"
uploading-media: "Uploading {} media"
-
desktop/views/components/renote-form.vue:
quote: "Quote..."
cancel: "Cancel"
@@ -298,10 +264,8 @@ desktop/views/components/renote-form.vue:
reposting: "Reposting..."
success: "Reposted!"
failure: "Failed to Renote"
-
desktop/views/components/renote-form-window.vue:
title: "Are you sure you want to renote this note?"
-
desktop/views/components/settings.vue:
profile: "Profile"
notification: "Notification"
@@ -313,7 +277,6 @@ desktop/views/components/settings.vue:
2fa: "Two-factor authentication"
other: "Other"
license: "License"
-
desktop/views/components/settings.2fa.vue:
intro: "If you set up 2-step verification, you will need not only a password at sign-in but also a pre-registered physical device (such as your smartphone), which will improve security. "
detail: "See details..."
@@ -332,20 +295,16 @@ desktop/views/components/settings.2fa.vue:
success: "Setup completed successfully!"
failed: "Failed to setup. please ensure that the token is correct."
info: "From now on, enter the token that is displayed on your device in addition to your password when signing-in to Misskey."
-
desktop/views/components/settings.api.vue:
intro: "APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。"
caution: "Please do not show this token to third parties (do not enter it somewhere else other than here) otherwise your account could get compromised."
regeneration-of-token: "In the unlikely event that this token leaks out you can regenerate it."
regenerate-token: "Regenerate the token"
enter-password: "Please enter the password"
-
desktop/views/components/settings.app.vue:
no-apps: "No authorized apps"
-
desktop/views/components/settings.mute.vue:
no-users: "No muted users"
-
desktop/views/components/settings.password.vue:
reset: "Change your password"
enter-current-password: "Enter the current password"
@@ -353,7 +312,6 @@ desktop/views/components/settings.password.vue:
enter-new-password-again: "Enter the new password again"
not-match: "New password doesn't match"
changed: "Password updated successfully"
-
desktop/views/components/settings.profile.vue:
avatar: "Avatar"
choice-avatar: "Choose an image"
@@ -362,99 +320,89 @@ desktop/views/components/settings.profile.vue:
description: "Description"
birthday: "Birthday"
save: "Update profile"
-
+desktop/views/components/timeline.vue:
+ home: "Home"
+ local: "Local"
+ global: "Global"
+ list: "List"
desktop/views/components/ui.header.account.vue:
profile: "Your profile"
drive: "Drive"
- mentions: "Mentions"
+ favorites: "Favorites"
+ lists: "Lists"
customize: "Customize"
settings: "Settings"
signout: "Sign out"
-
+ dark: "Fall in dark"
desktop/views/components/ui.header.nav.vue:
home: "Home"
messaging: "Messages"
game: "Game"
-
desktop/views/components/ui.header.notifications.vue:
title: "Notifications"
-
desktop/views/components/ui.header.post.vue:
post: "Compose new Post"
-
desktop/views/components/ui.header.search.vue:
placeholder: "Search"
-
desktop/views/pages/note.vue:
prev: "Previous note"
next: "Next note"
-
desktop/views/pages/selectdrive.vue:
title: "Choose file(s)"
ok: "OK"
cancel: "Cancel"
upload: "Upload files from you PC"
-
desktop/views/pages/user/user.followers-you-know.vue:
title: "Followers you know"
loading: "Loading"
no-users: "No users"
-
desktop/views/pages/user/user.friends.vue:
title: "Frequently replied"
loading: "Loading"
no-users: "No users"
-
desktop/views/pages/user/user.header.vue:
- is-remote: "This user is not a user of Misskey, so the information is not accurate."
+ is-suspended: "This account has been suspended."
+ is-remote: "This user is a remote user, so the information is not accurate. "
view-remote: "See accurate information"
-
desktop/views/pages/user/user.home.vue:
last-used-at: "Last active: "
-
desktop/views/pages/user/user.photos.vue:
title: "Photos"
loading: "Loading"
no-photos: "No photos"
-
desktop/views/pages/user/user.profile.vue:
follows-you: "Follows you"
+ stalk: "Stalk"
+ stalking: "Stalking"
+ unstalk: "Unstalk"
mute: "Mute"
muted: "Muting"
unmute: "Unmute"
-
desktop/views/widgets/messaging.vue:
title: "Messaging"
-
desktop/views/widgets/notifications.vue:
title: "Notifications"
settings: "Settings"
-
desktop/views/widgets/polls.vue:
title: "Polls"
refresh: "Show others"
nothing: "Nothing"
-
desktop/views/widgets/post-form.vue:
title: "Post"
note: "Post"
placeholder: "What's happening?"
-
desktop/views/widgets/trends.vue:
title: "Trend"
refresh: "Show others"
nothing: "Nothing"
-
desktop/views/widgets/users.vue:
title: "Recommended users"
refresh: "Show others"
no-one: "No one"
-
desktop/views/widgets/channel.vue:
title: "Channel"
settings: "Widget settings"
get-started: "Please click the cog in the upper right corner to specify a channel"
-
mobile/views/components/drive.vue:
drive: "Drive"
used: "used"
@@ -464,94 +412,72 @@ mobile/views/components/drive.vue:
load-more: "Load more"
nothing-in-drive: "Nothing"
folder-is-empty: "This folder is empty"
-
mobile/views/components/drive-file-chooser.vue:
select-file: "Choose a file"
-
mobile/views/components/drive-folder-chooser.vue:
select-folder: "Choose a folder"
-
mobile/views/components/drive.file-detail.vue:
download: "Download"
rename: "Rename"
move: "Move"
hash: "Hash (md5)"
exif: "EXIF"
-
mobile/views/components/follow-button.vue:
follow: "Follow"
unfollow: "Unfollow"
-
mobile/views/components/note.vue:
reposted-by: "Renoted by {}"
-
mobile/views/components/note-detail.vue:
reply: "Reply"
reaction: "Reaction"
-
mobile/views/components/notifications.vue:
more: "More"
empty: "No notifications"
-
mobile/views/components/post-form.vue:
submit: "Post"
reply-placeholder: "Reply to this note..."
note-placeholder: "What's happening?"
-
mobile/views/components/sub-note-content.vue:
media-count: "{} media"
poll: "Poll"
-
mobile/views/components/timeline.vue:
empty: "No notes"
load-more: "More"
-
mobile/views/components/ui.nav.vue:
home: "Home"
notifications: "Notifications"
messaging: "Messages"
+ search: "Search"
drive: "Drive"
settings: "Settings"
about: "About Misskey"
- search: "Search"
-
mobile/views/components/user-timeline.vue:
no-notes: "It seems this user hasn't posted yet"
no-notes-with-media: "There is no notes with attached media"
load-more: "More"
-
mobile/views/components/users-list.vue:
all: "All"
known: "You know"
load-more: "More"
-
mobile/views/pages/drive.vue:
drive: "Drive"
-
mobile/views/pages/followers.vue:
followers-of: "Followers of {}"
-
mobile/views/pages/following.vue:
following-of: "Following of {}"
-
mobile/views/pages/home.vue:
timeline: "Timeline"
-
mobile/views/pages/messaging.vue:
messaging: "Messaging"
-
mobile/views/pages/messaging-room.vue:
messaging: "Messaging"
-
mobile/views/pages/note.vue:
title: "Post"
prev: "Previous note"
next: "Next note"
-
mobile/views/pages/notifications.vue:
notifications: "Notifications"
read-all: "Are you sure you want to mark all unread notifications as read?"
-
mobile/views/pages/profile-setting.vue:
title: "Profile settings"
will-be-published: "These profile settings will be updated."
@@ -567,22 +493,18 @@ mobile/views/pages/profile-setting.vue:
set-banner: "Choose a banner"
save: "Save"
saved: "Profile updated successfully"
-
mobile/views/pages/search.vue:
search: "Search"
empty: "No posts were found for '{}'"
-
mobile/views/pages/selectdrive.vue:
select-file: "Choose a file"
-
mobile/views/pages/settings.vue:
signed-in-as: "Signed in as {}"
profile: "Profile"
- twitter-integration: "Twitter integration"
+ twitter: "Twitter integration"
signin-history: "Sign in history"
settings: "Settings"
signout: "Sign out"
-
mobile/views/pages/user.vue:
follows-you: "Follows you"
following: "Following"
@@ -591,9 +513,9 @@ mobile/views/pages/user.vue:
overview: "Overview"
timeline: "Timeline"
media: "Media"
- is-remote: "This user is not a user of Misskey, so the information might not be accurate."
+ is-suspended: "This account has been suspended."
+ is-remote: "This user is a remote user, so the information is not accurate. "
view-remote: "See accurate information"
-
mobile/views/pages/user/home.vue:
recent-notes: "Recent notes"
images: "Images"
@@ -603,27 +525,21 @@ mobile/views/pages/user/home.vue:
frequently-replied-users: "Frequently talking users"
followers-you-know: "Followers you know"
last-used-at: "Last active:"
-
mobile/views/pages/user/home.followers-you-know.vue:
loading: "Loading"
no-users: "No users"
-
mobile/views/pages/user/home.friends.vue:
loading: "Loading"
no-users: "No users"
-
mobile/views/pages/user/home.notes.vue:
loading: "Loading"
no-notes: "No notes"
-
mobile/views/pages/user/home.photos.vue:
loading: "Loading"
no-photos: "No photos"
-
docs:
edit-this-page-on-github: "Did you find an error or do you want to contribute to the documentation? "
edit-this-page-on-github-link: "Edit this page on Github!"
-
api:
entities:
properties: "Properties"
diff --git a/locales/es.yml b/locales/es.yml
new file mode 100644
index 0000000000..b6e7898a8e
--- /dev/null
+++ b/locales/es.yml
@@ -0,0 +1,550 @@
+---
+common:
+ misskey: "Misskeyで皆と共有しよう。"
+ time:
+ unknown: "なぞのじかん"
+ future: "未来"
+ just_now: "たった今"
+ seconds_ago: "{}秒前"
+ minutes_ago: "{}分前"
+ hours_ago: "{}時間前"
+ days_ago: "{}日前"
+ weeks_ago: "{}週間前"
+ months_ago: "{}ヶ月前"
+ years_ago: "{}年前"
+ weekday-short:
+ sunday: "日"
+ monday: "月"
+ tuesday: "火"
+ wednesday: "水"
+ thursday: "木"
+ friday: "金"
+ satruday: "土"
+ reactions:
+ like: "いいね"
+ love: "しゅき"
+ laugh: "笑"
+ hmm: "ふぅ~む"
+ surprise: "わお"
+ congrats: "おめでとう"
+ angry: "おこ"
+ confused: "こまこまのこまり"
+ pudding: "Pudding"
+ delete: "削除"
+ loading: "読み込み中"
+ ok: "わかった"
+ update-available: "Misskeyの新しいバージョンがあります({newer}。現在{current}を利用中)。ページを再度読み込みすると更新が適用されます。"
+ my-token-regenerated: "あなたのトークンが更新されたのでサインアウトします。"
+common/views/components/connect-failed.vue:
+ title: "サーバーに接続できません"
+ description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから{再度お試し}ください。"
+ thanks: "いつもMisskeyをご利用いただきありがとうございます。"
+ troubleshoot: "トラブルシュート"
+common/views/components/connect-failed.troubleshooter.vue:
+ title: "トラブルシューティング"
+ network: "ネットワーク接続"
+ checking-network: "ネットワーク接続を確認中"
+ internet: "インターネット接続"
+ checking-internet: "インターネット接続を確認中"
+ server: "サーバー接続"
+ checking-server: "サーバー接続を確認中"
+ finding: "問題を調べています"
+ no-network: "ネットワークに接続されていません"
+ no-network-desc: "お使いのPCのネットワーク接続が正常か確認してください。"
+ no-internet: "インターネットに接続されていません"
+ no-internet-desc: "ネットワークには接続されていますが、インターネットには接続されていないようです。お使いのPCのインターネット接続が正常か確認してください。"
+ no-server: "Misskeyのサーバーに接続できません"
+ no-server-desc: "お使いのPCのインターネット接続は正常ですが、Misskeyのサーバーには接続できませんでした。サーバーがダウンまたはメンテナンスしている可能性があるので、しばらくしてから再度御アクセスください。"
+ success: "Misskeyのサーバーに接続できました"
+ success-desc: "正常に接続できるようです。ページを再度読み込みしてください。"
+ flush: "キャッシュの削除"
+ set-version: "バージョン指定"
+common/views/components/messaging.vue:
+ search-user: "ユーザーを探す"
+ you: "あなた"
+ no-history: "履歴はありません"
+common/views/components/messaging-room.vue:
+ empty: "このユーザーと話したことはありません"
+ more: "もっと読む"
+ no-history: "これより過去の履歴はありません"
+ resize-form: "ドラッグしてフォームの広さを調整"
+ new-message: "新しいメッセージがあります"
+common/views/components/messaging-room.form.vue:
+ input-message-here: "ここにメッセージを入力"
+ send: "送信"
+ attach-from-local: "PCからファイルを添付する"
+ attach-from-drive: "ドライブからファイルを添付する"
+common/views/components/messaging-room.message.vue:
+ is-read: "既読"
+ deleted: "このメッセージは削除されました"
+common/views/components/nav.vue:
+ about: "Misskeyについて"
+ stats: "統計"
+ status: "ステータス"
+ wiki: "Wiki"
+ donors: "ドナー"
+ repository: "リポジトリ"
+ develop: "開発者"
+ feedback: "フィードバック"
+common/views/components/note-menu.vue:
+ favorite: "お気に入り"
+ pin: "ピン留め"
+ remote: "投稿元で見る"
+common/views/components/poll.vue:
+ vote-to: "「{}」に投票する"
+ vote-count: "{}票"
+ total-users: "{}人が投票"
+ vote: "投票する"
+ show-result: "結果を見る"
+ voted: "投票済み"
+common/views/components/poll-editor.vue:
+ no-only-one-choice: "投票には、選択肢が最低2つ必要です"
+ choice-n: "選択肢{}"
+ remove: "この選択肢を削除"
+ add: "+選択肢を追加"
+ destroy: "投票を破棄"
+common/views/components/reaction-picker.vue:
+ choose-reaction: "リアクションを選択"
+common/views/components/signin.vue:
+ username: "ユーザー名"
+ password: "パスワード"
+ token: "トークン"
+ signing-in: "やってます..."
+ signin: "サインイン"
+common/views/components/signup.vue:
+ username: "ユーザー名"
+ checking: "確認しています..."
+ available: "利用できます"
+ unavailable: "既に利用されています"
+ error: "通信エラー"
+ invalid-format: "a~z、A~Z、0~9、_が使えます"
+ too-short: "1文字以上でお願いします!"
+ too-long: "20文字以内でお願いします"
+ password: "パスワード"
+ password-placeholder: "8文字以上を推奨します"
+ weak-password: "弱いパスワード"
+ normal-password: "まあまあのパスワード"
+ strong-password: "強いパスワード"
+ retype: "再入力"
+ retype-placeholder: "確認のため再入力してください"
+ password-matched: "確認されました"
+ password-not-matched: "一致していません"
+ recaptcha: "認証"
+ create: "アカウント作成"
+ some-error: "何らかの原因によりアカウントの作成に失敗しました。再度お試しください。"
+common/views/components/special-message.vue:
+ new-year: "Happy New Year!"
+ christmas: "Merry Christmas!"
+common/views/components/stream-indicator.vue:
+ connecting: "接続中"
+ reconnecting: "再接続中"
+ connected: "接続完了"
+common/views/components/twitter-setting.vue:
+ description: "お使いのTwitterアカウントをお使いのMisskeyアカウントに接続しておくと、プロフィールでTwitterアカウント情報が表示されるようになったり、Twitterを用いた便利なサインインを利用できるようになります。"
+ connected-to: "次のTwitterアカウントに接続されています"
+ detail: "詳細..."
+ reconnect: "再接続する"
+ connect: "Twitterと接続する"
+ disconnect: "切断する"
+common/views/components/uploader.vue:
+ waiting: "待機中"
+common/views/widgets/broadcast.vue:
+ fetching: "確認中"
+ no-broadcasts: "お知らせはありません"
+ have-a-nice-day: "良い一日を!"
+ next: "次"
+common/views/widgets/donation.vue:
+ title: "寄付のお願い"
+ text: "Misskeyの運営にはドメイン、サーバー等のコストが掛かります。Misskeyは広告を掲載したりしないため、収入を皆様からの寄付に頼っています。もしご興味があれば、{}までご連絡ください。ご協力ありがとうございます。"
+common/views/widgets/photo-stream.vue:
+ title: "フォトストリーム"
+ no-photos: "写真はありません"
+common/views/widgets/server.vue:
+ title: "サーバー情報"
+ toggle: "表示を切り替え"
+desktop/views/components/activity.vue:
+ title: "アクティビティ"
+ toggle: "表示を切り替え"
+desktop/views/components/calendar.vue:
+ title: "{1}年 {2}月"
+ prev: "前の月"
+ next: "次の月"
+ go: "クリックして時間遡行"
+desktop/views/components/drive-window.vue:
+ used: "使用中"
+ drive: "ドライブ"
+desktop/views/components/drive.file.vue:
+ avatar: "アイコン"
+ banner: "バナー"
+ contextmenu:
+ rename: "名前を変更"
+ copy-url: "URLをコピー"
+ download: "ダウンロード"
+ else-files: "その他..."
+ set-as-avatar: "アイコンに設定"
+ set-as-banner: "バナーに設定"
+ open-in-app: "アプリで開く"
+ add-app: "アプリを追加"
+ rename-file: "ファイル名の変更"
+ input-new-file-name: "新しいファイル名を入力してください"
+ copied: "コピー完了"
+ copied-url-to-clipboard: "URLをクリップボードにコピーしました"
+desktop/views/components/drive.folder.vue:
+ unable-to-process: "操作を完了できません"
+ circular-reference-detected: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。"
+ unhandled-error: "不明なエラー"
+ contextmenu:
+ move-to-this-folder: "このフォルダへ移動"
+ show-in-new-window: "新しいウィンドウで表示"
+ rename: "名前を変更"
+ rename-folder: "フォルダ名の変更"
+ input-new-folder-name: "新しいフォルダ名を入力してください"
+desktop/views/components/drive.nav-folder.vue:
+ drive: "ドライブ"
+desktop/views/components/drive.vue:
+ search: "検索"
+ load-more: "もっと読み込む"
+ empty-draghover: "ドロップですか?いいですよ、ボクはカワイイですからね"
+ empty-drive: "ドライブには何もありません。"
+ empty-drive-description: "右クリックして「ファイルをアップロード」を選んだり、ファイルをドラッグ&ドロップすることでもアップロードできます。"
+ empty-folder: "このフォルダーは空です"
+ unable-to-process: "操作を完了できません"
+ circular-reference-detected: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。"
+ unhandled-error: "不明なエラー"
+ url-upload: "URLアップロード"
+ url-of-file: "アップロードしたいファイルのURL"
+ url-upload-requested: "アップロードをリクエストしました"
+ may-take-time: "アップロードが完了するまで時間がかかる場合があります。"
+ create-folder: "フォルダー作成"
+ folder-name: "フォルダー名"
+ contextmenu:
+ create-folder: "フォルダーを作成"
+ upload: "ファイルをアップロード"
+ url-upload: "URLからアップロード"
+desktop/views/components/messaging-window.vue:
+ title: "メッセージ"
+desktop/views/components/notes.note.vue:
+ reposted-by: "{}がRenote"
+ reply: "返信"
+ renote: "Renote"
+ add-reaction: "リアクション"
+ detail: "詳細"
+desktop/views/components/notifications.vue:
+ more: "もっと見る"
+ empty: "ありません!"
+desktop/views/components/post-form.vue:
+ note-placeholder: "いまどうしてる?"
+ reply-placeholder: "この投稿への返信..."
+ quote-placeholder: "この投稿を引用..."
+ note: "投稿"
+ reply: "返信"
+ renote: "Renote"
+ posted: "投稿しました!"
+ replied: "返信しました!"
+ reposted: "Renoteしました!"
+ note-failed: "投稿に失敗しました"
+ reply-failed: "返信に失敗しました"
+ renote-failed: "Renoteに失敗しました"
+ posting: "投稿中"
+ attach-media-from-local: "PCからメディアを添付"
+ attach-media-from-drive: "ドライブからメディアを添付"
+ attach-cancel: "添付取り消し"
+ insert-a-kao: "v(‘ω’)v"
+ create-poll: "投票を作成"
+ text-remain: "残り{}文字"
+desktop/views/components/post-form-window.vue:
+ note: "新規投稿"
+ reply: "返信"
+ attaches: "添付: {}メディア"
+ uploading-media: "{}個のメディアをアップロード中"
+desktop/views/components/renote-form.vue:
+ quote: "引用する..."
+ cancel: "キャンセル"
+ renote: "Renote"
+ reposting: "しています..."
+ success: "Renoteしました!"
+ failure: "Renoteに失敗しました"
+desktop/views/components/renote-form-window.vue:
+ title: "この投稿をRenoteしますか?"
+desktop/views/components/settings.vue:
+ profile: "プロフィール"
+ notification: "通知"
+ apps: "アプリ"
+ mute: "ミュート"
+ drive: "ドライブ"
+ security: "セキュリティ"
+ password: "パスワード"
+ 2fa: "二段階認証"
+ other: "その他"
+ license: "ライセンス"
+desktop/views/components/settings.2fa.vue:
+ intro: "二段階認証を設定すると、サインイン時にパスワードだけでなく、予め登録しておいた物理的なデバイス(例えばあなたのスマートフォンなど)も必要になり、よりセキュリティが向上します。"
+ detail: "詳細..."
+ url: "https://www.google.co.jp/intl/ja/landing/2step/"
+ caution: "登録したデバイスを紛失するなどした場合、Misskeyにサインインできなくなりますのでご注意ください。"
+ register: "デバイスを登録する"
+ already-registered: "既に設定は完了しています。"
+ unregister: "設定を解除"
+ unregistered: "二段階認証が無効になりました。"
+ enter-password: "パスワードを入力してください"
+ authenticator: "まず、Google Authenticatorをお使いのデバイスにインストールします:"
+ howtoinstall: "インストール方法はこちら"
+ scan: "次に、表示されているQRコードをスキャンします:"
+ done: "お使いのデバイスに表示されているトークンを入力して完了します:"
+ submit: "完了"
+ success: "設定が完了しました!"
+ failed: "設定に失敗しました。トークンに誤りがないかご確認ください。"
+ info: "次回サインインからは、同様にパスワードに加えてデバイスに表示されているトークンを入力します。"
+desktop/views/components/settings.api.vue:
+ intro: "APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。"
+ caution: "アカウントを不正利用される可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。"
+ regeneration-of-token: "万が一このトークンが漏れたりその可能性がある場合はトークンを再生成できます。"
+ regenerate-token: "トークンを再生成"
+ enter-password: "パスワードを入力してください"
+desktop/views/components/settings.app.vue:
+ no-apps: "連携しているアプリケーションはありません"
+desktop/views/components/settings.mute.vue:
+ no-users: "ミュートしているユーザーはいません"
+desktop/views/components/settings.password.vue:
+ reset: "パスワードを変更する"
+ enter-current-password: "現在のパスワードを入力してください"
+ enter-new-password: "新しいパスワードを入力してください"
+ enter-new-password-again: "もう一度新しいパスワードを入力してください"
+ not-match: "新しいパスワードが一致しません"
+ changed: "パスワードを変更しました"
+desktop/views/components/settings.profile.vue:
+ avatar: "アイコン"
+ choice-avatar: "画像を選択"
+ name: "名前"
+ location: "場所"
+ description: "自己紹介"
+ birthday: "誕生日"
+ save: "保存"
+desktop/views/components/ui.header.account.vue:
+ profile: "プロフィール"
+ drive: "ドライブ"
+ favorites: "お気に入り"
+ lists: "リスト"
+ customize: "カスタマイズ"
+ settings: "設定"
+ signout: "サインアウト"
+ dark: "闇に飲まれる"
+desktop/views/components/ui.header.nav.vue:
+ home: "ホーム"
+ messaging: "メッセージ"
+ game: "ゲーム"
+desktop/views/components/ui.header.notifications.vue:
+ title: "通知"
+desktop/views/components/ui.header.post.vue:
+ post: "新規投稿"
+desktop/views/components/ui.header.search.vue:
+ placeholder: "検索"
+desktop/views/pages/note.vue:
+ prev: "前の投稿"
+ next: "次の投稿"
+desktop/views/pages/selectdrive.vue:
+ title: "ファイルを選択してください"
+ ok: "決定"
+ cancel: "キャンセル"
+ upload: "PCからドライブにファイルをアップロード"
+desktop/views/pages/user/user.followers-you-know.vue:
+ title: "知り合いのフォロワー"
+ loading: "読み込み中"
+ no-users: "知り合いのフォロワーはいません"
+desktop/views/pages/user/user.friends.vue:
+ title: "よく話すユーザー"
+ loading: "読み込み中"
+ no-users: "よく話すユーザーはいません"
+desktop/views/pages/user/user.header.vue:
+ is-suspended: "このユーザーは凍結されています。"
+ is-remote: "このユーザーはリモートユーザーです。"
+ view-remote: "正確な情報を見る"
+desktop/views/pages/user/user.home.vue:
+ last-used-at: "最終アクセス"
+desktop/views/pages/user/user.photos.vue:
+ title: "フォト"
+ loading: "読み込み中"
+ no-photos: "写真はありません"
+desktop/views/pages/user/user.profile.vue:
+ follows-you: "フォローされています"
+ stalk: "ストークする"
+ stalking: "ストーキングしています"
+ unstalk: "ストーク解除"
+ mute: "ミュートする"
+ muted: "ミュートしています"
+ unmute: "ミュート解除"
+desktop/views/widgets/messaging.vue:
+ title: "メッセージ"
+desktop/views/widgets/notifications.vue:
+ title: "通知"
+ settings: "通知の設定"
+desktop/views/widgets/polls.vue:
+ title: "投票"
+ refresh: "他を見る"
+ nothing: "ありません!"
+desktop/views/widgets/post-form.vue:
+ title: "投稿"
+ note: "投稿"
+ placeholder: "いまどうしてる?"
+desktop/views/widgets/trends.vue:
+ title: "トレンド"
+ refresh: "他を見る"
+ nothing: "ありません!"
+desktop/views/widgets/users.vue:
+ title: "おすすめユーザー"
+ refresh: "他を見る"
+ no-one: "いません!"
+desktop/views/widgets/channel.vue:
+ title: "チャンネル"
+ settings: "ウィジェットの設定"
+ get-started: "右上の歯車をクリックして受信するチャンネルを指定してください"
+mobile/views/components/drive.vue:
+ drive: "ドライブ"
+ used: "使用中"
+ folder-count: "フォルダ"
+ count-separator: "、"
+ file-count: "ファイル"
+ load-more: "もっと読み込む"
+ nothing-in-drive: "ドライブには何もありません"
+ folder-is-empty: "このフォルダは空です"
+mobile/views/components/drive-file-chooser.vue:
+ select-file: "ファイルを選択"
+mobile/views/components/drive-folder-chooser.vue:
+ select-folder: "フォルダーを選択"
+mobile/views/components/drive.file-detail.vue:
+ download: "ダウンロード"
+ rename: "名前を変更"
+ move: "移動"
+ hash: "ハッシュ (md5)"
+ exif: "EXIF"
+mobile/views/components/follow-button.vue:
+ follow: "フォロー"
+ unfollow: "フォロー解除"
+mobile/views/components/note.vue:
+ reposted-by: "{}がRenote"
+mobile/views/components/note-detail.vue:
+ reply: "返信"
+ reaction: "リアクション"
+mobile/views/components/notifications.vue:
+ more: "もっと見る"
+ empty: "ありません!"
+mobile/views/components/post-form.vue:
+ submit: "投稿"
+ reply-placeholder: "この投稿への返信..."
+ note-placeholder: "いまどうしてる?"
+mobile/views/components/sub-note-content.vue:
+ media-count: "{}個のメディア"
+ poll: "投票"
+mobile/views/components/timeline.vue:
+ empty: "投稿がありません"
+ load-more: "もっと"
+mobile/views/components/ui.nav.vue:
+ home: "ホーム"
+ notifications: "通知"
+ messaging: "メッセージ"
+ search: "検索"
+ drive: "ドライブ"
+ settings: "設定"
+ about: "Misskeyについて"
+mobile/views/components/user-timeline.vue:
+ no-notes: "このユーザーは投稿していないようです。"
+ no-notes-with-media: "メディア付き投稿はありません。"
+ load-more: "もっと"
+mobile/views/components/users-list.vue:
+ all: "すべて"
+ known: "知り合い"
+ load-more: "もっと"
+mobile/views/pages/drive.vue:
+ drive: "ドライブ"
+mobile/views/pages/followers.vue:
+ followers-of: "{}のフォロワー"
+mobile/views/pages/following.vue:
+ following-of: "{}のフォロー"
+mobile/views/pages/home.vue:
+ timeline: "タイムライン"
+mobile/views/pages/messaging.vue:
+ messaging: "メッセージ"
+mobile/views/pages/messaging-room.vue:
+ messaging: "メッセージ"
+mobile/views/pages/note.vue:
+ title: "投稿"
+ prev: "前の投稿"
+ next: "次の投稿"
+mobile/views/pages/notifications.vue:
+ notifications: "通知"
+ read-all: "すべての通知を既読にしますか?"
+mobile/views/pages/profile-setting.vue:
+ title: "プロフィール設定"
+ will-be-published: "これらのプロフィールは公開されます。"
+ name: "名前"
+ location: "場所"
+ description: "自己紹介"
+ birthday: "誕生日"
+ avatar: "アイコン"
+ banner: "バナー"
+ avatar-saved: "アイコンを保存しました"
+ banner-saved: "バナーを保存しました"
+ set-avatar: "アイコンを選択する"
+ set-banner: "バナーを選択する"
+ save: "保存"
+ saved: "プロフィールを保存しました"
+mobile/views/pages/search.vue:
+ search: "検索"
+ empty: "「{}」に関する投稿は見つかりませんでした。"
+mobile/views/pages/selectdrive.vue:
+ select-file: "ファイルを選択"
+mobile/views/pages/settings.vue:
+ signed-in-as: "{}としてサインイン中"
+ profile: "プロフィール"
+ twitter: "Twitter連携"
+ signin-history: "サインイン履歴"
+ settings: "設定"
+ signout: "サインアウト"
+mobile/views/pages/user.vue:
+ follows-you: "フォローされています"
+ following: "フォロー"
+ followers: "フォロワー"
+ notes: "投稿"
+ overview: "概要"
+ timeline: "タイムライン"
+ media: "メディア"
+ is-suspended: "このユーザーは凍結されています。"
+ is-remote: "このユーザーはリモートユーザーです。"
+ view-remote: "正確な情報を見る"
+mobile/views/pages/user/home.vue:
+ recent-notes: "最近の投稿"
+ images: "画像"
+ activity: "アクティビティ"
+ keywords: "キーワード"
+ domains: "頻出ドメイン"
+ frequently-replied-users: "よく会話するユーザー"
+ followers-you-know: "知り合いのフォロワー"
+ last-used-at: "最終ログイン"
+mobile/views/pages/user/home.followers-you-know.vue:
+ loading: "読み込み中"
+ no-users: "知り合いのユーザーはいません"
+mobile/views/pages/user/home.friends.vue:
+ loading: "読み込み中"
+ no-users: "よく会話するユーザーはいません"
+mobile/views/pages/user/home.notes.vue:
+ loading: "読み込み中"
+ no-notes: "投稿はありません"
+mobile/views/pages/user/home.photos.vue:
+ loading: "読み込み中"
+ no-photos: "写真はありません"
+docs:
+ edit-this-page-on-github: "間違いや改善点を見つけましたか?"
+ edit-this-page-on-github-link: "このページをGitHubで編集"
+ api:
+ entities:
+ properties: "プロパティ"
+ endpoints:
+ params: "パラメータ"
+ res: "レスポンス"
+ props:
+ name: "名前"
+ type: "型"
+ optional: "オプション"
+ description: "説明"
+ yes: "はい"
+ no: "いいえ"
diff --git a/locales/fr.yml b/locales/fr.yml
index 583104b4f0..9978407abd 100644
--- a/locales/fr.yml
+++ b/locales/fr.yml
@@ -1,6 +1,6 @@
+---
common:
misskey: "Partagez avec les autres en utilisant Misskey"
-
time:
unknown: "inconnu"
future: "future"
@@ -12,7 +12,6 @@ common:
weeks_ago: "Il y a{}semaines(s)"
months_ago: "Il y a {}mois"
years_ago: "Il y a {}an(s)"
-
weekday-short:
sunday: "D"
monday: "L"
@@ -21,7 +20,6 @@ common:
thursday: "J"
friday: "V"
satruday: "S"
-
reactions:
like: "Aime"
love: "Adore"
@@ -32,19 +30,16 @@ common:
angry: "En Colère"
confused: "Confus"
pudding: "Pudding"
-
delete: "Supprimer"
loading: "Chargement"
ok: "OK"
update-available: "Une nouvelle version de Misskey est disponible({newer}, version actuelle: {current}). Recharger la page pour appliquer la mise à jour."
my-token-regenerated: "Votre token vient d'être généré, vous allez maintenant être déconnecté."
-
common/views/components/connect-failed.vue:
title: "Impossible de se connecter au server."
description: "Il y a soit un problème avec votre connexion internet, soit le serveur est hors-ligne ou en maintenance. Veuillez {ressayer} plus tard."
thanks: "On vous remercie d'utiliser Misskey."
troubleshoot: "dépanner"
-
common/views/components/connect-failed.troubleshooter.vue:
title: "Dépannage"
network: "Connexion au réseau"
@@ -64,29 +59,24 @@ common/views/components/connect-failed.troubleshooter.vue:
success-desc: "La connexion au serveur a été reussie. Veuillez recharger la page."
flush: "キャッシュの削除"
set-version: "バージョン指定"
-
common/views/components/messaging.vue:
search-user: "Trouver un utilisateur"
you: "Vous"
no-history: "Pas d'historique"
-
common/views/components/messaging-room.vue:
empty: "Pas de conversations"
more: "Voir Plus"
no-history: "Il n'y a pas plus d'historique"
resize-form: "Faites glisser pour redimensionner"
new-message: "Nouveau message"
-
common/views/components/messaging-room.form.vue:
input-message-here: "Tapez ici votre message"
send: "Envoyer"
attach-from-local: "Joindre un fichier depuis votre PC"
attach-from-drive: "Joindre un fichier depuis votre Drive"
-
common/views/components/messaging-room.message.vue:
is-read: "Lu"
deleted: "Ce message a été supprimé"
-
common/views/components/nav.vue:
about: "À propos"
stats: "Stats"
@@ -95,10 +85,11 @@ common/views/components/nav.vue:
donors: "Donateurs"
repository: "Repo"
develop: "Développeurs"
-
+ feedback: "フィードバック"
common/views/components/note-menu.vue:
+ favorite: "Favorite this note"
pin: "Épingler sur votre profile"
-
+ remote: "投稿元で見る"
common/views/components/poll.vue:
vote-to: "Voter pour '{}'"
vote-count: "{} votes"
@@ -106,24 +97,20 @@ common/views/components/poll.vue:
vote: "Vote"
show-result: "Montrer les résultats"
voted: "Voté"
-
common/views/components/poll-editor.vue:
no-only-one-choice: "Vous devez entrer au moins deux choix"
choice-n: "Choix {}"
remove: "Supprimer ce choix"
add: "+ Ajouter un choix"
destroy: "Supprimer ce sondage"
-
common/views/components/reaction-picker.vue:
choose-reaction: "Choisissez votre réaction"
-
common/views/components/signin.vue:
username: "Nom d'utilisateur"
password: "Mot de passe"
token: "Token"
signing-in: "Connexion...."
signin: "Se connecter"
-
common/views/components/signup.vue:
username: "Nom d'utilisateur"
checking: "Vérification"
@@ -145,16 +132,13 @@ common/views/components/signup.vue:
recaptcha: "Vérifier"
create: "Créer un compte"
some-error: "La création de compte a échoué. Veuillez ressayer."
-
common/views/components/special-message.vue:
new-year: "Bonne année!"
christmas: "Joyeux Noël!"
-
common/views/components/stream-indicator.vue:
connecting: "Connexion en cours"
reconnecting: "Re-connexion en cours"
connected: "Connecté"
-
common/views/components/twitter-setting.vue:
description: "Si vous liez votre compte Twitter à votre compte Misskey, vous verrez ensuite votre compte Twitter s'afficher sur votre profile, vous aurez aussi la possibilité de vous connecter à Misskey en utilisant votre compte Twitter."
connected-to: "Vous êtes connecté à ce compte"
@@ -162,42 +146,33 @@ common/views/components/twitter-setting.vue:
reconnect: "Reconnecter"
connect: "Lier votre compte Twitter"
disconnect: "Deconnecter"
-
common/views/components/uploader.vue:
waiting: "En attente"
-
common/views/widgets/broadcast.vue:
fetching: "Récuperation"
no-broadcasts: "No broadcasts"
have-a-nice-day: "Passez une bonne journée!"
next: "Suivant"
-
common/views/widgets/donation.vue:
title: "Dons"
text: "Toutes les depences pour couvrir les frais de Misskey sortent directement de notre poche. Nous ne recevons pas d'argent, si vous pouvez nous faire dons d'argent, on vous serait eternellement reconnaissant. Si vous êtes intéressés veuilles contacter {}. Merci pour votre contribution!"
-
common/views/widgets/photo-stream.vue:
title: "Flux de photo"
no-photos: "Pas de photos"
-
common/views/widgets/server.vue:
title: "Info sur le serveur"
toggle: "Afficher les vues"
-
desktop/views/components/activity.vue:
title: "Activitié"
toggle: "Afficher les vues"
-
desktop/views/components/calendar.vue:
title: "{1} / {2}"
prev: "Mois dernier"
next: "Mois prochain"
go: "Cliquer pour naviguer"
-
desktop/views/components/drive-window.vue:
used: "utilisé"
drive: "Drive"
-
desktop/views/components/drive.file.vue:
avatar: "Avatar"
banner: "Bannière"
@@ -214,7 +189,6 @@ desktop/views/components/drive.file.vue:
input-new-file-name: "Entrer un nouveau nom"
copied: "Copied"
copied-url-to-clipboard: "L'URL a été copié dans le presse-papier"
-
desktop/views/components/drive.folder.vue:
unable-to-process: "L'opération n'a pas pu être complétée"
circular-reference-detected: "Le dossier de destination est un sous-dossier du dossier que vous souhaitez déplacer."
@@ -225,10 +199,8 @@ desktop/views/components/drive.folder.vue:
rename: "Renommer"
rename-folder: "Renommer le dossier"
input-new-folder-name: "Entrer un nouveau nom"
-
desktop/views/components/drive.nav-folder.vue:
drive: "Drive"
-
desktop/views/components/drive.vue:
search: "Rechercher"
load-more: "Afficher plus"
@@ -249,21 +221,17 @@ desktop/views/components/drive.vue:
create-folder: "Créer un dossier"
upload: "Uploader un fichier"
url-upload: "Uploader d'un URL"
-
desktop/views/components/messaging-window.vue:
title: "Messagerie"
-
desktop/views/components/notes.note.vue:
reposted-by: "Reposté par {}"
reply: "Répondre"
renote: "Renote"
add-reaction: "Ajouter votre reaction"
detail: "Afficher les détails"
-
desktop/views/components/notifications.vue:
more: "Plus"
empty: "Pas de notifications"
-
desktop/views/components/post-form.vue:
note-placeholder: "Qu'est-ce qui se passe?"
reply-placeholder: "Répondre à cette note"
@@ -284,13 +252,11 @@ desktop/views/components/post-form.vue:
insert-a-kao: "v(‘ω’)v"
create-poll: "Créer un sondage"
text-remain: "{} charactères restants"
-
desktop/views/components/post-form-window.vue:
note: "Nouvelle note"
reply: "Répondre"
attaches: "{} media joint(s)"
uploading-media: "Upload du media {}"
-
desktop/views/components/renote-form.vue:
quote: "Citer..."
cancel: "Annuler"
@@ -298,10 +264,8 @@ desktop/views/components/renote-form.vue:
reposting: "Repost en cours..."
success: "Reposté!"
failure: "La renote a échoué"
-
desktop/views/components/renote-form-window.vue:
title: "Êtes vous sûr de vouloir renote cette note?"
-
desktop/views/components/settings.vue:
profile: "Profil"
notification: "Notification"
@@ -313,7 +277,6 @@ desktop/views/components/settings.vue:
2fa: "Vérification en deux étapes"
other: "Autres"
license: "License"
-
desktop/views/components/settings.2fa.vue:
intro: "Si vous configurez la vérication en deux étapes vous aurez non seulement besoin de votre mot de passe mais aussi un appareil déjà pré-enregistré(tel que votre smartphone) ce qui ameliora grandement la sécurité de votre compte."
detail: "Voir les détails..."
@@ -332,20 +295,16 @@ desktop/views/components/settings.2fa.vue:
success: "L'operation a été complétée avec succès!"
failed: "L'operation a échoué. Veuillez vous assurer que le token a été entrer correctement."
info: "À partir de maintenant, à chaque fois que vous vous connecter entrez votre mot de passe ainsi que le token généré sur votre appareil."
-
desktop/views/components/settings.api.vue:
intro: "APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。"
caution: "アカウントを不正利用される可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。"
regeneration-of-token: "万が一このトークンが漏れたりその可能性がある場合はトークンを再生成できます。"
regenerate-token: "Regenerer le token"
enter-password: "Veuillez entrer le mot de passe"
-
desktop/views/components/settings.app.vue:
no-apps: "Aucune application authorisée"
-
desktop/views/components/settings.mute.vue:
no-users: "Aucun utilisateurs mis en sourdine"
-
desktop/views/components/settings.password.vue:
reset: "Changer votre mot de passe"
enter-current-password: "Entrez votre mot de passe actuel"
@@ -353,7 +312,6 @@ desktop/views/components/settings.password.vue:
enter-new-password-again: "Entrez à nouveau le nouveau mot de passe"
not-match: "Le nouveau mot de passe ne correspond pas."
changed: "Mot de passe modifié avec succès"
-
desktop/views/components/settings.profile.vue:
avatar: "Avatar"
choice-avatar: "Choose an image"
@@ -362,99 +320,84 @@ desktop/views/components/settings.profile.vue:
description: "Description"
birthday: "Date de naissance"
save: "Mettre à jour le profil"
-
desktop/views/components/ui.header.account.vue:
profile: "Votre profil"
drive: "Drive"
- mentions: "Mentions"
+ favorites: "Favorites"
+ lists: "リスト"
customize: "Modifications"
settings: "Réglages"
signout: "Déconnexion"
-
+ dark: "Fall in dark"
desktop/views/components/ui.header.nav.vue:
home: "Accueil"
messaging: "Messages"
game: "Jeux"
-
desktop/views/components/ui.header.notifications.vue:
title: "Notifications"
-
desktop/views/components/ui.header.post.vue:
post: "Composer un nouveau post"
-
desktop/views/components/ui.header.search.vue:
placeholder: "Chercher"
-
desktop/views/pages/note.vue:
prev: "Note précédente"
next: "Note suivante"
-
desktop/views/pages/selectdrive.vue:
title: "Choisir fichier(s)"
ok: "OK"
cancel: "Annuler"
upload: "Uploader un ou plusieurs fichier(s) depuis votre PC"
-
desktop/views/pages/user/user.followers-you-know.vue:
title: "Abonnés que vous connaissez"
loading: "Chargement en cours"
no-users: "Pas d'utilisateurs"
-
desktop/views/pages/user/user.friends.vue:
title: "Personnes qui répondent le plus"
loading: "Chargement en cours"
no-users: "Pas d'utilisateurs"
-
desktop/views/pages/user/user.header.vue:
- is-remote: "Cet utilisateur n'est pas un utilisateur de Misskey. Certaines informations peuvent être erronées"
+ is-suspended: "This account has been suspended."
+ is-remote: "Cet utilisateur n'est pas un utilisateur de Misskey. Certaines informations peuvent être erronées "
view-remote: "Voir les informations détaillées"
-
desktop/views/pages/user/user.home.vue:
last-used-at: "Last used at"
-
desktop/views/pages/user/user.photos.vue:
title: "Photos"
loading: "Chargement en cours"
no-photos: "Pas de photos"
-
desktop/views/pages/user/user.profile.vue:
follows-you: "Vous suis"
+ stalk: "ストークする"
+ stalking: "ストーキングしています"
+ unstalk: "ストーク解除"
mute: "Mettre en sourdine"
muted: "Muting"
unmute: "Enlever la sourdine"
-
desktop/views/widgets/messaging.vue:
title: "Messagerie"
-
desktop/views/widgets/notifications.vue:
title: "Notifications"
settings: "Réglages"
-
desktop/views/widgets/polls.vue:
title: "Sondages"
refresh: "Afficher d'autres"
nothing: "Rien"
-
desktop/views/widgets/post-form.vue:
title: "Post"
note: "Post"
placeholder: "Qu'est-ce qu'il se passe?"
-
desktop/views/widgets/trends.vue:
title: "Tendances"
refresh: "Afficher d'autres"
nothing: "Rien"
-
desktop/views/widgets/users.vue:
- title: "Utilisateurs recommandés"
+ title: "Utilisateurs"
refresh: "Afficher d'autres"
no-one: "Personne"
-
desktop/views/widgets/channel.vue:
title: "Cannal"
settings: "Réglages des widgets"
get-started: "Veuillez cliquer sur la dent en haute à droite pour choisir un channel"
-
mobile/views/components/drive.vue:
drive: "Drive"
used: "utilisé"
@@ -464,94 +407,72 @@ mobile/views/components/drive.vue:
load-more: "Charger plus"
nothing-in-drive: "Rien"
folder-is-empty: "Ce dossier est vide"
-
mobile/views/components/drive-file-chooser.vue:
select-file: "Choisissez un fichier"
-
mobile/views/components/drive-folder-chooser.vue:
select-folder: "Choisissez un dossier"
-
mobile/views/components/drive.file-detail.vue:
download: "Télécharger"
rename: "Renommer"
move: "Déplacer"
hash: "Hash (md5)"
exif: "EXIF"
-
mobile/views/components/follow-button.vue:
follow: "Suivre"
unfollow: "Ne plus suivre"
-
mobile/views/components/note.vue:
reposted-by: "Renoté par {}"
-
mobile/views/components/note-detail.vue:
reply: "Répondre"
reaction: "Réaction"
-
mobile/views/components/notifications.vue:
more: "Plus"
empty: "Pas de notifications"
-
mobile/views/components/post-form.vue:
submit: "Poster"
reply-placeholder: "Répondre à cette note"
note-placeholder: "Qu'est-ce qu'il se passe?"
-
mobile/views/components/sub-note-content.vue:
media-count: "{} media"
poll: "Sondage"
-
mobile/views/components/timeline.vue:
empty: "Pas de notes"
load-more: "Afficher plus"
-
mobile/views/components/ui.nav.vue:
home: "Accueil"
notifications: "Notifications"
messaging: "Messages"
+ search: "Rechercher"
drive: "Drive"
settings: "Réglages"
about: "À propose de Misskey"
- search: "Rechercher"
-
mobile/views/components/user-timeline.vue:
no-notes: "Cette utilisateur semble n'avoir rien poster pour le moment"
no-notes-with-media: "Aucune notes avec des médias"
load-more: "Afficher Plus"
-
mobile/views/components/users-list.vue:
all: "Tout"
known: "Vous connaissez"
load-more: "Afficher plus"
-
mobile/views/pages/drive.vue:
drive: "Drive"
-
mobile/views/pages/followers.vue:
followers-of: "Abonnés de {}"
-
mobile/views/pages/following.vue:
following-of: "Abonnements de {}"
-
mobile/views/pages/home.vue:
timeline: "Fil d'actualité"
-
mobile/views/pages/messaging.vue:
messaging: "Messagerie"
-
mobile/views/pages/messaging-room.vue:
messaging: "Messagerie"
-
mobile/views/pages/note.vue:
title: "Post"
prev: "Note précedante"
next: "Note suivante"
-
mobile/views/pages/notifications.vue:
notifications: "Notifications"
read-all: "Êtes vous sûr de vouloir marqués toutes les notifications non-lus en tant que lus?"
-
mobile/views/pages/profile-setting.vue:
title: "Réglages du profiles"
will-be-published: "Ces profiles vont être publier"
@@ -567,22 +488,18 @@ mobile/views/pages/profile-setting.vue:
set-banner: "Choisir une bannière"
save: "Sauvegarder"
saved: "Profil mis à jour avec succès"
-
mobile/views/pages/search.vue:
search: "Chercher"
empty: "Aucun message trouvé pour '{}' "
-
mobile/views/pages/selectdrive.vue:
select-file: "Choisissez un fichier"
-
mobile/views/pages/settings.vue:
signed-in-as: "Connecté en tant que {}"
profile: "Profile"
- twitter-integration: "Twitter integration"
+ twitter: "Twitter連携"
signin-history: "Historique de connexion"
settings: "Réglages"
signout: "Déconnexion"
-
mobile/views/pages/user.vue:
follows-you: "vous suit"
following: "Abonnements"
@@ -591,9 +508,9 @@ mobile/views/pages/user.vue:
overview: "Aperçu"
timeline: "Fil d'actualité"
media: "Media"
- is-remote: "Cet utilisateur n'est pas un utilisateur de Misskey. Certaines informations peuvent être erronées"
+ is-suspended: "This account has been suspended."
+ is-remote: "Cet utilisateur n'est pas un utilisateur de Misskey. Certaines informations peuvent être erronées "
view-remote: "Voir les informations détaillées"
-
mobile/views/pages/user/home.vue:
recent-notes: "Notes récentes"
images: "Images"
@@ -603,27 +520,21 @@ mobile/views/pages/user/home.vue:
frequently-replied-users: "Utilisateurs qui interagissent souvent"
followers-you-know: "Abonnés que vous connaissez"
last-used-at: "Dernière connexion il y a"
-
mobile/views/pages/user/home.followers-you-know.vue:
loading: "Chargement"
no-users: "Pas d'utilisateurs"
-
mobile/views/pages/user/home.friends.vue:
loading: "Chargement"
no-users: "Pass d'utilisateurs"
-
mobile/views/pages/user/home.notes.vue:
loading: "Chargement"
no-notes: "Pas de notes"
-
mobile/views/pages/user/home.photos.vue:
loading: "Chargement"
no-photos: "Pas de photos"
-
docs:
edit-this-page-on-github: "Vous avez trouvé une erreur ou vous voulez contribuer à la documentation?"
edit-this-page-on-github-link: "Modifiez cette page sur github!"
-
api:
entities:
properties: "Propriétés"
diff --git a/locales/index.ts b/locales/index.ts
index 6e76f39179..926844766d 100644
--- a/locales/index.ts
+++ b/locales/index.ts
@@ -13,7 +13,8 @@ const native = loadLang('ja');
const langs = {
'en': loadLang('en'),
'fr': loadLang('fr'),
- 'ja': native
+ 'ja': native,
+ 'pl': loadLang('pl')
};
Object.entries(langs).map(([, locale]) => {
diff --git a/locales/it.yml b/locales/it.yml
new file mode 100644
index 0000000000..b6e7898a8e
--- /dev/null
+++ b/locales/it.yml
@@ -0,0 +1,550 @@
+---
+common:
+ misskey: "Misskeyで皆と共有しよう。"
+ time:
+ unknown: "なぞのじかん"
+ future: "未来"
+ just_now: "たった今"
+ seconds_ago: "{}秒前"
+ minutes_ago: "{}分前"
+ hours_ago: "{}時間前"
+ days_ago: "{}日前"
+ weeks_ago: "{}週間前"
+ months_ago: "{}ヶ月前"
+ years_ago: "{}年前"
+ weekday-short:
+ sunday: "日"
+ monday: "月"
+ tuesday: "火"
+ wednesday: "水"
+ thursday: "木"
+ friday: "金"
+ satruday: "土"
+ reactions:
+ like: "いいね"
+ love: "しゅき"
+ laugh: "笑"
+ hmm: "ふぅ~む"
+ surprise: "わお"
+ congrats: "おめでとう"
+ angry: "おこ"
+ confused: "こまこまのこまり"
+ pudding: "Pudding"
+ delete: "削除"
+ loading: "読み込み中"
+ ok: "わかった"
+ update-available: "Misskeyの新しいバージョンがあります({newer}。現在{current}を利用中)。ページを再度読み込みすると更新が適用されます。"
+ my-token-regenerated: "あなたのトークンが更新されたのでサインアウトします。"
+common/views/components/connect-failed.vue:
+ title: "サーバーに接続できません"
+ description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから{再度お試し}ください。"
+ thanks: "いつもMisskeyをご利用いただきありがとうございます。"
+ troubleshoot: "トラブルシュート"
+common/views/components/connect-failed.troubleshooter.vue:
+ title: "トラブルシューティング"
+ network: "ネットワーク接続"
+ checking-network: "ネットワーク接続を確認中"
+ internet: "インターネット接続"
+ checking-internet: "インターネット接続を確認中"
+ server: "サーバー接続"
+ checking-server: "サーバー接続を確認中"
+ finding: "問題を調べています"
+ no-network: "ネットワークに接続されていません"
+ no-network-desc: "お使いのPCのネットワーク接続が正常か確認してください。"
+ no-internet: "インターネットに接続されていません"
+ no-internet-desc: "ネットワークには接続されていますが、インターネットには接続されていないようです。お使いのPCのインターネット接続が正常か確認してください。"
+ no-server: "Misskeyのサーバーに接続できません"
+ no-server-desc: "お使いのPCのインターネット接続は正常ですが、Misskeyのサーバーには接続できませんでした。サーバーがダウンまたはメンテナンスしている可能性があるので、しばらくしてから再度御アクセスください。"
+ success: "Misskeyのサーバーに接続できました"
+ success-desc: "正常に接続できるようです。ページを再度読み込みしてください。"
+ flush: "キャッシュの削除"
+ set-version: "バージョン指定"
+common/views/components/messaging.vue:
+ search-user: "ユーザーを探す"
+ you: "あなた"
+ no-history: "履歴はありません"
+common/views/components/messaging-room.vue:
+ empty: "このユーザーと話したことはありません"
+ more: "もっと読む"
+ no-history: "これより過去の履歴はありません"
+ resize-form: "ドラッグしてフォームの広さを調整"
+ new-message: "新しいメッセージがあります"
+common/views/components/messaging-room.form.vue:
+ input-message-here: "ここにメッセージを入力"
+ send: "送信"
+ attach-from-local: "PCからファイルを添付する"
+ attach-from-drive: "ドライブからファイルを添付する"
+common/views/components/messaging-room.message.vue:
+ is-read: "既読"
+ deleted: "このメッセージは削除されました"
+common/views/components/nav.vue:
+ about: "Misskeyについて"
+ stats: "統計"
+ status: "ステータス"
+ wiki: "Wiki"
+ donors: "ドナー"
+ repository: "リポジトリ"
+ develop: "開発者"
+ feedback: "フィードバック"
+common/views/components/note-menu.vue:
+ favorite: "お気に入り"
+ pin: "ピン留め"
+ remote: "投稿元で見る"
+common/views/components/poll.vue:
+ vote-to: "「{}」に投票する"
+ vote-count: "{}票"
+ total-users: "{}人が投票"
+ vote: "投票する"
+ show-result: "結果を見る"
+ voted: "投票済み"
+common/views/components/poll-editor.vue:
+ no-only-one-choice: "投票には、選択肢が最低2つ必要です"
+ choice-n: "選択肢{}"
+ remove: "この選択肢を削除"
+ add: "+選択肢を追加"
+ destroy: "投票を破棄"
+common/views/components/reaction-picker.vue:
+ choose-reaction: "リアクションを選択"
+common/views/components/signin.vue:
+ username: "ユーザー名"
+ password: "パスワード"
+ token: "トークン"
+ signing-in: "やってます..."
+ signin: "サインイン"
+common/views/components/signup.vue:
+ username: "ユーザー名"
+ checking: "確認しています..."
+ available: "利用できます"
+ unavailable: "既に利用されています"
+ error: "通信エラー"
+ invalid-format: "a~z、A~Z、0~9、_が使えます"
+ too-short: "1文字以上でお願いします!"
+ too-long: "20文字以内でお願いします"
+ password: "パスワード"
+ password-placeholder: "8文字以上を推奨します"
+ weak-password: "弱いパスワード"
+ normal-password: "まあまあのパスワード"
+ strong-password: "強いパスワード"
+ retype: "再入力"
+ retype-placeholder: "確認のため再入力してください"
+ password-matched: "確認されました"
+ password-not-matched: "一致していません"
+ recaptcha: "認証"
+ create: "アカウント作成"
+ some-error: "何らかの原因によりアカウントの作成に失敗しました。再度お試しください。"
+common/views/components/special-message.vue:
+ new-year: "Happy New Year!"
+ christmas: "Merry Christmas!"
+common/views/components/stream-indicator.vue:
+ connecting: "接続中"
+ reconnecting: "再接続中"
+ connected: "接続完了"
+common/views/components/twitter-setting.vue:
+ description: "お使いのTwitterアカウントをお使いのMisskeyアカウントに接続しておくと、プロフィールでTwitterアカウント情報が表示されるようになったり、Twitterを用いた便利なサインインを利用できるようになります。"
+ connected-to: "次のTwitterアカウントに接続されています"
+ detail: "詳細..."
+ reconnect: "再接続する"
+ connect: "Twitterと接続する"
+ disconnect: "切断する"
+common/views/components/uploader.vue:
+ waiting: "待機中"
+common/views/widgets/broadcast.vue:
+ fetching: "確認中"
+ no-broadcasts: "お知らせはありません"
+ have-a-nice-day: "良い一日を!"
+ next: "次"
+common/views/widgets/donation.vue:
+ title: "寄付のお願い"
+ text: "Misskeyの運営にはドメイン、サーバー等のコストが掛かります。Misskeyは広告を掲載したりしないため、収入を皆様からの寄付に頼っています。もしご興味があれば、{}までご連絡ください。ご協力ありがとうございます。"
+common/views/widgets/photo-stream.vue:
+ title: "フォトストリーム"
+ no-photos: "写真はありません"
+common/views/widgets/server.vue:
+ title: "サーバー情報"
+ toggle: "表示を切り替え"
+desktop/views/components/activity.vue:
+ title: "アクティビティ"
+ toggle: "表示を切り替え"
+desktop/views/components/calendar.vue:
+ title: "{1}年 {2}月"
+ prev: "前の月"
+ next: "次の月"
+ go: "クリックして時間遡行"
+desktop/views/components/drive-window.vue:
+ used: "使用中"
+ drive: "ドライブ"
+desktop/views/components/drive.file.vue:
+ avatar: "アイコン"
+ banner: "バナー"
+ contextmenu:
+ rename: "名前を変更"
+ copy-url: "URLをコピー"
+ download: "ダウンロード"
+ else-files: "その他..."
+ set-as-avatar: "アイコンに設定"
+ set-as-banner: "バナーに設定"
+ open-in-app: "アプリで開く"
+ add-app: "アプリを追加"
+ rename-file: "ファイル名の変更"
+ input-new-file-name: "新しいファイル名を入力してください"
+ copied: "コピー完了"
+ copied-url-to-clipboard: "URLをクリップボードにコピーしました"
+desktop/views/components/drive.folder.vue:
+ unable-to-process: "操作を完了できません"
+ circular-reference-detected: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。"
+ unhandled-error: "不明なエラー"
+ contextmenu:
+ move-to-this-folder: "このフォルダへ移動"
+ show-in-new-window: "新しいウィンドウで表示"
+ rename: "名前を変更"
+ rename-folder: "フォルダ名の変更"
+ input-new-folder-name: "新しいフォルダ名を入力してください"
+desktop/views/components/drive.nav-folder.vue:
+ drive: "ドライブ"
+desktop/views/components/drive.vue:
+ search: "検索"
+ load-more: "もっと読み込む"
+ empty-draghover: "ドロップですか?いいですよ、ボクはカワイイですからね"
+ empty-drive: "ドライブには何もありません。"
+ empty-drive-description: "右クリックして「ファイルをアップロード」を選んだり、ファイルをドラッグ&ドロップすることでもアップロードできます。"
+ empty-folder: "このフォルダーは空です"
+ unable-to-process: "操作を完了できません"
+ circular-reference-detected: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。"
+ unhandled-error: "不明なエラー"
+ url-upload: "URLアップロード"
+ url-of-file: "アップロードしたいファイルのURL"
+ url-upload-requested: "アップロードをリクエストしました"
+ may-take-time: "アップロードが完了するまで時間がかかる場合があります。"
+ create-folder: "フォルダー作成"
+ folder-name: "フォルダー名"
+ contextmenu:
+ create-folder: "フォルダーを作成"
+ upload: "ファイルをアップロード"
+ url-upload: "URLからアップロード"
+desktop/views/components/messaging-window.vue:
+ title: "メッセージ"
+desktop/views/components/notes.note.vue:
+ reposted-by: "{}がRenote"
+ reply: "返信"
+ renote: "Renote"
+ add-reaction: "リアクション"
+ detail: "詳細"
+desktop/views/components/notifications.vue:
+ more: "もっと見る"
+ empty: "ありません!"
+desktop/views/components/post-form.vue:
+ note-placeholder: "いまどうしてる?"
+ reply-placeholder: "この投稿への返信..."
+ quote-placeholder: "この投稿を引用..."
+ note: "投稿"
+ reply: "返信"
+ renote: "Renote"
+ posted: "投稿しました!"
+ replied: "返信しました!"
+ reposted: "Renoteしました!"
+ note-failed: "投稿に失敗しました"
+ reply-failed: "返信に失敗しました"
+ renote-failed: "Renoteに失敗しました"
+ posting: "投稿中"
+ attach-media-from-local: "PCからメディアを添付"
+ attach-media-from-drive: "ドライブからメディアを添付"
+ attach-cancel: "添付取り消し"
+ insert-a-kao: "v(‘ω’)v"
+ create-poll: "投票を作成"
+ text-remain: "残り{}文字"
+desktop/views/components/post-form-window.vue:
+ note: "新規投稿"
+ reply: "返信"
+ attaches: "添付: {}メディア"
+ uploading-media: "{}個のメディアをアップロード中"
+desktop/views/components/renote-form.vue:
+ quote: "引用する..."
+ cancel: "キャンセル"
+ renote: "Renote"
+ reposting: "しています..."
+ success: "Renoteしました!"
+ failure: "Renoteに失敗しました"
+desktop/views/components/renote-form-window.vue:
+ title: "この投稿をRenoteしますか?"
+desktop/views/components/settings.vue:
+ profile: "プロフィール"
+ notification: "通知"
+ apps: "アプリ"
+ mute: "ミュート"
+ drive: "ドライブ"
+ security: "セキュリティ"
+ password: "パスワード"
+ 2fa: "二段階認証"
+ other: "その他"
+ license: "ライセンス"
+desktop/views/components/settings.2fa.vue:
+ intro: "二段階認証を設定すると、サインイン時にパスワードだけでなく、予め登録しておいた物理的なデバイス(例えばあなたのスマートフォンなど)も必要になり、よりセキュリティが向上します。"
+ detail: "詳細..."
+ url: "https://www.google.co.jp/intl/ja/landing/2step/"
+ caution: "登録したデバイスを紛失するなどした場合、Misskeyにサインインできなくなりますのでご注意ください。"
+ register: "デバイスを登録する"
+ already-registered: "既に設定は完了しています。"
+ unregister: "設定を解除"
+ unregistered: "二段階認証が無効になりました。"
+ enter-password: "パスワードを入力してください"
+ authenticator: "まず、Google Authenticatorをお使いのデバイスにインストールします:"
+ howtoinstall: "インストール方法はこちら"
+ scan: "次に、表示されているQRコードをスキャンします:"
+ done: "お使いのデバイスに表示されているトークンを入力して完了します:"
+ submit: "完了"
+ success: "設定が完了しました!"
+ failed: "設定に失敗しました。トークンに誤りがないかご確認ください。"
+ info: "次回サインインからは、同様にパスワードに加えてデバイスに表示されているトークンを入力します。"
+desktop/views/components/settings.api.vue:
+ intro: "APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。"
+ caution: "アカウントを不正利用される可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。"
+ regeneration-of-token: "万が一このトークンが漏れたりその可能性がある場合はトークンを再生成できます。"
+ regenerate-token: "トークンを再生成"
+ enter-password: "パスワードを入力してください"
+desktop/views/components/settings.app.vue:
+ no-apps: "連携しているアプリケーションはありません"
+desktop/views/components/settings.mute.vue:
+ no-users: "ミュートしているユーザーはいません"
+desktop/views/components/settings.password.vue:
+ reset: "パスワードを変更する"
+ enter-current-password: "現在のパスワードを入力してください"
+ enter-new-password: "新しいパスワードを入力してください"
+ enter-new-password-again: "もう一度新しいパスワードを入力してください"
+ not-match: "新しいパスワードが一致しません"
+ changed: "パスワードを変更しました"
+desktop/views/components/settings.profile.vue:
+ avatar: "アイコン"
+ choice-avatar: "画像を選択"
+ name: "名前"
+ location: "場所"
+ description: "自己紹介"
+ birthday: "誕生日"
+ save: "保存"
+desktop/views/components/ui.header.account.vue:
+ profile: "プロフィール"
+ drive: "ドライブ"
+ favorites: "お気に入り"
+ lists: "リスト"
+ customize: "カスタマイズ"
+ settings: "設定"
+ signout: "サインアウト"
+ dark: "闇に飲まれる"
+desktop/views/components/ui.header.nav.vue:
+ home: "ホーム"
+ messaging: "メッセージ"
+ game: "ゲーム"
+desktop/views/components/ui.header.notifications.vue:
+ title: "通知"
+desktop/views/components/ui.header.post.vue:
+ post: "新規投稿"
+desktop/views/components/ui.header.search.vue:
+ placeholder: "検索"
+desktop/views/pages/note.vue:
+ prev: "前の投稿"
+ next: "次の投稿"
+desktop/views/pages/selectdrive.vue:
+ title: "ファイルを選択してください"
+ ok: "決定"
+ cancel: "キャンセル"
+ upload: "PCからドライブにファイルをアップロード"
+desktop/views/pages/user/user.followers-you-know.vue:
+ title: "知り合いのフォロワー"
+ loading: "読み込み中"
+ no-users: "知り合いのフォロワーはいません"
+desktop/views/pages/user/user.friends.vue:
+ title: "よく話すユーザー"
+ loading: "読み込み中"
+ no-users: "よく話すユーザーはいません"
+desktop/views/pages/user/user.header.vue:
+ is-suspended: "このユーザーは凍結されています。"
+ is-remote: "このユーザーはリモートユーザーです。"
+ view-remote: "正確な情報を見る"
+desktop/views/pages/user/user.home.vue:
+ last-used-at: "最終アクセス"
+desktop/views/pages/user/user.photos.vue:
+ title: "フォト"
+ loading: "読み込み中"
+ no-photos: "写真はありません"
+desktop/views/pages/user/user.profile.vue:
+ follows-you: "フォローされています"
+ stalk: "ストークする"
+ stalking: "ストーキングしています"
+ unstalk: "ストーク解除"
+ mute: "ミュートする"
+ muted: "ミュートしています"
+ unmute: "ミュート解除"
+desktop/views/widgets/messaging.vue:
+ title: "メッセージ"
+desktop/views/widgets/notifications.vue:
+ title: "通知"
+ settings: "通知の設定"
+desktop/views/widgets/polls.vue:
+ title: "投票"
+ refresh: "他を見る"
+ nothing: "ありません!"
+desktop/views/widgets/post-form.vue:
+ title: "投稿"
+ note: "投稿"
+ placeholder: "いまどうしてる?"
+desktop/views/widgets/trends.vue:
+ title: "トレンド"
+ refresh: "他を見る"
+ nothing: "ありません!"
+desktop/views/widgets/users.vue:
+ title: "おすすめユーザー"
+ refresh: "他を見る"
+ no-one: "いません!"
+desktop/views/widgets/channel.vue:
+ title: "チャンネル"
+ settings: "ウィジェットの設定"
+ get-started: "右上の歯車をクリックして受信するチャンネルを指定してください"
+mobile/views/components/drive.vue:
+ drive: "ドライブ"
+ used: "使用中"
+ folder-count: "フォルダ"
+ count-separator: "、"
+ file-count: "ファイル"
+ load-more: "もっと読み込む"
+ nothing-in-drive: "ドライブには何もありません"
+ folder-is-empty: "このフォルダは空です"
+mobile/views/components/drive-file-chooser.vue:
+ select-file: "ファイルを選択"
+mobile/views/components/drive-folder-chooser.vue:
+ select-folder: "フォルダーを選択"
+mobile/views/components/drive.file-detail.vue:
+ download: "ダウンロード"
+ rename: "名前を変更"
+ move: "移動"
+ hash: "ハッシュ (md5)"
+ exif: "EXIF"
+mobile/views/components/follow-button.vue:
+ follow: "フォロー"
+ unfollow: "フォロー解除"
+mobile/views/components/note.vue:
+ reposted-by: "{}がRenote"
+mobile/views/components/note-detail.vue:
+ reply: "返信"
+ reaction: "リアクション"
+mobile/views/components/notifications.vue:
+ more: "もっと見る"
+ empty: "ありません!"
+mobile/views/components/post-form.vue:
+ submit: "投稿"
+ reply-placeholder: "この投稿への返信..."
+ note-placeholder: "いまどうしてる?"
+mobile/views/components/sub-note-content.vue:
+ media-count: "{}個のメディア"
+ poll: "投票"
+mobile/views/components/timeline.vue:
+ empty: "投稿がありません"
+ load-more: "もっと"
+mobile/views/components/ui.nav.vue:
+ home: "ホーム"
+ notifications: "通知"
+ messaging: "メッセージ"
+ search: "検索"
+ drive: "ドライブ"
+ settings: "設定"
+ about: "Misskeyについて"
+mobile/views/components/user-timeline.vue:
+ no-notes: "このユーザーは投稿していないようです。"
+ no-notes-with-media: "メディア付き投稿はありません。"
+ load-more: "もっと"
+mobile/views/components/users-list.vue:
+ all: "すべて"
+ known: "知り合い"
+ load-more: "もっと"
+mobile/views/pages/drive.vue:
+ drive: "ドライブ"
+mobile/views/pages/followers.vue:
+ followers-of: "{}のフォロワー"
+mobile/views/pages/following.vue:
+ following-of: "{}のフォロー"
+mobile/views/pages/home.vue:
+ timeline: "タイムライン"
+mobile/views/pages/messaging.vue:
+ messaging: "メッセージ"
+mobile/views/pages/messaging-room.vue:
+ messaging: "メッセージ"
+mobile/views/pages/note.vue:
+ title: "投稿"
+ prev: "前の投稿"
+ next: "次の投稿"
+mobile/views/pages/notifications.vue:
+ notifications: "通知"
+ read-all: "すべての通知を既読にしますか?"
+mobile/views/pages/profile-setting.vue:
+ title: "プロフィール設定"
+ will-be-published: "これらのプロフィールは公開されます。"
+ name: "名前"
+ location: "場所"
+ description: "自己紹介"
+ birthday: "誕生日"
+ avatar: "アイコン"
+ banner: "バナー"
+ avatar-saved: "アイコンを保存しました"
+ banner-saved: "バナーを保存しました"
+ set-avatar: "アイコンを選択する"
+ set-banner: "バナーを選択する"
+ save: "保存"
+ saved: "プロフィールを保存しました"
+mobile/views/pages/search.vue:
+ search: "検索"
+ empty: "「{}」に関する投稿は見つかりませんでした。"
+mobile/views/pages/selectdrive.vue:
+ select-file: "ファイルを選択"
+mobile/views/pages/settings.vue:
+ signed-in-as: "{}としてサインイン中"
+ profile: "プロフィール"
+ twitter: "Twitter連携"
+ signin-history: "サインイン履歴"
+ settings: "設定"
+ signout: "サインアウト"
+mobile/views/pages/user.vue:
+ follows-you: "フォローされています"
+ following: "フォロー"
+ followers: "フォロワー"
+ notes: "投稿"
+ overview: "概要"
+ timeline: "タイムライン"
+ media: "メディア"
+ is-suspended: "このユーザーは凍結されています。"
+ is-remote: "このユーザーはリモートユーザーです。"
+ view-remote: "正確な情報を見る"
+mobile/views/pages/user/home.vue:
+ recent-notes: "最近の投稿"
+ images: "画像"
+ activity: "アクティビティ"
+ keywords: "キーワード"
+ domains: "頻出ドメイン"
+ frequently-replied-users: "よく会話するユーザー"
+ followers-you-know: "知り合いのフォロワー"
+ last-used-at: "最終ログイン"
+mobile/views/pages/user/home.followers-you-know.vue:
+ loading: "読み込み中"
+ no-users: "知り合いのユーザーはいません"
+mobile/views/pages/user/home.friends.vue:
+ loading: "読み込み中"
+ no-users: "よく会話するユーザーはいません"
+mobile/views/pages/user/home.notes.vue:
+ loading: "読み込み中"
+ no-notes: "投稿はありません"
+mobile/views/pages/user/home.photos.vue:
+ loading: "読み込み中"
+ no-photos: "写真はありません"
+docs:
+ edit-this-page-on-github: "間違いや改善点を見つけましたか?"
+ edit-this-page-on-github-link: "このページをGitHubで編集"
+ api:
+ entities:
+ properties: "プロパティ"
+ endpoints:
+ params: "パラメータ"
+ res: "レスポンス"
+ props:
+ name: "名前"
+ type: "型"
+ optional: "オプション"
+ description: "説明"
+ yes: "はい"
+ no: "いいえ"
diff --git a/locales/ja.yml b/locales/ja.yml
index b5479b5cef..85826110f0 100644
--- a/locales/ja.yml
+++ b/locales/ja.yml
@@ -95,9 +95,12 @@ common/views/components/nav.vue:
donors: "ドナー"
repository: "リポジトリ"
develop: "開発者"
+ feedback: "フィードバック"
common/views/components/note-menu.vue:
+ favorite: "お気に入り"
pin: "ピン留め"
+ remote: "投稿元で見る"
common/views/components/poll.vue:
vote-to: "「{}」に投票する"
@@ -363,13 +366,21 @@ desktop/views/components/settings.profile.vue:
birthday: "誕生日"
save: "保存"
+desktop/views/components/timeline.vue:
+ home: "ホーム"
+ local: "ローカル"
+ global: "グローバル"
+ list: "リスト"
+
desktop/views/components/ui.header.account.vue:
profile: "プロフィール"
drive: "ドライブ"
- mentions: "あなた宛て"
+ favorites: "お気に入り"
+ lists: "リスト"
customize: "カスタマイズ"
settings: "設定"
signout: "サインアウト"
+ dark: "闇に飲まれる"
desktop/views/components/ui.header.nav.vue:
home: "ホーム"
@@ -406,7 +417,8 @@ desktop/views/pages/user/user.friends.vue:
no-users: "よく話すユーザーはいません"
desktop/views/pages/user/user.header.vue:
- is-remote: "このユーザーはMisskeyのユーザーではないため情報が正確ではありません。"
+ is-suspended: "このユーザーは凍結されています。"
+ is-remote: "このユーザーはリモートユーザーです。"
view-remote: "正確な情報を見る"
desktop/views/pages/user/user.home.vue:
@@ -419,6 +431,9 @@ desktop/views/pages/user/user.photos.vue:
desktop/views/pages/user/user.profile.vue:
follows-you: "フォローされています"
+ stalk: "ストークする"
+ stalking: "ストーキングしています"
+ unstalk: "ストーク解除"
mute: "ミュートする"
muted: "ミュートしています"
unmute: "ミュート解除"
@@ -591,7 +606,8 @@ mobile/views/pages/user.vue:
overview: "概要"
timeline: "タイムライン"
media: "メディア"
- is-remote: "このユーザーはMisskeyのユーザーではないため情報が正確ではありません。"
+ is-suspended: "このユーザーは凍結されています。"
+ is-remote: "このユーザーはリモートユーザーです。"
view-remote: "正確な情報を見る"
mobile/views/pages/user/home.vue:
diff --git a/locales/ko.yml b/locales/ko.yml
new file mode 100644
index 0000000000..b6e7898a8e
--- /dev/null
+++ b/locales/ko.yml
@@ -0,0 +1,550 @@
+---
+common:
+ misskey: "Misskeyで皆と共有しよう。"
+ time:
+ unknown: "なぞのじかん"
+ future: "未来"
+ just_now: "たった今"
+ seconds_ago: "{}秒前"
+ minutes_ago: "{}分前"
+ hours_ago: "{}時間前"
+ days_ago: "{}日前"
+ weeks_ago: "{}週間前"
+ months_ago: "{}ヶ月前"
+ years_ago: "{}年前"
+ weekday-short:
+ sunday: "日"
+ monday: "月"
+ tuesday: "火"
+ wednesday: "水"
+ thursday: "木"
+ friday: "金"
+ satruday: "土"
+ reactions:
+ like: "いいね"
+ love: "しゅき"
+ laugh: "笑"
+ hmm: "ふぅ~む"
+ surprise: "わお"
+ congrats: "おめでとう"
+ angry: "おこ"
+ confused: "こまこまのこまり"
+ pudding: "Pudding"
+ delete: "削除"
+ loading: "読み込み中"
+ ok: "わかった"
+ update-available: "Misskeyの新しいバージョンがあります({newer}。現在{current}を利用中)。ページを再度読み込みすると更新が適用されます。"
+ my-token-regenerated: "あなたのトークンが更新されたのでサインアウトします。"
+common/views/components/connect-failed.vue:
+ title: "サーバーに接続できません"
+ description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから{再度お試し}ください。"
+ thanks: "いつもMisskeyをご利用いただきありがとうございます。"
+ troubleshoot: "トラブルシュート"
+common/views/components/connect-failed.troubleshooter.vue:
+ title: "トラブルシューティング"
+ network: "ネットワーク接続"
+ checking-network: "ネットワーク接続を確認中"
+ internet: "インターネット接続"
+ checking-internet: "インターネット接続を確認中"
+ server: "サーバー接続"
+ checking-server: "サーバー接続を確認中"
+ finding: "問題を調べています"
+ no-network: "ネットワークに接続されていません"
+ no-network-desc: "お使いのPCのネットワーク接続が正常か確認してください。"
+ no-internet: "インターネットに接続されていません"
+ no-internet-desc: "ネットワークには接続されていますが、インターネットには接続されていないようです。お使いのPCのインターネット接続が正常か確認してください。"
+ no-server: "Misskeyのサーバーに接続できません"
+ no-server-desc: "お使いのPCのインターネット接続は正常ですが、Misskeyのサーバーには接続できませんでした。サーバーがダウンまたはメンテナンスしている可能性があるので、しばらくしてから再度御アクセスください。"
+ success: "Misskeyのサーバーに接続できました"
+ success-desc: "正常に接続できるようです。ページを再度読み込みしてください。"
+ flush: "キャッシュの削除"
+ set-version: "バージョン指定"
+common/views/components/messaging.vue:
+ search-user: "ユーザーを探す"
+ you: "あなた"
+ no-history: "履歴はありません"
+common/views/components/messaging-room.vue:
+ empty: "このユーザーと話したことはありません"
+ more: "もっと読む"
+ no-history: "これより過去の履歴はありません"
+ resize-form: "ドラッグしてフォームの広さを調整"
+ new-message: "新しいメッセージがあります"
+common/views/components/messaging-room.form.vue:
+ input-message-here: "ここにメッセージを入力"
+ send: "送信"
+ attach-from-local: "PCからファイルを添付する"
+ attach-from-drive: "ドライブからファイルを添付する"
+common/views/components/messaging-room.message.vue:
+ is-read: "既読"
+ deleted: "このメッセージは削除されました"
+common/views/components/nav.vue:
+ about: "Misskeyについて"
+ stats: "統計"
+ status: "ステータス"
+ wiki: "Wiki"
+ donors: "ドナー"
+ repository: "リポジトリ"
+ develop: "開発者"
+ feedback: "フィードバック"
+common/views/components/note-menu.vue:
+ favorite: "お気に入り"
+ pin: "ピン留め"
+ remote: "投稿元で見る"
+common/views/components/poll.vue:
+ vote-to: "「{}」に投票する"
+ vote-count: "{}票"
+ total-users: "{}人が投票"
+ vote: "投票する"
+ show-result: "結果を見る"
+ voted: "投票済み"
+common/views/components/poll-editor.vue:
+ no-only-one-choice: "投票には、選択肢が最低2つ必要です"
+ choice-n: "選択肢{}"
+ remove: "この選択肢を削除"
+ add: "+選択肢を追加"
+ destroy: "投票を破棄"
+common/views/components/reaction-picker.vue:
+ choose-reaction: "リアクションを選択"
+common/views/components/signin.vue:
+ username: "ユーザー名"
+ password: "パスワード"
+ token: "トークン"
+ signing-in: "やってます..."
+ signin: "サインイン"
+common/views/components/signup.vue:
+ username: "ユーザー名"
+ checking: "確認しています..."
+ available: "利用できます"
+ unavailable: "既に利用されています"
+ error: "通信エラー"
+ invalid-format: "a~z、A~Z、0~9、_が使えます"
+ too-short: "1文字以上でお願いします!"
+ too-long: "20文字以内でお願いします"
+ password: "パスワード"
+ password-placeholder: "8文字以上を推奨します"
+ weak-password: "弱いパスワード"
+ normal-password: "まあまあのパスワード"
+ strong-password: "強いパスワード"
+ retype: "再入力"
+ retype-placeholder: "確認のため再入力してください"
+ password-matched: "確認されました"
+ password-not-matched: "一致していません"
+ recaptcha: "認証"
+ create: "アカウント作成"
+ some-error: "何らかの原因によりアカウントの作成に失敗しました。再度お試しください。"
+common/views/components/special-message.vue:
+ new-year: "Happy New Year!"
+ christmas: "Merry Christmas!"
+common/views/components/stream-indicator.vue:
+ connecting: "接続中"
+ reconnecting: "再接続中"
+ connected: "接続完了"
+common/views/components/twitter-setting.vue:
+ description: "お使いのTwitterアカウントをお使いのMisskeyアカウントに接続しておくと、プロフィールでTwitterアカウント情報が表示されるようになったり、Twitterを用いた便利なサインインを利用できるようになります。"
+ connected-to: "次のTwitterアカウントに接続されています"
+ detail: "詳細..."
+ reconnect: "再接続する"
+ connect: "Twitterと接続する"
+ disconnect: "切断する"
+common/views/components/uploader.vue:
+ waiting: "待機中"
+common/views/widgets/broadcast.vue:
+ fetching: "確認中"
+ no-broadcasts: "お知らせはありません"
+ have-a-nice-day: "良い一日を!"
+ next: "次"
+common/views/widgets/donation.vue:
+ title: "寄付のお願い"
+ text: "Misskeyの運営にはドメイン、サーバー等のコストが掛かります。Misskeyは広告を掲載したりしないため、収入を皆様からの寄付に頼っています。もしご興味があれば、{}までご連絡ください。ご協力ありがとうございます。"
+common/views/widgets/photo-stream.vue:
+ title: "フォトストリーム"
+ no-photos: "写真はありません"
+common/views/widgets/server.vue:
+ title: "サーバー情報"
+ toggle: "表示を切り替え"
+desktop/views/components/activity.vue:
+ title: "アクティビティ"
+ toggle: "表示を切り替え"
+desktop/views/components/calendar.vue:
+ title: "{1}年 {2}月"
+ prev: "前の月"
+ next: "次の月"
+ go: "クリックして時間遡行"
+desktop/views/components/drive-window.vue:
+ used: "使用中"
+ drive: "ドライブ"
+desktop/views/components/drive.file.vue:
+ avatar: "アイコン"
+ banner: "バナー"
+ contextmenu:
+ rename: "名前を変更"
+ copy-url: "URLをコピー"
+ download: "ダウンロード"
+ else-files: "その他..."
+ set-as-avatar: "アイコンに設定"
+ set-as-banner: "バナーに設定"
+ open-in-app: "アプリで開く"
+ add-app: "アプリを追加"
+ rename-file: "ファイル名の変更"
+ input-new-file-name: "新しいファイル名を入力してください"
+ copied: "コピー完了"
+ copied-url-to-clipboard: "URLをクリップボードにコピーしました"
+desktop/views/components/drive.folder.vue:
+ unable-to-process: "操作を完了できません"
+ circular-reference-detected: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。"
+ unhandled-error: "不明なエラー"
+ contextmenu:
+ move-to-this-folder: "このフォルダへ移動"
+ show-in-new-window: "新しいウィンドウで表示"
+ rename: "名前を変更"
+ rename-folder: "フォルダ名の変更"
+ input-new-folder-name: "新しいフォルダ名を入力してください"
+desktop/views/components/drive.nav-folder.vue:
+ drive: "ドライブ"
+desktop/views/components/drive.vue:
+ search: "検索"
+ load-more: "もっと読み込む"
+ empty-draghover: "ドロップですか?いいですよ、ボクはカワイイですからね"
+ empty-drive: "ドライブには何もありません。"
+ empty-drive-description: "右クリックして「ファイルをアップロード」を選んだり、ファイルをドラッグ&ドロップすることでもアップロードできます。"
+ empty-folder: "このフォルダーは空です"
+ unable-to-process: "操作を完了できません"
+ circular-reference-detected: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。"
+ unhandled-error: "不明なエラー"
+ url-upload: "URLアップロード"
+ url-of-file: "アップロードしたいファイルのURL"
+ url-upload-requested: "アップロードをリクエストしました"
+ may-take-time: "アップロードが完了するまで時間がかかる場合があります。"
+ create-folder: "フォルダー作成"
+ folder-name: "フォルダー名"
+ contextmenu:
+ create-folder: "フォルダーを作成"
+ upload: "ファイルをアップロード"
+ url-upload: "URLからアップロード"
+desktop/views/components/messaging-window.vue:
+ title: "メッセージ"
+desktop/views/components/notes.note.vue:
+ reposted-by: "{}がRenote"
+ reply: "返信"
+ renote: "Renote"
+ add-reaction: "リアクション"
+ detail: "詳細"
+desktop/views/components/notifications.vue:
+ more: "もっと見る"
+ empty: "ありません!"
+desktop/views/components/post-form.vue:
+ note-placeholder: "いまどうしてる?"
+ reply-placeholder: "この投稿への返信..."
+ quote-placeholder: "この投稿を引用..."
+ note: "投稿"
+ reply: "返信"
+ renote: "Renote"
+ posted: "投稿しました!"
+ replied: "返信しました!"
+ reposted: "Renoteしました!"
+ note-failed: "投稿に失敗しました"
+ reply-failed: "返信に失敗しました"
+ renote-failed: "Renoteに失敗しました"
+ posting: "投稿中"
+ attach-media-from-local: "PCからメディアを添付"
+ attach-media-from-drive: "ドライブからメディアを添付"
+ attach-cancel: "添付取り消し"
+ insert-a-kao: "v(‘ω’)v"
+ create-poll: "投票を作成"
+ text-remain: "残り{}文字"
+desktop/views/components/post-form-window.vue:
+ note: "新規投稿"
+ reply: "返信"
+ attaches: "添付: {}メディア"
+ uploading-media: "{}個のメディアをアップロード中"
+desktop/views/components/renote-form.vue:
+ quote: "引用する..."
+ cancel: "キャンセル"
+ renote: "Renote"
+ reposting: "しています..."
+ success: "Renoteしました!"
+ failure: "Renoteに失敗しました"
+desktop/views/components/renote-form-window.vue:
+ title: "この投稿をRenoteしますか?"
+desktop/views/components/settings.vue:
+ profile: "プロフィール"
+ notification: "通知"
+ apps: "アプリ"
+ mute: "ミュート"
+ drive: "ドライブ"
+ security: "セキュリティ"
+ password: "パスワード"
+ 2fa: "二段階認証"
+ other: "その他"
+ license: "ライセンス"
+desktop/views/components/settings.2fa.vue:
+ intro: "二段階認証を設定すると、サインイン時にパスワードだけでなく、予め登録しておいた物理的なデバイス(例えばあなたのスマートフォンなど)も必要になり、よりセキュリティが向上します。"
+ detail: "詳細..."
+ url: "https://www.google.co.jp/intl/ja/landing/2step/"
+ caution: "登録したデバイスを紛失するなどした場合、Misskeyにサインインできなくなりますのでご注意ください。"
+ register: "デバイスを登録する"
+ already-registered: "既に設定は完了しています。"
+ unregister: "設定を解除"
+ unregistered: "二段階認証が無効になりました。"
+ enter-password: "パスワードを入力してください"
+ authenticator: "まず、Google Authenticatorをお使いのデバイスにインストールします:"
+ howtoinstall: "インストール方法はこちら"
+ scan: "次に、表示されているQRコードをスキャンします:"
+ done: "お使いのデバイスに表示されているトークンを入力して完了します:"
+ submit: "完了"
+ success: "設定が完了しました!"
+ failed: "設定に失敗しました。トークンに誤りがないかご確認ください。"
+ info: "次回サインインからは、同様にパスワードに加えてデバイスに表示されているトークンを入力します。"
+desktop/views/components/settings.api.vue:
+ intro: "APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。"
+ caution: "アカウントを不正利用される可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。"
+ regeneration-of-token: "万が一このトークンが漏れたりその可能性がある場合はトークンを再生成できます。"
+ regenerate-token: "トークンを再生成"
+ enter-password: "パスワードを入力してください"
+desktop/views/components/settings.app.vue:
+ no-apps: "連携しているアプリケーションはありません"
+desktop/views/components/settings.mute.vue:
+ no-users: "ミュートしているユーザーはいません"
+desktop/views/components/settings.password.vue:
+ reset: "パスワードを変更する"
+ enter-current-password: "現在のパスワードを入力してください"
+ enter-new-password: "新しいパスワードを入力してください"
+ enter-new-password-again: "もう一度新しいパスワードを入力してください"
+ not-match: "新しいパスワードが一致しません"
+ changed: "パスワードを変更しました"
+desktop/views/components/settings.profile.vue:
+ avatar: "アイコン"
+ choice-avatar: "画像を選択"
+ name: "名前"
+ location: "場所"
+ description: "自己紹介"
+ birthday: "誕生日"
+ save: "保存"
+desktop/views/components/ui.header.account.vue:
+ profile: "プロフィール"
+ drive: "ドライブ"
+ favorites: "お気に入り"
+ lists: "リスト"
+ customize: "カスタマイズ"
+ settings: "設定"
+ signout: "サインアウト"
+ dark: "闇に飲まれる"
+desktop/views/components/ui.header.nav.vue:
+ home: "ホーム"
+ messaging: "メッセージ"
+ game: "ゲーム"
+desktop/views/components/ui.header.notifications.vue:
+ title: "通知"
+desktop/views/components/ui.header.post.vue:
+ post: "新規投稿"
+desktop/views/components/ui.header.search.vue:
+ placeholder: "検索"
+desktop/views/pages/note.vue:
+ prev: "前の投稿"
+ next: "次の投稿"
+desktop/views/pages/selectdrive.vue:
+ title: "ファイルを選択してください"
+ ok: "決定"
+ cancel: "キャンセル"
+ upload: "PCからドライブにファイルをアップロード"
+desktop/views/pages/user/user.followers-you-know.vue:
+ title: "知り合いのフォロワー"
+ loading: "読み込み中"
+ no-users: "知り合いのフォロワーはいません"
+desktop/views/pages/user/user.friends.vue:
+ title: "よく話すユーザー"
+ loading: "読み込み中"
+ no-users: "よく話すユーザーはいません"
+desktop/views/pages/user/user.header.vue:
+ is-suspended: "このユーザーは凍結されています。"
+ is-remote: "このユーザーはリモートユーザーです。"
+ view-remote: "正確な情報を見る"
+desktop/views/pages/user/user.home.vue:
+ last-used-at: "最終アクセス"
+desktop/views/pages/user/user.photos.vue:
+ title: "フォト"
+ loading: "読み込み中"
+ no-photos: "写真はありません"
+desktop/views/pages/user/user.profile.vue:
+ follows-you: "フォローされています"
+ stalk: "ストークする"
+ stalking: "ストーキングしています"
+ unstalk: "ストーク解除"
+ mute: "ミュートする"
+ muted: "ミュートしています"
+ unmute: "ミュート解除"
+desktop/views/widgets/messaging.vue:
+ title: "メッセージ"
+desktop/views/widgets/notifications.vue:
+ title: "通知"
+ settings: "通知の設定"
+desktop/views/widgets/polls.vue:
+ title: "投票"
+ refresh: "他を見る"
+ nothing: "ありません!"
+desktop/views/widgets/post-form.vue:
+ title: "投稿"
+ note: "投稿"
+ placeholder: "いまどうしてる?"
+desktop/views/widgets/trends.vue:
+ title: "トレンド"
+ refresh: "他を見る"
+ nothing: "ありません!"
+desktop/views/widgets/users.vue:
+ title: "おすすめユーザー"
+ refresh: "他を見る"
+ no-one: "いません!"
+desktop/views/widgets/channel.vue:
+ title: "チャンネル"
+ settings: "ウィジェットの設定"
+ get-started: "右上の歯車をクリックして受信するチャンネルを指定してください"
+mobile/views/components/drive.vue:
+ drive: "ドライブ"
+ used: "使用中"
+ folder-count: "フォルダ"
+ count-separator: "、"
+ file-count: "ファイル"
+ load-more: "もっと読み込む"
+ nothing-in-drive: "ドライブには何もありません"
+ folder-is-empty: "このフォルダは空です"
+mobile/views/components/drive-file-chooser.vue:
+ select-file: "ファイルを選択"
+mobile/views/components/drive-folder-chooser.vue:
+ select-folder: "フォルダーを選択"
+mobile/views/components/drive.file-detail.vue:
+ download: "ダウンロード"
+ rename: "名前を変更"
+ move: "移動"
+ hash: "ハッシュ (md5)"
+ exif: "EXIF"
+mobile/views/components/follow-button.vue:
+ follow: "フォロー"
+ unfollow: "フォロー解除"
+mobile/views/components/note.vue:
+ reposted-by: "{}がRenote"
+mobile/views/components/note-detail.vue:
+ reply: "返信"
+ reaction: "リアクション"
+mobile/views/components/notifications.vue:
+ more: "もっと見る"
+ empty: "ありません!"
+mobile/views/components/post-form.vue:
+ submit: "投稿"
+ reply-placeholder: "この投稿への返信..."
+ note-placeholder: "いまどうしてる?"
+mobile/views/components/sub-note-content.vue:
+ media-count: "{}個のメディア"
+ poll: "投票"
+mobile/views/components/timeline.vue:
+ empty: "投稿がありません"
+ load-more: "もっと"
+mobile/views/components/ui.nav.vue:
+ home: "ホーム"
+ notifications: "通知"
+ messaging: "メッセージ"
+ search: "検索"
+ drive: "ドライブ"
+ settings: "設定"
+ about: "Misskeyについて"
+mobile/views/components/user-timeline.vue:
+ no-notes: "このユーザーは投稿していないようです。"
+ no-notes-with-media: "メディア付き投稿はありません。"
+ load-more: "もっと"
+mobile/views/components/users-list.vue:
+ all: "すべて"
+ known: "知り合い"
+ load-more: "もっと"
+mobile/views/pages/drive.vue:
+ drive: "ドライブ"
+mobile/views/pages/followers.vue:
+ followers-of: "{}のフォロワー"
+mobile/views/pages/following.vue:
+ following-of: "{}のフォロー"
+mobile/views/pages/home.vue:
+ timeline: "タイムライン"
+mobile/views/pages/messaging.vue:
+ messaging: "メッセージ"
+mobile/views/pages/messaging-room.vue:
+ messaging: "メッセージ"
+mobile/views/pages/note.vue:
+ title: "投稿"
+ prev: "前の投稿"
+ next: "次の投稿"
+mobile/views/pages/notifications.vue:
+ notifications: "通知"
+ read-all: "すべての通知を既読にしますか?"
+mobile/views/pages/profile-setting.vue:
+ title: "プロフィール設定"
+ will-be-published: "これらのプロフィールは公開されます。"
+ name: "名前"
+ location: "場所"
+ description: "自己紹介"
+ birthday: "誕生日"
+ avatar: "アイコン"
+ banner: "バナー"
+ avatar-saved: "アイコンを保存しました"
+ banner-saved: "バナーを保存しました"
+ set-avatar: "アイコンを選択する"
+ set-banner: "バナーを選択する"
+ save: "保存"
+ saved: "プロフィールを保存しました"
+mobile/views/pages/search.vue:
+ search: "検索"
+ empty: "「{}」に関する投稿は見つかりませんでした。"
+mobile/views/pages/selectdrive.vue:
+ select-file: "ファイルを選択"
+mobile/views/pages/settings.vue:
+ signed-in-as: "{}としてサインイン中"
+ profile: "プロフィール"
+ twitter: "Twitter連携"
+ signin-history: "サインイン履歴"
+ settings: "設定"
+ signout: "サインアウト"
+mobile/views/pages/user.vue:
+ follows-you: "フォローされています"
+ following: "フォロー"
+ followers: "フォロワー"
+ notes: "投稿"
+ overview: "概要"
+ timeline: "タイムライン"
+ media: "メディア"
+ is-suspended: "このユーザーは凍結されています。"
+ is-remote: "このユーザーはリモートユーザーです。"
+ view-remote: "正確な情報を見る"
+mobile/views/pages/user/home.vue:
+ recent-notes: "最近の投稿"
+ images: "画像"
+ activity: "アクティビティ"
+ keywords: "キーワード"
+ domains: "頻出ドメイン"
+ frequently-replied-users: "よく会話するユーザー"
+ followers-you-know: "知り合いのフォロワー"
+ last-used-at: "最終ログイン"
+mobile/views/pages/user/home.followers-you-know.vue:
+ loading: "読み込み中"
+ no-users: "知り合いのユーザーはいません"
+mobile/views/pages/user/home.friends.vue:
+ loading: "読み込み中"
+ no-users: "よく会話するユーザーはいません"
+mobile/views/pages/user/home.notes.vue:
+ loading: "読み込み中"
+ no-notes: "投稿はありません"
+mobile/views/pages/user/home.photos.vue:
+ loading: "読み込み中"
+ no-photos: "写真はありません"
+docs:
+ edit-this-page-on-github: "間違いや改善点を見つけましたか?"
+ edit-this-page-on-github-link: "このページをGitHubで編集"
+ api:
+ entities:
+ properties: "プロパティ"
+ endpoints:
+ params: "パラメータ"
+ res: "レスポンス"
+ props:
+ name: "名前"
+ type: "型"
+ optional: "オプション"
+ description: "説明"
+ yes: "はい"
+ no: "いいえ"
diff --git a/locales/pl.yml b/locales/pl.yml
new file mode 100644
index 0000000000..3474a7355a
--- /dev/null
+++ b/locales/pl.yml
@@ -0,0 +1,550 @@
+---
+common:
+ misskey: "Dziel się zawartością z innymi korzystając z Misskey."
+ time:
+ unknown: "nieznany"
+ future: "w przyszłości"
+ just_now: "teraz"
+ seconds_ago: "{} sek. temu"
+ minutes_ago: "{} min. temu"
+ hours_ago: "{} godz. temu"
+ days_ago: "{} dni temu"
+ weeks_ago: "{} tyg. temu"
+ months_ago: "{} mies. temu"
+ years_ago: "{} lat temu"
+ weekday-short:
+ sunday: "N"
+ monday: "Pn"
+ tuesday: "W"
+ wednesday: "Ś"
+ thursday: "C"
+ friday: "P"
+ satruday: "S"
+ reactions:
+ like: "Lubię"
+ love: "Kocham"
+ laugh: "Śmieszne"
+ hmm: "Hmm…?"
+ surprise: "Wow"
+ congrats: "Gratuluję!"
+ angry: "Wściekły"
+ confused: "Zmieszany"
+ pudding: "Pudding"
+ delete: "Usuń"
+ loading: "Ładowanie"
+ ok: "OK"
+ update-available: "Nowa wersja Misskey jest dostępna ({newer}, obecna to {current}). Odśwież stronę, aby zastosować aktualizację."
+ my-token-regenerated: "Twój token został wygenerowany. Zostaniesz wylogowany."
+common/views/components/connect-failed.vue:
+ title: "Nie udało się połączyć z serwerem"
+ description: "Wystąpił problem z Twoim połączeniem z Internetem, lub z serwerem. {Spróbuj ponownie} wkrótce."
+ thanks: "Dziękujemy za korzystanie z Misskey."
+ troubleshoot: "Rozwiązywanie problemów"
+common/views/components/connect-failed.troubleshooter.vue:
+ title: "Rozwiązywanie problemów"
+ network: "Połączenie z siecią"
+ checking-network: "Sprawdzanie połączenia sieciowego"
+ internet: "Połączenie z Internetem"
+ checking-internet: "Sprawdzanie połączenia z Internetem"
+ server: "Połączenie z serwerem"
+ checking-server: "Sprawdzanie połączenia z serwerem"
+ finding: "Wyszukiwanie problemu"
+ no-network: "Brak połączenia z siecią"
+ no-network-desc: "Upewnij się, że jesteś połączony z siecią."
+ no-internet: "Brak połączenia z Internetem"
+ no-internet-desc: "Upewnij się, że jesteś połączony z Internetem."
+ no-server: "Nie udało się połączyć z serwerem"
+ no-server-desc: "Połączenie sieciowe działa, ale nie udało się połączyć z serwerem Misskey. Możliwe że serwer nie działa lub trwają prace konserwacyjne, spróbuj ponownie później."
+ success: "Pomyślnie połączono z serwerem Misskey"
+ success-desc: "Wygląda na to, że udało się połączyć. Odśwież stronę."
+ flush: "Wyczyść pamięć podręczną"
+ set-version: "Określ wersję"
+common/views/components/messaging.vue:
+ search-user: "Znajdź użytkownika"
+ you: "Ty"
+ no-history: "Brak historii"
+common/views/components/messaging-room.vue:
+ empty: "Brak konwersacji"
+ more: "Więcej"
+ no-history: "Brak dalszej historii"
+ resize-form: "Przeciągnij aby zmienić rozmiar"
+ new-message: "Nowa wiadomość"
+common/views/components/messaging-room.form.vue:
+ input-message-here: "Wprowadź wiadomość tutaj"
+ send: "Wyślij"
+ attach-from-local: "Załącz pliki z komputera"
+ attach-from-drive: "Załącz pliki z dysku"
+common/views/components/messaging-room.message.vue:
+ is-read: "Przeczytano"
+ deleted: "Wiadomość została usunięta"
+common/views/components/nav.vue:
+ about: "O stronie"
+ stats: "Statystyki"
+ status: "Stan"
+ wiki: "Wiki"
+ donors: "Sponsorzy"
+ repository: "Repozytorium"
+ develop: "Autorzy"
+ feedback: "Podziel się opinią"
+common/views/components/note-menu.vue:
+ favorite: "Dodaj do ulubionych"
+ pin: "Przypnij do profilu"
+ remote: "投稿元で見る"
+common/views/components/poll.vue:
+ vote-to: "Zagłosuj na '{}'"
+ vote-count: "{} głosów"
+ total-users: "{} głosujących"
+ vote: "Zagłosuj"
+ show-result: "Pokaż wyniki"
+ voted: "Zagłosowano"
+common/views/components/poll-editor.vue:
+ no-only-one-choice: "Musisz wprowadzić dwie lub więcej opcji."
+ choice-n: "Opcja {}"
+ remove: "Usuń tą opcję"
+ add: "+ Dodaj opcję"
+ destroy: "Usuń ankietę"
+common/views/components/reaction-picker.vue:
+ choose-reaction: "Wybierz reakcję"
+common/views/components/signin.vue:
+ username: "Nazwa użytkownika"
+ password: "Hasło"
+ token: "Token"
+ signing-in: "Logowanie…"
+ signin: "Zaloguj"
+common/views/components/signup.vue:
+ username: "Nazwa użytkownika"
+ checking: "Sprawdzanie…"
+ available: "Dostępna"
+ unavailable: "Niedostępna"
+ error: "Błąd sieci"
+ invalid-format: "Może zawierać litery, cyfry i myślniki."
+ too-short: "Wprowadź przynajmniej jeden znak"
+ too-long: "Nazwa nie może zawierać więcej niż 20 znaków"
+ password: "Hasło"
+ password-placeholder: "Zalecamy korzystanie z hasła zawierającego przynajmniej 8 znaków."
+ weak-password: "Słabe"
+ normal-password: "Średnie"
+ strong-password: "Silne"
+ retype: "Powtórz hasło"
+ retype-placeholder: "Potwierdź hasło"
+ password-matched: "OK"
+ password-not-matched: "Hasła nie zgadzają się"
+ recaptcha: "Weryfikacja"
+ create: "Utwórz konto"
+ some-error: "Nie udało się utworzyć konta. Spróbuj ponownie."
+common/views/components/special-message.vue:
+ new-year: "Szczęśliwego nowego roku!"
+ christmas: "Wesołych świąt!"
+common/views/components/stream-indicator.vue:
+ connecting: "Łączenie"
+ reconnecting: "Ponowne łączenie"
+ connected: "Połączono"
+common/views/components/twitter-setting.vue:
+ description: "Jeżeli połączysz konto Twittera z kontem Misskey, informacje z Twittera będą widoczne na Twoim profilu i będziesz mógł logować się z użyciem Twittera."
+ connected-to: "Jesteś połączony z tym kontem Twittera"
+ detail: "Szczegóły…"
+ reconnect: "Połącz ponownie"
+ connect: "Połącz z kontem Twittera"
+ disconnect: "Rozłącz"
+common/views/components/uploader.vue:
+ waiting: "Oczekiwanie"
+common/views/widgets/broadcast.vue:
+ fetching: "確認中"
+ no-broadcasts: "Brak transmisji"
+ have-a-nice-day: "Miłego dnia!"
+ next: "Dalej"
+common/views/widgets/donation.vue:
+ title: "Dotacje"
+ text: "Aby utrzymywać Misskey, płacimy za domenę, serwery i nie tylko… Nie zarabiamy na tym, więc byłoby nam miło, gdybyśmy uzyskali od Ciebie dotację. Jeżeli jesteś zainteresowany, skontaktuj się z {}. Dziękujemy za wsparcie!"
+common/views/widgets/photo-stream.vue:
+ title: "フォトストリーム"
+ no-photos: "Brak zdjęć"
+common/views/widgets/server.vue:
+ title: "Informacje o serwerze"
+ toggle: "Przełącz widok"
+desktop/views/components/activity.vue:
+ title: "Aktywność"
+ toggle: "Przełącz widok"
+desktop/views/components/calendar.vue:
+ title: "{1} / {2}"
+ prev: "Poprzedni miesiąc"
+ next: "Następny miesiąc"
+ go: "Naciśnij, aby przejść"
+desktop/views/components/drive-window.vue:
+ used: "wykorzystane"
+ drive: "Dysk"
+desktop/views/components/drive.file.vue:
+ avatar: "Awatar"
+ banner: "Baner"
+ contextmenu:
+ rename: "Zmień nazwę"
+ copy-url: "Skopiuj adres"
+ download: "Pobierz"
+ else-files: "Inne"
+ set-as-avatar: "Ustaw jako awatar"
+ set-as-banner: "Ustaw jako baner"
+ open-in-app: "Otwórz w aplikacji"
+ add-app: "Dodaj aplikację"
+ rename-file: "Zmień nazwę pliku"
+ input-new-file-name: "Wprowadź nową nazwę"
+ copied: "Skopiowano"
+ copied-url-to-clipboard: "Skopiowano adres do schowka"
+desktop/views/components/drive.folder.vue:
+ unable-to-process: "Nie udało się ukończyć działania."
+ circular-reference-detected: "Docelowy katalog znajduje się w katalogu, który chcesz przenieść."
+ unhandled-error: "Nieznany błąd"
+ contextmenu:
+ move-to-this-folder: "Przenieś do tego katalogu"
+ show-in-new-window: "Otwórz w nowym oknie"
+ rename: "Zmień nazwę"
+ rename-folder: "Zmień nazwę katalogu"
+ input-new-folder-name: "Wprowadź nową nazwę"
+desktop/views/components/drive.nav-folder.vue:
+ drive: "Dysk"
+desktop/views/components/drive.vue:
+ search: "Szukaj"
+ load-more: "Załaduj więcej"
+ empty-draghover: "ドロップですか?いいですよ、ボクはカワイイですからね"
+ empty-drive: "Twój dysk jest pusty"
+ empty-drive-description: "Możesz wysłać plik klikając prawym przyciskiem myszy i wybierając \"Wyślij plik\" lub przeciągnąć plik i upuścić w tym oknie."
+ empty-folder: "Ten katalog jest posty"
+ unable-to-process: "Nie udało się dokończyć działania."
+ circular-reference-detected: "Ten katalog znajduje się w katalogu, który chcesz przenieść."
+ unhandled-error: "Nieznany błąd"
+ url-upload: "Wyślij z adresu"
+ url-of-file: "Adres URL pliku, który chcesz wysłać"
+ url-upload-requested: "Zaplanowano wysyłanie"
+ may-take-time: "Może trochę potrwać, zanim wysyłanie zostanie ukończone."
+ create-folder: "Utwórz katalog"
+ folder-name: "Nazwa katalogu"
+ contextmenu:
+ create-folder: "Utwórz katalog"
+ upload: "Wyślij plik"
+ url-upload: "Wyślij z adresu URL"
+desktop/views/components/messaging-window.vue:
+ title: "Wiadomości"
+desktop/views/components/notes.note.vue:
+ reposted-by: "Udostępniono przez {}"
+ reply: "Odpowiedz"
+ renote: "Przeredaguj"
+ add-reaction: "Dodaj reakcję"
+ detail: "Pokaż szczegóły"
+desktop/views/components/notifications.vue:
+ more: "Więcej"
+ empty: "Brak powiadomień"
+desktop/views/components/post-form.vue:
+ note-placeholder: "Co się dzieje?"
+ reply-placeholder: "Odpowiedz na ten wpis…"
+ quote-placeholder: "Zacytuj ten wpis…"
+ note: "Wyślij"
+ reply: "Odpowiedz"
+ renote: "Przeredaguj"
+ posted: "Opublikowano!"
+ replied: "Odpowiedziano!"
+ reposted: "Udostępniono!"
+ note-failed: "Nie udało się wysłać"
+ reply-failed: "Nie udało się odpowiedzieć"
+ renote-failed: "Nie udało się przeredagować"
+ posting: "Wysyłanie"
+ attach-media-from-local: "Załącz zawartość multimedialną z komputera"
+ attach-media-from-drive: "Załącz zawartość multimedialną z dysku"
+ attach-cancel: "Usuń załącznik"
+ insert-a-kao: "v(‘ω’)v"
+ create-poll: "Utwórz ankietę"
+ text-remain: "pozostałe znaki: {}"
+desktop/views/components/post-form-window.vue:
+ note: "Nowy wpis"
+ reply: "Odpowiedz"
+ attaches: "{} załączników multimedialnych"
+ uploading-media: "Wysyłanie {} treści multimedialnych"
+desktop/views/components/renote-form.vue:
+ quote: "Cytuj…"
+ cancel: "Anuluj"
+ renote: "Przeredaguj"
+ reposting: "Udostępnianie…"
+ success: "Udostępniono!"
+ failure: "Nie udało się przeredagować"
+desktop/views/components/renote-form-window.vue:
+ title: "Czy na pewno chcesz przeredagować ten wpis?"
+desktop/views/components/settings.vue:
+ profile: "Profil"
+ notification: "Powiadomienia"
+ apps: "Aplikacje"
+ mute: "Wyciszanie"
+ drive: "Dysk"
+ security: "Bezpieczeństwo"
+ password: "Hasło"
+ 2fa: "Uwierzytelnianie dwuetapowe"
+ other: "Inne"
+ license: "Licencja"
+desktop/views/components/settings.2fa.vue:
+ intro: "Jeżeli skonfigurujesz uwierzytelnianie dwuetapowe, aby zablokować się będziesz potrzebować (oprócz hasła) kodu ze skonfigurowanego urządzenia (np. smartfonu), co zwiększy bezpieczeństwo."
+ detail: "Zobacz szczegóły…"
+ url: "https://www.google.com/landing/2step/"
+ caution: "Jeżeli stracisz dostęp do urządzenia, nie będziesz mógł logować się do Misskey!"
+ register: "Zarejestruj urządzenie"
+ already-registered: "Urządzenie jest już zarejestrowane"
+ unregister: "Wyłącz"
+ unregistered: "Wyłączono uwierzytelnianie dwuetapowe."
+ enter-password: "Wprowadź hasło"
+ authenticator: "Na początek musisz zainstalować Google Authenticator na swoim urządzeniu:"
+ howtoinstall: "Jak zainstalować"
+ scan: "Później, zeskanuje ten kod QR:"
+ done: "Wprowadź token wyświetlony na Twoim urządzeniu:"
+ submit: "Wyślij"
+ success: "Pomyślnie ukończono konfigurację!"
+ failed: "Nie udało się skonfigurować uwierzytelniania dwuetapowego, upewnij się że wprowadziłeś prawidłowy token."
+ info: "Od teraz, wprowadzaj token wyświetlany na urządzeniu przy każdym logowaniu do Misskey."
+desktop/views/components/settings.api.vue:
+ intro: "APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。"
+ caution: "Nie pokazuj tego tokenu osobom trzecim (nie wprowadzaj go nigdzie indziej), aby konto nie trafiło w niepowołane ręce."
+ regeneration-of-token: "W przypadku wycieku tokenu, możesz wygenerować nowy."
+ regenerate-token: "Wygeneruj nowy token"
+ enter-password: "Wprowadź hasło"
+desktop/views/components/settings.app.vue:
+ no-apps: "Brak zautoryzowanych aplikacji"
+desktop/views/components/settings.mute.vue:
+ no-users: "Brak wyciszonych użytkowników"
+desktop/views/components/settings.password.vue:
+ reset: "Zmień hasło"
+ enter-current-password: "Wprowadź obecne hasło"
+ enter-new-password: "Wprowadź nowe hasło"
+ enter-new-password-again: "Wprowadź ponownie nowe hasło"
+ not-match: "Nowe hasła nie pasują do siebie"
+ changed: "Pomyślnie zmieniono hasło"
+desktop/views/components/settings.profile.vue:
+ avatar: "Awatar"
+ choice-avatar: "Wybierz obraz"
+ name: "Nazwa"
+ location: "Lokalizacja"
+ description: "Opis"
+ birthday: "Data urodzenia"
+ save: "Aktualizuj profil"
+desktop/views/components/ui.header.account.vue:
+ profile: "Twój profil"
+ drive: "Dysk"
+ favorites: "Ulubione"
+ lists: "Listy"
+ customize: "Dostosuj"
+ settings: "Ustawienia"
+ signout: "Wyloguj się"
+ dark: "Sprowadź ciemność"
+desktop/views/components/ui.header.nav.vue:
+ home: "Strona główna"
+ messaging: "Wiadomości"
+ game: "Gra"
+desktop/views/components/ui.header.notifications.vue:
+ title: "Powiadomienia"
+desktop/views/components/ui.header.post.vue:
+ post: "Utwórz nowy wpis"
+desktop/views/components/ui.header.search.vue:
+ placeholder: "Szukaj"
+desktop/views/pages/note.vue:
+ prev: "Poprzedni wpis"
+ next: "Następny wpis"
+desktop/views/pages/selectdrive.vue:
+ title: "Wybierz plik(i)"
+ ok: "OK"
+ cancel: "Anuluj"
+ upload: "Wyślij pliki z Twojego komputera"
+desktop/views/pages/user/user.followers-you-know.vue:
+ title: "Śledzący których znasz"
+ loading: "Ładowanie"
+ no-users: "Brak użytkowników"
+desktop/views/pages/user/user.friends.vue:
+ title: "Najbardziej aktywni"
+ loading: "Ładowanie"
+ no-users: "Brak użytkowników"
+desktop/views/pages/user/user.header.vue:
+ is-suspended: "To konto zostało zawieszone."
+ is-remote: "To jest użytkownik zdalnej instancji, informacje mogą nie być w pełni dokładne."
+ view-remote: "Wyświetl dokładne informacje"
+desktop/views/pages/user/user.home.vue:
+ last-used-at: "Ostatnio aktywny: "
+desktop/views/pages/user/user.photos.vue:
+ title: "Zdjęcia"
+ loading: "Ładowanie"
+ no-photos: "Brak zdjęć"
+desktop/views/pages/user/user.profile.vue:
+ follows-you: "Śledzi Cię"
+ stalk: "Stalkuj"
+ stalking: "Stalkujesz"
+ unstalk: "Przestań stalkować"
+ mute: "Wycisz"
+ muted: "Wyciszyłeś"
+ unmute: "Cofnij wyciszenie"
+desktop/views/widgets/messaging.vue:
+ title: "メッセージ"
+desktop/views/widgets/notifications.vue:
+ title: "Powiadomienia"
+ settings: "Ustawienia"
+desktop/views/widgets/polls.vue:
+ title: "Ankiety"
+ refresh: "Pokaż inne"
+ nothing: "Pusto"
+desktop/views/widgets/post-form.vue:
+ title: "Wpis"
+ note: "Wpis"
+ placeholder: "Co się dzieje?"
+desktop/views/widgets/trends.vue:
+ title: "Na czasie"
+ refresh: "Pokaż inne"
+ nothing: "Pusto"
+desktop/views/widgets/users.vue:
+ title: "Polecani użytkownicy"
+ refresh: "Pokaż innych"
+ no-one: "Pusto"
+desktop/views/widgets/channel.vue:
+ title: "Kanał"
+ settings: "Ustawienia widżetu"
+ get-started: "右上の歯車をクリックして受信するチャンネルを指定してください"
+mobile/views/components/drive.vue:
+ drive: "Dysk"
+ used: "użyto"
+ folder-count: "Katalog(i)"
+ count-separator: ", "
+ file-count: "Plik(i)"
+ load-more: "Załaduj więcej"
+ nothing-in-drive: "Pusto"
+ folder-is-empty: "Ten katalog jest pusty"
+mobile/views/components/drive-file-chooser.vue:
+ select-file: "Wybierz plik"
+mobile/views/components/drive-folder-chooser.vue:
+ select-folder: "Wybierz katalog"
+mobile/views/components/drive.file-detail.vue:
+ download: "Pobierz"
+ rename: "Zmień nazwę"
+ move: "Przenieś"
+ hash: "Hash (md5)"
+ exif: "EXIF"
+mobile/views/components/follow-button.vue:
+ follow: "Śledź"
+ unfollow: "Przestań śledzić"
+mobile/views/components/note.vue:
+ reposted-by: "{}がRenote"
+mobile/views/components/note-detail.vue:
+ reply: "Odpowiedz"
+ reaction: "Reakcja"
+mobile/views/components/notifications.vue:
+ more: "Więcej"
+ empty: "Brak powiadomień"
+mobile/views/components/post-form.vue:
+ submit: "Wyślij"
+ reply-placeholder: "Odpowiedź na ten wpis…"
+ note-placeholder: "Co się dzieje?"
+mobile/views/components/sub-note-content.vue:
+ media-count: "{} zawartości multimedialnej"
+ poll: "Ankieta"
+mobile/views/components/timeline.vue:
+ empty: "Brak wpisów"
+ load-more: "Więcej"
+mobile/views/components/ui.nav.vue:
+ home: "Strona główna"
+ notifications: "Powiadomienia"
+ messaging: "Wiadomości"
+ search: "Szukaj"
+ drive: "Dysk"
+ settings: "Ustawienia"
+ about: "O Misskey"
+mobile/views/components/user-timeline.vue:
+ no-notes: "Wygląda na to, że ten użytkownik nie opublikował jeszcze niczego"
+ no-notes-with-media: "Brak wpisów z zawartością multimedialną"
+ load-more: "Więcej"
+mobile/views/components/users-list.vue:
+ all: "Wszyscy"
+ known: "Znasz"
+ load-more: "Więcej"
+mobile/views/pages/drive.vue:
+ drive: "Dysk"
+mobile/views/pages/followers.vue:
+ followers-of: "Śledzący {}"
+mobile/views/pages/following.vue:
+ following-of: "Śledzeni przez {}"
+mobile/views/pages/home.vue:
+ timeline: "Oś czasu"
+mobile/views/pages/messaging.vue:
+ messaging: "Wiadomości"
+mobile/views/pages/messaging-room.vue:
+ messaging: "Wiadomości"
+mobile/views/pages/note.vue:
+ title: "Wyślij"
+ prev: "Poprzedni wpis"
+ next: "Następny wpis"
+mobile/views/pages/notifications.vue:
+ notifications: "Powiadomienia"
+ read-all: "Czy na pewno chcesz oznaczyć wszystkie powiadomienia jako przeczytane?"
+mobile/views/pages/profile-setting.vue:
+ title: "Ustawienia profilu"
+ will-be-published: "Te ustawienia profilu zostaną zaktualizowane."
+ name: "Nazwa"
+ location: "Lokalizacja"
+ description: "Opis"
+ birthday: "Data urodzenia"
+ avatar: "Awatar"
+ banner: "Baner"
+ avatar-saved: "Pomyślnie zaktualizowano awatar"
+ banner-saved: "Pomyślnie zaktualizowano baner"
+ set-avatar: "Wybierz awatar"
+ set-banner: "Wybierz baner"
+ save: "Zapisz"
+ saved: "Pomyślnie zaktualizowano profil"
+mobile/views/pages/search.vue:
+ search: "Szukaj"
+ empty: "Nie znaleziono wpisów zawierających '{}'"
+mobile/views/pages/selectdrive.vue:
+ select-file: "Wybierz plik"
+mobile/views/pages/settings.vue:
+ signed-in-as: "Zalogowany jako {}"
+ profile: "Profil"
+ twitter: "Twitter連携"
+ signin-history: "Historia logowań"
+ settings: "Ustawienia"
+ signout: "Wyloguj"
+mobile/views/pages/user.vue:
+ follows-you: "Śledzi Cię"
+ following: "Śledzeni"
+ followers: "Śledzący"
+ notes: "Wpisy"
+ overview: "Przegląd"
+ timeline: "Oś czasu"
+ media: "Zawartość multimedialna"
+ is-suspended: "To konto zostało zablokowane"
+ is-remote: "To jest użytkownik zdalnej instancji, informacje mogą nie być w pełni dokładne."
+ view-remote: "Wyświetl dokładne informacje"
+mobile/views/pages/user/home.vue:
+ recent-notes: "Ostatnie wpisy"
+ images: "Zdjęcia"
+ activity: "Aktywność"
+ keywords: "Słowa kluczowe"
+ domains: "Domeny"
+ frequently-replied-users: "Często aktywni użytkownicy"
+ followers-you-know: "Śledzący których znasz"
+ last-used-at: "Ostatnio aktywny:"
+mobile/views/pages/user/home.followers-you-know.vue:
+ loading: "Ładowanie"
+ no-users: "Brak użytkowników"
+mobile/views/pages/user/home.friends.vue:
+ loading: "Ładowanie"
+ no-users: "Brak użytkowników"
+mobile/views/pages/user/home.notes.vue:
+ loading: "Ładowanie"
+ no-notes: "Brak wpisów"
+mobile/views/pages/user/home.photos.vue:
+ loading: "Ładowanie"
+ no-photos: "Brak zdjęć"
+docs:
+ edit-this-page-on-github: "Znalazłeś błąd lub chcesz pomóc w tworzeniu dokumentacji?"
+ edit-this-page-on-github-link: "Edytuj stronę na GitHubie!"
+ api:
+ entities:
+ properties: "Właściwości"
+ endpoints:
+ params: "Parametry"
+ res: "Odpowiedź"
+ props:
+ name: "Nazwa"
+ type: "Rodzaj"
+ optional: "Nieobowiązkowy"
+ description: "Opis"
+ yes: "Tak"
+ no: "Nie"
diff --git a/locales/ru.yml b/locales/ru.yml
new file mode 100644
index 0000000000..b6e7898a8e
--- /dev/null
+++ b/locales/ru.yml
@@ -0,0 +1,550 @@
+---
+common:
+ misskey: "Misskeyで皆と共有しよう。"
+ time:
+ unknown: "なぞのじかん"
+ future: "未来"
+ just_now: "たった今"
+ seconds_ago: "{}秒前"
+ minutes_ago: "{}分前"
+ hours_ago: "{}時間前"
+ days_ago: "{}日前"
+ weeks_ago: "{}週間前"
+ months_ago: "{}ヶ月前"
+ years_ago: "{}年前"
+ weekday-short:
+ sunday: "日"
+ monday: "月"
+ tuesday: "火"
+ wednesday: "水"
+ thursday: "木"
+ friday: "金"
+ satruday: "土"
+ reactions:
+ like: "いいね"
+ love: "しゅき"
+ laugh: "笑"
+ hmm: "ふぅ~む"
+ surprise: "わお"
+ congrats: "おめでとう"
+ angry: "おこ"
+ confused: "こまこまのこまり"
+ pudding: "Pudding"
+ delete: "削除"
+ loading: "読み込み中"
+ ok: "わかった"
+ update-available: "Misskeyの新しいバージョンがあります({newer}。現在{current}を利用中)。ページを再度読み込みすると更新が適用されます。"
+ my-token-regenerated: "あなたのトークンが更新されたのでサインアウトします。"
+common/views/components/connect-failed.vue:
+ title: "サーバーに接続できません"
+ description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから{再度お試し}ください。"
+ thanks: "いつもMisskeyをご利用いただきありがとうございます。"
+ troubleshoot: "トラブルシュート"
+common/views/components/connect-failed.troubleshooter.vue:
+ title: "トラブルシューティング"
+ network: "ネットワーク接続"
+ checking-network: "ネットワーク接続を確認中"
+ internet: "インターネット接続"
+ checking-internet: "インターネット接続を確認中"
+ server: "サーバー接続"
+ checking-server: "サーバー接続を確認中"
+ finding: "問題を調べています"
+ no-network: "ネットワークに接続されていません"
+ no-network-desc: "お使いのPCのネットワーク接続が正常か確認してください。"
+ no-internet: "インターネットに接続されていません"
+ no-internet-desc: "ネットワークには接続されていますが、インターネットには接続されていないようです。お使いのPCのインターネット接続が正常か確認してください。"
+ no-server: "Misskeyのサーバーに接続できません"
+ no-server-desc: "お使いのPCのインターネット接続は正常ですが、Misskeyのサーバーには接続できませんでした。サーバーがダウンまたはメンテナンスしている可能性があるので、しばらくしてから再度御アクセスください。"
+ success: "Misskeyのサーバーに接続できました"
+ success-desc: "正常に接続できるようです。ページを再度読み込みしてください。"
+ flush: "キャッシュの削除"
+ set-version: "バージョン指定"
+common/views/components/messaging.vue:
+ search-user: "ユーザーを探す"
+ you: "あなた"
+ no-history: "履歴はありません"
+common/views/components/messaging-room.vue:
+ empty: "このユーザーと話したことはありません"
+ more: "もっと読む"
+ no-history: "これより過去の履歴はありません"
+ resize-form: "ドラッグしてフォームの広さを調整"
+ new-message: "新しいメッセージがあります"
+common/views/components/messaging-room.form.vue:
+ input-message-here: "ここにメッセージを入力"
+ send: "送信"
+ attach-from-local: "PCからファイルを添付する"
+ attach-from-drive: "ドライブからファイルを添付する"
+common/views/components/messaging-room.message.vue:
+ is-read: "既読"
+ deleted: "このメッセージは削除されました"
+common/views/components/nav.vue:
+ about: "Misskeyについて"
+ stats: "統計"
+ status: "ステータス"
+ wiki: "Wiki"
+ donors: "ドナー"
+ repository: "リポジトリ"
+ develop: "開発者"
+ feedback: "フィードバック"
+common/views/components/note-menu.vue:
+ favorite: "お気に入り"
+ pin: "ピン留め"
+ remote: "投稿元で見る"
+common/views/components/poll.vue:
+ vote-to: "「{}」に投票する"
+ vote-count: "{}票"
+ total-users: "{}人が投票"
+ vote: "投票する"
+ show-result: "結果を見る"
+ voted: "投票済み"
+common/views/components/poll-editor.vue:
+ no-only-one-choice: "投票には、選択肢が最低2つ必要です"
+ choice-n: "選択肢{}"
+ remove: "この選択肢を削除"
+ add: "+選択肢を追加"
+ destroy: "投票を破棄"
+common/views/components/reaction-picker.vue:
+ choose-reaction: "リアクションを選択"
+common/views/components/signin.vue:
+ username: "ユーザー名"
+ password: "パスワード"
+ token: "トークン"
+ signing-in: "やってます..."
+ signin: "サインイン"
+common/views/components/signup.vue:
+ username: "ユーザー名"
+ checking: "確認しています..."
+ available: "利用できます"
+ unavailable: "既に利用されています"
+ error: "通信エラー"
+ invalid-format: "a~z、A~Z、0~9、_が使えます"
+ too-short: "1文字以上でお願いします!"
+ too-long: "20文字以内でお願いします"
+ password: "パスワード"
+ password-placeholder: "8文字以上を推奨します"
+ weak-password: "弱いパスワード"
+ normal-password: "まあまあのパスワード"
+ strong-password: "強いパスワード"
+ retype: "再入力"
+ retype-placeholder: "確認のため再入力してください"
+ password-matched: "確認されました"
+ password-not-matched: "一致していません"
+ recaptcha: "認証"
+ create: "アカウント作成"
+ some-error: "何らかの原因によりアカウントの作成に失敗しました。再度お試しください。"
+common/views/components/special-message.vue:
+ new-year: "Happy New Year!"
+ christmas: "Merry Christmas!"
+common/views/components/stream-indicator.vue:
+ connecting: "接続中"
+ reconnecting: "再接続中"
+ connected: "接続完了"
+common/views/components/twitter-setting.vue:
+ description: "お使いのTwitterアカウントをお使いのMisskeyアカウントに接続しておくと、プロフィールでTwitterアカウント情報が表示されるようになったり、Twitterを用いた便利なサインインを利用できるようになります。"
+ connected-to: "次のTwitterアカウントに接続されています"
+ detail: "詳細..."
+ reconnect: "再接続する"
+ connect: "Twitterと接続する"
+ disconnect: "切断する"
+common/views/components/uploader.vue:
+ waiting: "待機中"
+common/views/widgets/broadcast.vue:
+ fetching: "確認中"
+ no-broadcasts: "お知らせはありません"
+ have-a-nice-day: "良い一日を!"
+ next: "次"
+common/views/widgets/donation.vue:
+ title: "寄付のお願い"
+ text: "Misskeyの運営にはドメイン、サーバー等のコストが掛かります。Misskeyは広告を掲載したりしないため、収入を皆様からの寄付に頼っています。もしご興味があれば、{}までご連絡ください。ご協力ありがとうございます。"
+common/views/widgets/photo-stream.vue:
+ title: "フォトストリーム"
+ no-photos: "写真はありません"
+common/views/widgets/server.vue:
+ title: "サーバー情報"
+ toggle: "表示を切り替え"
+desktop/views/components/activity.vue:
+ title: "アクティビティ"
+ toggle: "表示を切り替え"
+desktop/views/components/calendar.vue:
+ title: "{1}年 {2}月"
+ prev: "前の月"
+ next: "次の月"
+ go: "クリックして時間遡行"
+desktop/views/components/drive-window.vue:
+ used: "使用中"
+ drive: "ドライブ"
+desktop/views/components/drive.file.vue:
+ avatar: "アイコン"
+ banner: "バナー"
+ contextmenu:
+ rename: "名前を変更"
+ copy-url: "URLをコピー"
+ download: "ダウンロード"
+ else-files: "その他..."
+ set-as-avatar: "アイコンに設定"
+ set-as-banner: "バナーに設定"
+ open-in-app: "アプリで開く"
+ add-app: "アプリを追加"
+ rename-file: "ファイル名の変更"
+ input-new-file-name: "新しいファイル名を入力してください"
+ copied: "コピー完了"
+ copied-url-to-clipboard: "URLをクリップボードにコピーしました"
+desktop/views/components/drive.folder.vue:
+ unable-to-process: "操作を完了できません"
+ circular-reference-detected: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。"
+ unhandled-error: "不明なエラー"
+ contextmenu:
+ move-to-this-folder: "このフォルダへ移動"
+ show-in-new-window: "新しいウィンドウで表示"
+ rename: "名前を変更"
+ rename-folder: "フォルダ名の変更"
+ input-new-folder-name: "新しいフォルダ名を入力してください"
+desktop/views/components/drive.nav-folder.vue:
+ drive: "ドライブ"
+desktop/views/components/drive.vue:
+ search: "検索"
+ load-more: "もっと読み込む"
+ empty-draghover: "ドロップですか?いいですよ、ボクはカワイイですからね"
+ empty-drive: "ドライブには何もありません。"
+ empty-drive-description: "右クリックして「ファイルをアップロード」を選んだり、ファイルをドラッグ&ドロップすることでもアップロードできます。"
+ empty-folder: "このフォルダーは空です"
+ unable-to-process: "操作を完了できません"
+ circular-reference-detected: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。"
+ unhandled-error: "不明なエラー"
+ url-upload: "URLアップロード"
+ url-of-file: "アップロードしたいファイルのURL"
+ url-upload-requested: "アップロードをリクエストしました"
+ may-take-time: "アップロードが完了するまで時間がかかる場合があります。"
+ create-folder: "フォルダー作成"
+ folder-name: "フォルダー名"
+ contextmenu:
+ create-folder: "フォルダーを作成"
+ upload: "ファイルをアップロード"
+ url-upload: "URLからアップロード"
+desktop/views/components/messaging-window.vue:
+ title: "メッセージ"
+desktop/views/components/notes.note.vue:
+ reposted-by: "{}がRenote"
+ reply: "返信"
+ renote: "Renote"
+ add-reaction: "リアクション"
+ detail: "詳細"
+desktop/views/components/notifications.vue:
+ more: "もっと見る"
+ empty: "ありません!"
+desktop/views/components/post-form.vue:
+ note-placeholder: "いまどうしてる?"
+ reply-placeholder: "この投稿への返信..."
+ quote-placeholder: "この投稿を引用..."
+ note: "投稿"
+ reply: "返信"
+ renote: "Renote"
+ posted: "投稿しました!"
+ replied: "返信しました!"
+ reposted: "Renoteしました!"
+ note-failed: "投稿に失敗しました"
+ reply-failed: "返信に失敗しました"
+ renote-failed: "Renoteに失敗しました"
+ posting: "投稿中"
+ attach-media-from-local: "PCからメディアを添付"
+ attach-media-from-drive: "ドライブからメディアを添付"
+ attach-cancel: "添付取り消し"
+ insert-a-kao: "v(‘ω’)v"
+ create-poll: "投票を作成"
+ text-remain: "残り{}文字"
+desktop/views/components/post-form-window.vue:
+ note: "新規投稿"
+ reply: "返信"
+ attaches: "添付: {}メディア"
+ uploading-media: "{}個のメディアをアップロード中"
+desktop/views/components/renote-form.vue:
+ quote: "引用する..."
+ cancel: "キャンセル"
+ renote: "Renote"
+ reposting: "しています..."
+ success: "Renoteしました!"
+ failure: "Renoteに失敗しました"
+desktop/views/components/renote-form-window.vue:
+ title: "この投稿をRenoteしますか?"
+desktop/views/components/settings.vue:
+ profile: "プロフィール"
+ notification: "通知"
+ apps: "アプリ"
+ mute: "ミュート"
+ drive: "ドライブ"
+ security: "セキュリティ"
+ password: "パスワード"
+ 2fa: "二段階認証"
+ other: "その他"
+ license: "ライセンス"
+desktop/views/components/settings.2fa.vue:
+ intro: "二段階認証を設定すると、サインイン時にパスワードだけでなく、予め登録しておいた物理的なデバイス(例えばあなたのスマートフォンなど)も必要になり、よりセキュリティが向上します。"
+ detail: "詳細..."
+ url: "https://www.google.co.jp/intl/ja/landing/2step/"
+ caution: "登録したデバイスを紛失するなどした場合、Misskeyにサインインできなくなりますのでご注意ください。"
+ register: "デバイスを登録する"
+ already-registered: "既に設定は完了しています。"
+ unregister: "設定を解除"
+ unregistered: "二段階認証が無効になりました。"
+ enter-password: "パスワードを入力してください"
+ authenticator: "まず、Google Authenticatorをお使いのデバイスにインストールします:"
+ howtoinstall: "インストール方法はこちら"
+ scan: "次に、表示されているQRコードをスキャンします:"
+ done: "お使いのデバイスに表示されているトークンを入力して完了します:"
+ submit: "完了"
+ success: "設定が完了しました!"
+ failed: "設定に失敗しました。トークンに誤りがないかご確認ください。"
+ info: "次回サインインからは、同様にパスワードに加えてデバイスに表示されているトークンを入力します。"
+desktop/views/components/settings.api.vue:
+ intro: "APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。"
+ caution: "アカウントを不正利用される可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。"
+ regeneration-of-token: "万が一このトークンが漏れたりその可能性がある場合はトークンを再生成できます。"
+ regenerate-token: "トークンを再生成"
+ enter-password: "パスワードを入力してください"
+desktop/views/components/settings.app.vue:
+ no-apps: "連携しているアプリケーションはありません"
+desktop/views/components/settings.mute.vue:
+ no-users: "ミュートしているユーザーはいません"
+desktop/views/components/settings.password.vue:
+ reset: "パスワードを変更する"
+ enter-current-password: "現在のパスワードを入力してください"
+ enter-new-password: "新しいパスワードを入力してください"
+ enter-new-password-again: "もう一度新しいパスワードを入力してください"
+ not-match: "新しいパスワードが一致しません"
+ changed: "パスワードを変更しました"
+desktop/views/components/settings.profile.vue:
+ avatar: "アイコン"
+ choice-avatar: "画像を選択"
+ name: "名前"
+ location: "場所"
+ description: "自己紹介"
+ birthday: "誕生日"
+ save: "保存"
+desktop/views/components/ui.header.account.vue:
+ profile: "プロフィール"
+ drive: "ドライブ"
+ favorites: "お気に入り"
+ lists: "リスト"
+ customize: "カスタマイズ"
+ settings: "設定"
+ signout: "サインアウト"
+ dark: "闇に飲まれる"
+desktop/views/components/ui.header.nav.vue:
+ home: "ホーム"
+ messaging: "メッセージ"
+ game: "ゲーム"
+desktop/views/components/ui.header.notifications.vue:
+ title: "通知"
+desktop/views/components/ui.header.post.vue:
+ post: "新規投稿"
+desktop/views/components/ui.header.search.vue:
+ placeholder: "検索"
+desktop/views/pages/note.vue:
+ prev: "前の投稿"
+ next: "次の投稿"
+desktop/views/pages/selectdrive.vue:
+ title: "ファイルを選択してください"
+ ok: "決定"
+ cancel: "キャンセル"
+ upload: "PCからドライブにファイルをアップロード"
+desktop/views/pages/user/user.followers-you-know.vue:
+ title: "知り合いのフォロワー"
+ loading: "読み込み中"
+ no-users: "知り合いのフォロワーはいません"
+desktop/views/pages/user/user.friends.vue:
+ title: "よく話すユーザー"
+ loading: "読み込み中"
+ no-users: "よく話すユーザーはいません"
+desktop/views/pages/user/user.header.vue:
+ is-suspended: "このユーザーは凍結されています。"
+ is-remote: "このユーザーはリモートユーザーです。"
+ view-remote: "正確な情報を見る"
+desktop/views/pages/user/user.home.vue:
+ last-used-at: "最終アクセス"
+desktop/views/pages/user/user.photos.vue:
+ title: "フォト"
+ loading: "読み込み中"
+ no-photos: "写真はありません"
+desktop/views/pages/user/user.profile.vue:
+ follows-you: "フォローされています"
+ stalk: "ストークする"
+ stalking: "ストーキングしています"
+ unstalk: "ストーク解除"
+ mute: "ミュートする"
+ muted: "ミュートしています"
+ unmute: "ミュート解除"
+desktop/views/widgets/messaging.vue:
+ title: "メッセージ"
+desktop/views/widgets/notifications.vue:
+ title: "通知"
+ settings: "通知の設定"
+desktop/views/widgets/polls.vue:
+ title: "投票"
+ refresh: "他を見る"
+ nothing: "ありません!"
+desktop/views/widgets/post-form.vue:
+ title: "投稿"
+ note: "投稿"
+ placeholder: "いまどうしてる?"
+desktop/views/widgets/trends.vue:
+ title: "トレンド"
+ refresh: "他を見る"
+ nothing: "ありません!"
+desktop/views/widgets/users.vue:
+ title: "おすすめユーザー"
+ refresh: "他を見る"
+ no-one: "いません!"
+desktop/views/widgets/channel.vue:
+ title: "チャンネル"
+ settings: "ウィジェットの設定"
+ get-started: "右上の歯車をクリックして受信するチャンネルを指定してください"
+mobile/views/components/drive.vue:
+ drive: "ドライブ"
+ used: "使用中"
+ folder-count: "フォルダ"
+ count-separator: "、"
+ file-count: "ファイル"
+ load-more: "もっと読み込む"
+ nothing-in-drive: "ドライブには何もありません"
+ folder-is-empty: "このフォルダは空です"
+mobile/views/components/drive-file-chooser.vue:
+ select-file: "ファイルを選択"
+mobile/views/components/drive-folder-chooser.vue:
+ select-folder: "フォルダーを選択"
+mobile/views/components/drive.file-detail.vue:
+ download: "ダウンロード"
+ rename: "名前を変更"
+ move: "移動"
+ hash: "ハッシュ (md5)"
+ exif: "EXIF"
+mobile/views/components/follow-button.vue:
+ follow: "フォロー"
+ unfollow: "フォロー解除"
+mobile/views/components/note.vue:
+ reposted-by: "{}がRenote"
+mobile/views/components/note-detail.vue:
+ reply: "返信"
+ reaction: "リアクション"
+mobile/views/components/notifications.vue:
+ more: "もっと見る"
+ empty: "ありません!"
+mobile/views/components/post-form.vue:
+ submit: "投稿"
+ reply-placeholder: "この投稿への返信..."
+ note-placeholder: "いまどうしてる?"
+mobile/views/components/sub-note-content.vue:
+ media-count: "{}個のメディア"
+ poll: "投票"
+mobile/views/components/timeline.vue:
+ empty: "投稿がありません"
+ load-more: "もっと"
+mobile/views/components/ui.nav.vue:
+ home: "ホーム"
+ notifications: "通知"
+ messaging: "メッセージ"
+ search: "検索"
+ drive: "ドライブ"
+ settings: "設定"
+ about: "Misskeyについて"
+mobile/views/components/user-timeline.vue:
+ no-notes: "このユーザーは投稿していないようです。"
+ no-notes-with-media: "メディア付き投稿はありません。"
+ load-more: "もっと"
+mobile/views/components/users-list.vue:
+ all: "すべて"
+ known: "知り合い"
+ load-more: "もっと"
+mobile/views/pages/drive.vue:
+ drive: "ドライブ"
+mobile/views/pages/followers.vue:
+ followers-of: "{}のフォロワー"
+mobile/views/pages/following.vue:
+ following-of: "{}のフォロー"
+mobile/views/pages/home.vue:
+ timeline: "タイムライン"
+mobile/views/pages/messaging.vue:
+ messaging: "メッセージ"
+mobile/views/pages/messaging-room.vue:
+ messaging: "メッセージ"
+mobile/views/pages/note.vue:
+ title: "投稿"
+ prev: "前の投稿"
+ next: "次の投稿"
+mobile/views/pages/notifications.vue:
+ notifications: "通知"
+ read-all: "すべての通知を既読にしますか?"
+mobile/views/pages/profile-setting.vue:
+ title: "プロフィール設定"
+ will-be-published: "これらのプロフィールは公開されます。"
+ name: "名前"
+ location: "場所"
+ description: "自己紹介"
+ birthday: "誕生日"
+ avatar: "アイコン"
+ banner: "バナー"
+ avatar-saved: "アイコンを保存しました"
+ banner-saved: "バナーを保存しました"
+ set-avatar: "アイコンを選択する"
+ set-banner: "バナーを選択する"
+ save: "保存"
+ saved: "プロフィールを保存しました"
+mobile/views/pages/search.vue:
+ search: "検索"
+ empty: "「{}」に関する投稿は見つかりませんでした。"
+mobile/views/pages/selectdrive.vue:
+ select-file: "ファイルを選択"
+mobile/views/pages/settings.vue:
+ signed-in-as: "{}としてサインイン中"
+ profile: "プロフィール"
+ twitter: "Twitter連携"
+ signin-history: "サインイン履歴"
+ settings: "設定"
+ signout: "サインアウト"
+mobile/views/pages/user.vue:
+ follows-you: "フォローされています"
+ following: "フォロー"
+ followers: "フォロワー"
+ notes: "投稿"
+ overview: "概要"
+ timeline: "タイムライン"
+ media: "メディア"
+ is-suspended: "このユーザーは凍結されています。"
+ is-remote: "このユーザーはリモートユーザーです。"
+ view-remote: "正確な情報を見る"
+mobile/views/pages/user/home.vue:
+ recent-notes: "最近の投稿"
+ images: "画像"
+ activity: "アクティビティ"
+ keywords: "キーワード"
+ domains: "頻出ドメイン"
+ frequently-replied-users: "よく会話するユーザー"
+ followers-you-know: "知り合いのフォロワー"
+ last-used-at: "最終ログイン"
+mobile/views/pages/user/home.followers-you-know.vue:
+ loading: "読み込み中"
+ no-users: "知り合いのユーザーはいません"
+mobile/views/pages/user/home.friends.vue:
+ loading: "読み込み中"
+ no-users: "よく会話するユーザーはいません"
+mobile/views/pages/user/home.notes.vue:
+ loading: "読み込み中"
+ no-notes: "投稿はありません"
+mobile/views/pages/user/home.photos.vue:
+ loading: "読み込み中"
+ no-photos: "写真はありません"
+docs:
+ edit-this-page-on-github: "間違いや改善点を見つけましたか?"
+ edit-this-page-on-github-link: "このページをGitHubで編集"
+ api:
+ entities:
+ properties: "プロパティ"
+ endpoints:
+ params: "パラメータ"
+ res: "レスポンス"
+ props:
+ name: "名前"
+ type: "型"
+ optional: "オプション"
+ description: "説明"
+ yes: "はい"
+ no: "いいえ"
diff --git a/locales/zh.yml b/locales/zh.yml
new file mode 100644
index 0000000000..b6e7898a8e
--- /dev/null
+++ b/locales/zh.yml
@@ -0,0 +1,550 @@
+---
+common:
+ misskey: "Misskeyで皆と共有しよう。"
+ time:
+ unknown: "なぞのじかん"
+ future: "未来"
+ just_now: "たった今"
+ seconds_ago: "{}秒前"
+ minutes_ago: "{}分前"
+ hours_ago: "{}時間前"
+ days_ago: "{}日前"
+ weeks_ago: "{}週間前"
+ months_ago: "{}ヶ月前"
+ years_ago: "{}年前"
+ weekday-short:
+ sunday: "日"
+ monday: "月"
+ tuesday: "火"
+ wednesday: "水"
+ thursday: "木"
+ friday: "金"
+ satruday: "土"
+ reactions:
+ like: "いいね"
+ love: "しゅき"
+ laugh: "笑"
+ hmm: "ふぅ~む"
+ surprise: "わお"
+ congrats: "おめでとう"
+ angry: "おこ"
+ confused: "こまこまのこまり"
+ pudding: "Pudding"
+ delete: "削除"
+ loading: "読み込み中"
+ ok: "わかった"
+ update-available: "Misskeyの新しいバージョンがあります({newer}。現在{current}を利用中)。ページを再度読み込みすると更新が適用されます。"
+ my-token-regenerated: "あなたのトークンが更新されたのでサインアウトします。"
+common/views/components/connect-failed.vue:
+ title: "サーバーに接続できません"
+ description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから{再度お試し}ください。"
+ thanks: "いつもMisskeyをご利用いただきありがとうございます。"
+ troubleshoot: "トラブルシュート"
+common/views/components/connect-failed.troubleshooter.vue:
+ title: "トラブルシューティング"
+ network: "ネットワーク接続"
+ checking-network: "ネットワーク接続を確認中"
+ internet: "インターネット接続"
+ checking-internet: "インターネット接続を確認中"
+ server: "サーバー接続"
+ checking-server: "サーバー接続を確認中"
+ finding: "問題を調べています"
+ no-network: "ネットワークに接続されていません"
+ no-network-desc: "お使いのPCのネットワーク接続が正常か確認してください。"
+ no-internet: "インターネットに接続されていません"
+ no-internet-desc: "ネットワークには接続されていますが、インターネットには接続されていないようです。お使いのPCのインターネット接続が正常か確認してください。"
+ no-server: "Misskeyのサーバーに接続できません"
+ no-server-desc: "お使いのPCのインターネット接続は正常ですが、Misskeyのサーバーには接続できませんでした。サーバーがダウンまたはメンテナンスしている可能性があるので、しばらくしてから再度御アクセスください。"
+ success: "Misskeyのサーバーに接続できました"
+ success-desc: "正常に接続できるようです。ページを再度読み込みしてください。"
+ flush: "キャッシュの削除"
+ set-version: "バージョン指定"
+common/views/components/messaging.vue:
+ search-user: "ユーザーを探す"
+ you: "あなた"
+ no-history: "履歴はありません"
+common/views/components/messaging-room.vue:
+ empty: "このユーザーと話したことはありません"
+ more: "もっと読む"
+ no-history: "これより過去の履歴はありません"
+ resize-form: "ドラッグしてフォームの広さを調整"
+ new-message: "新しいメッセージがあります"
+common/views/components/messaging-room.form.vue:
+ input-message-here: "ここにメッセージを入力"
+ send: "送信"
+ attach-from-local: "PCからファイルを添付する"
+ attach-from-drive: "ドライブからファイルを添付する"
+common/views/components/messaging-room.message.vue:
+ is-read: "既読"
+ deleted: "このメッセージは削除されました"
+common/views/components/nav.vue:
+ about: "Misskeyについて"
+ stats: "統計"
+ status: "ステータス"
+ wiki: "Wiki"
+ donors: "ドナー"
+ repository: "リポジトリ"
+ develop: "開発者"
+ feedback: "フィードバック"
+common/views/components/note-menu.vue:
+ favorite: "お気に入り"
+ pin: "ピン留め"
+ remote: "投稿元で見る"
+common/views/components/poll.vue:
+ vote-to: "「{}」に投票する"
+ vote-count: "{}票"
+ total-users: "{}人が投票"
+ vote: "投票する"
+ show-result: "結果を見る"
+ voted: "投票済み"
+common/views/components/poll-editor.vue:
+ no-only-one-choice: "投票には、選択肢が最低2つ必要です"
+ choice-n: "選択肢{}"
+ remove: "この選択肢を削除"
+ add: "+選択肢を追加"
+ destroy: "投票を破棄"
+common/views/components/reaction-picker.vue:
+ choose-reaction: "リアクションを選択"
+common/views/components/signin.vue:
+ username: "ユーザー名"
+ password: "パスワード"
+ token: "トークン"
+ signing-in: "やってます..."
+ signin: "サインイン"
+common/views/components/signup.vue:
+ username: "ユーザー名"
+ checking: "確認しています..."
+ available: "利用できます"
+ unavailable: "既に利用されています"
+ error: "通信エラー"
+ invalid-format: "a~z、A~Z、0~9、_が使えます"
+ too-short: "1文字以上でお願いします!"
+ too-long: "20文字以内でお願いします"
+ password: "パスワード"
+ password-placeholder: "8文字以上を推奨します"
+ weak-password: "弱いパスワード"
+ normal-password: "まあまあのパスワード"
+ strong-password: "強いパスワード"
+ retype: "再入力"
+ retype-placeholder: "確認のため再入力してください"
+ password-matched: "確認されました"
+ password-not-matched: "一致していません"
+ recaptcha: "認証"
+ create: "アカウント作成"
+ some-error: "何らかの原因によりアカウントの作成に失敗しました。再度お試しください。"
+common/views/components/special-message.vue:
+ new-year: "Happy New Year!"
+ christmas: "Merry Christmas!"
+common/views/components/stream-indicator.vue:
+ connecting: "接続中"
+ reconnecting: "再接続中"
+ connected: "接続完了"
+common/views/components/twitter-setting.vue:
+ description: "お使いのTwitterアカウントをお使いのMisskeyアカウントに接続しておくと、プロフィールでTwitterアカウント情報が表示されるようになったり、Twitterを用いた便利なサインインを利用できるようになります。"
+ connected-to: "次のTwitterアカウントに接続されています"
+ detail: "詳細..."
+ reconnect: "再接続する"
+ connect: "Twitterと接続する"
+ disconnect: "切断する"
+common/views/components/uploader.vue:
+ waiting: "待機中"
+common/views/widgets/broadcast.vue:
+ fetching: "確認中"
+ no-broadcasts: "お知らせはありません"
+ have-a-nice-day: "良い一日を!"
+ next: "次"
+common/views/widgets/donation.vue:
+ title: "寄付のお願い"
+ text: "Misskeyの運営にはドメイン、サーバー等のコストが掛かります。Misskeyは広告を掲載したりしないため、収入を皆様からの寄付に頼っています。もしご興味があれば、{}までご連絡ください。ご協力ありがとうございます。"
+common/views/widgets/photo-stream.vue:
+ title: "フォトストリーム"
+ no-photos: "写真はありません"
+common/views/widgets/server.vue:
+ title: "サーバー情報"
+ toggle: "表示を切り替え"
+desktop/views/components/activity.vue:
+ title: "アクティビティ"
+ toggle: "表示を切り替え"
+desktop/views/components/calendar.vue:
+ title: "{1}年 {2}月"
+ prev: "前の月"
+ next: "次の月"
+ go: "クリックして時間遡行"
+desktop/views/components/drive-window.vue:
+ used: "使用中"
+ drive: "ドライブ"
+desktop/views/components/drive.file.vue:
+ avatar: "アイコン"
+ banner: "バナー"
+ contextmenu:
+ rename: "名前を変更"
+ copy-url: "URLをコピー"
+ download: "ダウンロード"
+ else-files: "その他..."
+ set-as-avatar: "アイコンに設定"
+ set-as-banner: "バナーに設定"
+ open-in-app: "アプリで開く"
+ add-app: "アプリを追加"
+ rename-file: "ファイル名の変更"
+ input-new-file-name: "新しいファイル名を入力してください"
+ copied: "コピー完了"
+ copied-url-to-clipboard: "URLをクリップボードにコピーしました"
+desktop/views/components/drive.folder.vue:
+ unable-to-process: "操作を完了できません"
+ circular-reference-detected: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。"
+ unhandled-error: "不明なエラー"
+ contextmenu:
+ move-to-this-folder: "このフォルダへ移動"
+ show-in-new-window: "新しいウィンドウで表示"
+ rename: "名前を変更"
+ rename-folder: "フォルダ名の変更"
+ input-new-folder-name: "新しいフォルダ名を入力してください"
+desktop/views/components/drive.nav-folder.vue:
+ drive: "ドライブ"
+desktop/views/components/drive.vue:
+ search: "検索"
+ load-more: "もっと読み込む"
+ empty-draghover: "ドロップですか?いいですよ、ボクはカワイイですからね"
+ empty-drive: "ドライブには何もありません。"
+ empty-drive-description: "右クリックして「ファイルをアップロード」を選んだり、ファイルをドラッグ&ドロップすることでもアップロードできます。"
+ empty-folder: "このフォルダーは空です"
+ unable-to-process: "操作を完了できません"
+ circular-reference-detected: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。"
+ unhandled-error: "不明なエラー"
+ url-upload: "URLアップロード"
+ url-of-file: "アップロードしたいファイルのURL"
+ url-upload-requested: "アップロードをリクエストしました"
+ may-take-time: "アップロードが完了するまで時間がかかる場合があります。"
+ create-folder: "フォルダー作成"
+ folder-name: "フォルダー名"
+ contextmenu:
+ create-folder: "フォルダーを作成"
+ upload: "ファイルをアップロード"
+ url-upload: "URLからアップロード"
+desktop/views/components/messaging-window.vue:
+ title: "メッセージ"
+desktop/views/components/notes.note.vue:
+ reposted-by: "{}がRenote"
+ reply: "返信"
+ renote: "Renote"
+ add-reaction: "リアクション"
+ detail: "詳細"
+desktop/views/components/notifications.vue:
+ more: "もっと見る"
+ empty: "ありません!"
+desktop/views/components/post-form.vue:
+ note-placeholder: "いまどうしてる?"
+ reply-placeholder: "この投稿への返信..."
+ quote-placeholder: "この投稿を引用..."
+ note: "投稿"
+ reply: "返信"
+ renote: "Renote"
+ posted: "投稿しました!"
+ replied: "返信しました!"
+ reposted: "Renoteしました!"
+ note-failed: "投稿に失敗しました"
+ reply-failed: "返信に失敗しました"
+ renote-failed: "Renoteに失敗しました"
+ posting: "投稿中"
+ attach-media-from-local: "PCからメディアを添付"
+ attach-media-from-drive: "ドライブからメディアを添付"
+ attach-cancel: "添付取り消し"
+ insert-a-kao: "v(‘ω’)v"
+ create-poll: "投票を作成"
+ text-remain: "残り{}文字"
+desktop/views/components/post-form-window.vue:
+ note: "新規投稿"
+ reply: "返信"
+ attaches: "添付: {}メディア"
+ uploading-media: "{}個のメディアをアップロード中"
+desktop/views/components/renote-form.vue:
+ quote: "引用する..."
+ cancel: "キャンセル"
+ renote: "Renote"
+ reposting: "しています..."
+ success: "Renoteしました!"
+ failure: "Renoteに失敗しました"
+desktop/views/components/renote-form-window.vue:
+ title: "この投稿をRenoteしますか?"
+desktop/views/components/settings.vue:
+ profile: "プロフィール"
+ notification: "通知"
+ apps: "アプリ"
+ mute: "ミュート"
+ drive: "ドライブ"
+ security: "セキュリティ"
+ password: "パスワード"
+ 2fa: "二段階認証"
+ other: "その他"
+ license: "ライセンス"
+desktop/views/components/settings.2fa.vue:
+ intro: "二段階認証を設定すると、サインイン時にパスワードだけでなく、予め登録しておいた物理的なデバイス(例えばあなたのスマートフォンなど)も必要になり、よりセキュリティが向上します。"
+ detail: "詳細..."
+ url: "https://www.google.co.jp/intl/ja/landing/2step/"
+ caution: "登録したデバイスを紛失するなどした場合、Misskeyにサインインできなくなりますのでご注意ください。"
+ register: "デバイスを登録する"
+ already-registered: "既に設定は完了しています。"
+ unregister: "設定を解除"
+ unregistered: "二段階認証が無効になりました。"
+ enter-password: "パスワードを入力してください"
+ authenticator: "まず、Google Authenticatorをお使いのデバイスにインストールします:"
+ howtoinstall: "インストール方法はこちら"
+ scan: "次に、表示されているQRコードをスキャンします:"
+ done: "お使いのデバイスに表示されているトークンを入力して完了します:"
+ submit: "完了"
+ success: "設定が完了しました!"
+ failed: "設定に失敗しました。トークンに誤りがないかご確認ください。"
+ info: "次回サインインからは、同様にパスワードに加えてデバイスに表示されているトークンを入力します。"
+desktop/views/components/settings.api.vue:
+ intro: "APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。"
+ caution: "アカウントを不正利用される可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。"
+ regeneration-of-token: "万が一このトークンが漏れたりその可能性がある場合はトークンを再生成できます。"
+ regenerate-token: "トークンを再生成"
+ enter-password: "パスワードを入力してください"
+desktop/views/components/settings.app.vue:
+ no-apps: "連携しているアプリケーションはありません"
+desktop/views/components/settings.mute.vue:
+ no-users: "ミュートしているユーザーはいません"
+desktop/views/components/settings.password.vue:
+ reset: "パスワードを変更する"
+ enter-current-password: "現在のパスワードを入力してください"
+ enter-new-password: "新しいパスワードを入力してください"
+ enter-new-password-again: "もう一度新しいパスワードを入力してください"
+ not-match: "新しいパスワードが一致しません"
+ changed: "パスワードを変更しました"
+desktop/views/components/settings.profile.vue:
+ avatar: "アイコン"
+ choice-avatar: "画像を選択"
+ name: "名前"
+ location: "場所"
+ description: "自己紹介"
+ birthday: "誕生日"
+ save: "保存"
+desktop/views/components/ui.header.account.vue:
+ profile: "プロフィール"
+ drive: "ドライブ"
+ favorites: "お気に入り"
+ lists: "リスト"
+ customize: "カスタマイズ"
+ settings: "設定"
+ signout: "サインアウト"
+ dark: "闇に飲まれる"
+desktop/views/components/ui.header.nav.vue:
+ home: "ホーム"
+ messaging: "メッセージ"
+ game: "ゲーム"
+desktop/views/components/ui.header.notifications.vue:
+ title: "通知"
+desktop/views/components/ui.header.post.vue:
+ post: "新規投稿"
+desktop/views/components/ui.header.search.vue:
+ placeholder: "検索"
+desktop/views/pages/note.vue:
+ prev: "前の投稿"
+ next: "次の投稿"
+desktop/views/pages/selectdrive.vue:
+ title: "ファイルを選択してください"
+ ok: "決定"
+ cancel: "キャンセル"
+ upload: "PCからドライブにファイルをアップロード"
+desktop/views/pages/user/user.followers-you-know.vue:
+ title: "知り合いのフォロワー"
+ loading: "読み込み中"
+ no-users: "知り合いのフォロワーはいません"
+desktop/views/pages/user/user.friends.vue:
+ title: "よく話すユーザー"
+ loading: "読み込み中"
+ no-users: "よく話すユーザーはいません"
+desktop/views/pages/user/user.header.vue:
+ is-suspended: "このユーザーは凍結されています。"
+ is-remote: "このユーザーはリモートユーザーです。"
+ view-remote: "正確な情報を見る"
+desktop/views/pages/user/user.home.vue:
+ last-used-at: "最終アクセス"
+desktop/views/pages/user/user.photos.vue:
+ title: "フォト"
+ loading: "読み込み中"
+ no-photos: "写真はありません"
+desktop/views/pages/user/user.profile.vue:
+ follows-you: "フォローされています"
+ stalk: "ストークする"
+ stalking: "ストーキングしています"
+ unstalk: "ストーク解除"
+ mute: "ミュートする"
+ muted: "ミュートしています"
+ unmute: "ミュート解除"
+desktop/views/widgets/messaging.vue:
+ title: "メッセージ"
+desktop/views/widgets/notifications.vue:
+ title: "通知"
+ settings: "通知の設定"
+desktop/views/widgets/polls.vue:
+ title: "投票"
+ refresh: "他を見る"
+ nothing: "ありません!"
+desktop/views/widgets/post-form.vue:
+ title: "投稿"
+ note: "投稿"
+ placeholder: "いまどうしてる?"
+desktop/views/widgets/trends.vue:
+ title: "トレンド"
+ refresh: "他を見る"
+ nothing: "ありません!"
+desktop/views/widgets/users.vue:
+ title: "おすすめユーザー"
+ refresh: "他を見る"
+ no-one: "いません!"
+desktop/views/widgets/channel.vue:
+ title: "チャンネル"
+ settings: "ウィジェットの設定"
+ get-started: "右上の歯車をクリックして受信するチャンネルを指定してください"
+mobile/views/components/drive.vue:
+ drive: "ドライブ"
+ used: "使用中"
+ folder-count: "フォルダ"
+ count-separator: "、"
+ file-count: "ファイル"
+ load-more: "もっと読み込む"
+ nothing-in-drive: "ドライブには何もありません"
+ folder-is-empty: "このフォルダは空です"
+mobile/views/components/drive-file-chooser.vue:
+ select-file: "ファイルを選択"
+mobile/views/components/drive-folder-chooser.vue:
+ select-folder: "フォルダーを選択"
+mobile/views/components/drive.file-detail.vue:
+ download: "ダウンロード"
+ rename: "名前を変更"
+ move: "移動"
+ hash: "ハッシュ (md5)"
+ exif: "EXIF"
+mobile/views/components/follow-button.vue:
+ follow: "フォロー"
+ unfollow: "フォロー解除"
+mobile/views/components/note.vue:
+ reposted-by: "{}がRenote"
+mobile/views/components/note-detail.vue:
+ reply: "返信"
+ reaction: "リアクション"
+mobile/views/components/notifications.vue:
+ more: "もっと見る"
+ empty: "ありません!"
+mobile/views/components/post-form.vue:
+ submit: "投稿"
+ reply-placeholder: "この投稿への返信..."
+ note-placeholder: "いまどうしてる?"
+mobile/views/components/sub-note-content.vue:
+ media-count: "{}個のメディア"
+ poll: "投票"
+mobile/views/components/timeline.vue:
+ empty: "投稿がありません"
+ load-more: "もっと"
+mobile/views/components/ui.nav.vue:
+ home: "ホーム"
+ notifications: "通知"
+ messaging: "メッセージ"
+ search: "検索"
+ drive: "ドライブ"
+ settings: "設定"
+ about: "Misskeyについて"
+mobile/views/components/user-timeline.vue:
+ no-notes: "このユーザーは投稿していないようです。"
+ no-notes-with-media: "メディア付き投稿はありません。"
+ load-more: "もっと"
+mobile/views/components/users-list.vue:
+ all: "すべて"
+ known: "知り合い"
+ load-more: "もっと"
+mobile/views/pages/drive.vue:
+ drive: "ドライブ"
+mobile/views/pages/followers.vue:
+ followers-of: "{}のフォロワー"
+mobile/views/pages/following.vue:
+ following-of: "{}のフォロー"
+mobile/views/pages/home.vue:
+ timeline: "タイムライン"
+mobile/views/pages/messaging.vue:
+ messaging: "メッセージ"
+mobile/views/pages/messaging-room.vue:
+ messaging: "メッセージ"
+mobile/views/pages/note.vue:
+ title: "投稿"
+ prev: "前の投稿"
+ next: "次の投稿"
+mobile/views/pages/notifications.vue:
+ notifications: "通知"
+ read-all: "すべての通知を既読にしますか?"
+mobile/views/pages/profile-setting.vue:
+ title: "プロフィール設定"
+ will-be-published: "これらのプロフィールは公開されます。"
+ name: "名前"
+ location: "場所"
+ description: "自己紹介"
+ birthday: "誕生日"
+ avatar: "アイコン"
+ banner: "バナー"
+ avatar-saved: "アイコンを保存しました"
+ banner-saved: "バナーを保存しました"
+ set-avatar: "アイコンを選択する"
+ set-banner: "バナーを選択する"
+ save: "保存"
+ saved: "プロフィールを保存しました"
+mobile/views/pages/search.vue:
+ search: "検索"
+ empty: "「{}」に関する投稿は見つかりませんでした。"
+mobile/views/pages/selectdrive.vue:
+ select-file: "ファイルを選択"
+mobile/views/pages/settings.vue:
+ signed-in-as: "{}としてサインイン中"
+ profile: "プロフィール"
+ twitter: "Twitter連携"
+ signin-history: "サインイン履歴"
+ settings: "設定"
+ signout: "サインアウト"
+mobile/views/pages/user.vue:
+ follows-you: "フォローされています"
+ following: "フォロー"
+ followers: "フォロワー"
+ notes: "投稿"
+ overview: "概要"
+ timeline: "タイムライン"
+ media: "メディア"
+ is-suspended: "このユーザーは凍結されています。"
+ is-remote: "このユーザーはリモートユーザーです。"
+ view-remote: "正確な情報を見る"
+mobile/views/pages/user/home.vue:
+ recent-notes: "最近の投稿"
+ images: "画像"
+ activity: "アクティビティ"
+ keywords: "キーワード"
+ domains: "頻出ドメイン"
+ frequently-replied-users: "よく会話するユーザー"
+ followers-you-know: "知り合いのフォロワー"
+ last-used-at: "最終ログイン"
+mobile/views/pages/user/home.followers-you-know.vue:
+ loading: "読み込み中"
+ no-users: "知り合いのユーザーはいません"
+mobile/views/pages/user/home.friends.vue:
+ loading: "読み込み中"
+ no-users: "よく会話するユーザーはいません"
+mobile/views/pages/user/home.notes.vue:
+ loading: "読み込み中"
+ no-notes: "投稿はありません"
+mobile/views/pages/user/home.photos.vue:
+ loading: "読み込み中"
+ no-photos: "写真はありません"
+docs:
+ edit-this-page-on-github: "間違いや改善点を見つけましたか?"
+ edit-this-page-on-github-link: "このページをGitHubで編集"
+ api:
+ entities:
+ properties: "プロパティ"
+ endpoints:
+ params: "パラメータ"
+ res: "レスポンス"
+ props:
+ name: "名前"
+ type: "型"
+ optional: "オプション"
+ description: "説明"
+ yes: "はい"
+ no: "いいえ"
diff --git a/migration/2.0.0.js b/migration/2.0.0.js
new file mode 100644
index 0000000000..eb8f5730c7
--- /dev/null
+++ b/migration/2.0.0.js
@@ -0,0 +1,57 @@
+// for Node.js interpret
+
+const chalk = require('chalk');
+const sequential = require('promise-sequential');
+
+const { default: User } = require('../built/models/user');
+const { default: DriveFile } = require('../built/models/drive-file');
+
+async function main() {
+ const promiseGens = [];
+
+ const count = await DriveFile.count({});
+
+ let prev;
+
+ for (let i = 0; i < count; i++) {
+ promiseGens.push(() => {
+ const promise = new Promise(async (res, rej) => {
+ const file = await DriveFile.findOne(prev ? {
+ _id: { $gt: prev._id }
+ } : {}, {
+ sort: {
+ _id: 1
+ }
+ });
+
+ prev = file;
+
+ const user = await User.findOne({ _id: file.metadata.userId });
+
+ DriveFile.update({
+ _id: file._id
+ }, {
+ $set: {
+ 'metadata._user': {
+ host: user.host
+ }
+ }
+ }).then(() => {
+ res([i, file]);
+ }).catch(rej);
+ });
+
+ promise.then(([i, file]) => {
+ console.log(chalk`{gray ${i}} {green done: {bold ${file._id}} ${file.filename}}`);
+ });
+
+ return promise;
+ });
+ }
+
+ return await sequential(promiseGens);
+}
+
+main().then(() => {
+ console.log('ALL DONE');
+}).catch(console.error);
diff --git a/migration/2.4.0.js b/migration/2.4.0.js
new file mode 100644
index 0000000000..e9584a1dfc
--- /dev/null
+++ b/migration/2.4.0.js
@@ -0,0 +1,71 @@
+// for Node.js interpret
+
+const chalk = require('chalk');
+const sequential = require('promise-sequential');
+
+const { default: User } = require('../built/models/user');
+const { default: DriveFile } = require('../built/models/drive-file');
+
+async function main() {
+ const promiseGens = [];
+
+ const count = await User.count({});
+
+ let prev;
+
+ for (let i = 0; i < count; i++) {
+ promiseGens.push(() => {
+ const promise = new Promise(async (res, rej) => {
+ const user = await User.findOne(prev ? {
+ _id: { $gt: prev._id }
+ } : {}, {
+ sort: {
+ _id: 1
+ }
+ });
+
+ prev = user;
+
+ const set = {};
+
+ if (user.avatarId != null) {
+ const file = await DriveFile.findOne({ _id: user.avatarId });
+
+ if (file && file.metadata.properties.avgColor) {
+ set.avatarColor = file.metadata.properties.avgColor;
+ }
+ }
+
+ if (user.bannerId != null) {
+ const file = await DriveFile.findOne({ _id: user.bannerId });
+
+ if (file && file.metadata.properties.avgColor) {
+ set.bannerColor = file.metadata.properties.avgColor;
+ }
+ }
+
+ if (Object.keys(set).length === 0) return res([i, user]);
+
+ User.update({
+ _id: user._id
+ }, {
+ $set: set
+ }).then(() => {
+ res([i, user]);
+ }).catch(rej);
+ });
+
+ promise.then(([i, user]) => {
+ console.log(chalk`{gray ${i}} {green done: {bold ${user._id}} @${user.username}}`);
+ });
+
+ return promise;
+ });
+ }
+
+ return await sequential(promiseGens);
+}
+
+main().then(() => {
+ console.log('ALL DONE');
+}).catch(console.error);
diff --git a/package.json b/package.json
index 2fcd68b1cb..544dda6036 100644
--- a/package.json
+++ b/package.json
@@ -1,12 +1,9 @@
{
"name": "misskey",
"author": "syuilo <i@syuilo.com>",
- "version": "0.0.4970",
+ "version": "2.6.2",
+ "clientVersion": "1.0.5260",
"codename": "nighthike",
- "license": "MIT",
- "description": "A miniblog-based SNS",
- "bugs": "https://github.com/syuilo/misskey/issues",
- "repository": "https://github.com/syuilo/misskey.git",
"main": "./built/index.js",
"private": true,
"scripts": {
@@ -34,7 +31,7 @@
"@prezzemolo/rap": "0.1.2",
"@prezzemolo/zip": "0.0.3",
"@types/bcryptjs": "2.4.1",
- "@types/chai": "4.1.2",
+ "@types/chai": "4.1.3",
"@types/chai-http": "3.0.4",
"@types/debug": "0.0.30",
"@types/deep-equal": "1.0.1",
@@ -54,22 +51,25 @@
"@types/js-yaml": "3.11.1",
"@types/koa": "2.0.45",
"@types/koa-bodyparser": "4.2.0",
- "@types/koa-compress": "^2.0.8",
+ "@types/koa-compress": "2.0.8",
"@types/koa-favicon": "2.0.19",
+ "@types/koa-logger": "3.1.0",
"@types/koa-mount": "3.0.1",
"@types/koa-multer": "1.0.0",
- "@types/koa-router": "7.0.27",
+ "@types/koa-router": "7.0.28",
"@types/koa-send": "4.1.1",
+ "@types/koa-views": "^2.0.3",
"@types/koa__cors": "2.2.2",
"@types/kue": "0.11.8",
"@types/license-checker": "15.0.0",
"@types/mkdirp": "0.5.2",
- "@types/mocha": "5.0.0",
- "@types/mongodb": "3.0.12",
+ "@types/mocha": "5.2.0",
+ "@types/mongodb": "3.0.15",
"@types/monk": "6.0.0",
"@types/ms": "0.7.30",
- "@types/node": "9.6.4",
+ "@types/node": "9.6.6",
"@types/nopt": "3.0.29",
+ "@types/parse5": "^3.0.0",
"@types/pug": "2.0.4",
"@types/qrcode": "0.8.1",
"@types/ratelimiter": "2.1.28",
@@ -78,10 +78,11 @@
"@types/request-promise-native": "1.0.14",
"@types/rimraf": "2.0.2",
"@types/seedrandom": "2.4.27",
+ "@types/single-line-log": "^1.1.0",
"@types/speakeasy": "2.0.2",
"@types/tmp": "0.0.33",
"@types/uuid": "3.4.3",
- "@types/webpack": "4.1.3",
+ "@types/webpack": "4.1.4",
"@types/webpack-stream": "3.2.10",
"@types/websocket": "0.0.38",
"@types/ws": "4.0.2",
@@ -90,10 +91,10 @@
"autwh": "0.1.0",
"bcryptjs": "2.4.3",
"bootstrap-vue": "2.0.0-rc.6",
- "cafy": "3.2.1",
+ "cafy": "8.0.0",
"chai": "4.1.2",
"chai-http": "4.0.0",
- "chalk": "2.3.2",
+ "chalk": "2.4.1",
"crc-32": "1.2.0",
"css-loader": "0.28.11",
"debug": "3.1.0",
@@ -102,12 +103,12 @@
"diskusage": "0.2.4",
"dompurify": "1.0.3",
"elasticsearch": "14.2.2",
- "element-ui": "2.3.3",
+ "element-ui": "2.3.6",
"emojilib": "2.2.12",
"escape-regexp": "0.0.1",
"eslint": "4.19.1",
- "eslint-plugin-vue": "4.4.0",
- "eventemitter3": "3.0.1",
+ "eslint-plugin-vue": "4.5.0",
+ "eventemitter3": "3.1.0",
"exif-js": "2.3.0",
"file-loader": "1.1.11",
"file-type": "7.6.0",
@@ -118,7 +119,7 @@
"gulp-htmlmin": "4.0.0",
"gulp-imagemin": "4.1.0",
"gulp-mocha": "5.0.0",
- "gulp-pug": "4.0.0",
+ "gulp-pug": "4.0.1",
"gulp-rename": "1.2.2",
"gulp-replace": "0.6.1",
"gulp-sourcemaps": "2.6.4",
@@ -129,42 +130,47 @@
"gulp-util": "3.0.8",
"hard-source-webpack-plugin": "0.6.4",
"highlight.js": "9.12.0",
- "html-minifier": "3.5.14",
+ "html-minifier": "3.5.15",
"http-signature": "1.2.0",
"inquirer": "5.2.0",
"is-root": "2.0.0",
"is-url": "1.2.4",
"js-yaml": "3.11.0",
- "jsdom": "11.7.0",
- "koa": "2.5.0",
+ "jsdom": "11.9.0",
+ "koa": "2.5.1",
"koa-bodyparser": "4.2.0",
- "koa-compress": "^2.0.0",
+ "koa-compress": "3.0.0",
"koa-favicon": "2.0.1",
- "koa-json-body": "^5.3.0",
+ "koa-json-body": "5.3.0",
+ "koa-logger": "3.2.0",
"koa-mount": "3.0.0",
"koa-multer": "1.0.2",
"koa-router": "7.4.0",
"koa-send": "4.1.3",
+ "koa-slow": "2.1.0",
+ "koa-views": "^6.1.4",
"kue": "0.11.6",
"license-checker": "18.0.0",
"loader-utils": "1.1.0",
"mecab-async": "0.1.2",
"mkdirp": "0.5.1",
- "mocha": "5.0.5",
+ "mocha": "5.1.1",
"moji": "0.5.1",
- "mongodb": "3.0.6",
+ "mongodb": "3.0.7",
"monk": "6.0.5",
"ms": "2.1.1",
"nan": "2.10.0",
- "node-sass": "4.8.3",
- "node-sass-json-importer": "3.1.6",
+ "node-sass": "4.9.0",
+ "node-sass-json-importer": "3.2.0",
"nopt": "4.0.1",
"nprogress": "0.2.0",
"object-assign-deep": "0.4.0",
"on-build-webpack": "0.1.0",
"os-utils": "0.0.14",
+ "parse5": "^4.0.0",
"progress-bar-webpack-plugin": "1.11.0",
"prominence": "0.2.0",
+ "promise-sequential": "^1.1.1",
"pug": "2.0.3",
"punycode": "2.1.0",
"qrcode": "1.2.0",
@@ -177,41 +183,43 @@
"rimraf": "2.6.2",
"rndstr": "1.0.0",
"s-age": "1.1.2",
- "sass-loader": "6.0.7",
+ "sass-loader": "7.0.1",
"seedrandom": "2.4.3",
+ "single-line-log": "^1.1.2",
"speakeasy": "2.0.0",
- "style-loader": "0.20.3",
+ "style-loader": "0.21.0",
"stylus": "0.54.5",
"stylus-loader": "3.0.2",
- "summaly": "2.0.3",
+ "summaly": "2.0.4",
"swagger-jsdoc": "1.9.7",
"syuilo-password-strength": "0.0.1",
"tcp-port-used": "0.1.2",
"textarea-caret": "3.1.0",
"tmp": "0.0.33",
- "ts-loader": "4.1.0",
- "ts-node": "5.0.1",
+ "ts-loader": "4.2.0",
+ "ts-node": "6.0.1",
"tslint": "5.9.1",
- "typescript": "2.8.1",
- "typescript-eslint-parser": "14.0.0",
+ "typescript": "2.8.3",
+ "typescript-eslint-parser": "15.0.0",
"uglify-es": "3.3.9",
"url-loader": "1.0.1",
"uuid": "3.2.1",
"v-animate-css": "0.0.2",
"vue": "2.5.16",
"vue-cropperjs": "2.2.0",
- "vue-js-modal": "1.3.12",
- "vue-json-tree-view": "2.1.3",
- "vue-loader": "14.2.2",
+ "vue-js-modal": "1.3.13",
+ "vue-json-tree-view": "2.1.4",
+ "vue-loader": "15.0.3",
"vue-router": "3.0.1",
"vue-template-compiler": "2.5.16",
"vuedraggable": "2.16.0",
+ "vuex": "3.0.1",
"web-push": "3.3.0",
"webfinger.js": "2.6.6",
- "webpack": "4.5.0",
- "webpack-cli": "2.0.14",
+ "webpack": "4.6.0",
+ "webpack-cli": "2.0.15",
"webpack-replace-loader": "1.3.0",
- "websocket": "1.0.25",
+ "websocket": "1.0.26",
"ws": "5.1.1",
"xev": "2.0.0"
}
diff --git a/src/build/i18n.ts b/src/build/i18n.ts
index a6cc6c38ff..6c0f633ad9 100644
--- a/src/build/i18n.ts
+++ b/src/build/i18n.ts
@@ -7,7 +7,7 @@ import locale from '../../locales';
export default class Replacer {
private lang: string;
- public pattern = /%i18n:([a-z_\-@\.\!]+?)%/g;
+ public pattern = /%i18n:([a-z0-9_\-@\.\!]+?)%/g;
constructor(lang: string) {
this.lang = lang;
diff --git a/src/cafy-id.ts b/src/cafy-id.ts
new file mode 100644
index 0000000000..310b1eb20b
--- /dev/null
+++ b/src/cafy-id.ts
@@ -0,0 +1,29 @@
+import * as mongo from 'mongodb';
+import { Query } from 'cafy';
+
+export const isAnId = x => mongo.ObjectID.isValid(x);
+export const isNotAnId = x => !isAnId(x);
+
+/**
+ * ID
+ */
+export default class ID extends Query<mongo.ObjectID> {
+ constructor() {
+ super();
+
+ this.transform = v => {
+ if (isAnId(v) && !mongo.ObjectID.prototype.isPrototypeOf(v)) {
+ return new mongo.ObjectID(v);
+ } else {
+ return v;
+ }
+ };
+
+ this.push(v => {
+ if (!mongo.ObjectID.prototype.isPrototypeOf(v) && isNotAnId(v)) {
+ return new Error('must-be-an-id');
+ }
+ return true;
+ });
+ }
+}
diff --git a/src/client/app/auth/views/form.vue b/src/client/app/auth/views/form.vue
index b323907eb0..152b900429 100644
--- a/src/client/app/auth/views/form.vue
+++ b/src/client/app/auth/views/form.vue
@@ -94,13 +94,13 @@ export default Vue.extend({
margin 0 auto -38px auto
border solid 5px #fff
border-radius 100%
- box-shadow 0 2px 2px rgba(0, 0, 0, 0.1)
+ box-shadow 0 2px 2px rgba(#000, 0.1)
> .app
padding 44px 16px 0 16px
color #555
background #eee
- box-shadow 0 2px 2px rgba(0, 0, 0, 0.1) inset
+ box-shadow 0 2px 2px rgba(#000, 0.1) inset
&:after
content ''
diff --git a/src/client/app/auth/views/index.vue b/src/client/app/auth/views/index.vue
index e1e1b265e1..0fcd9bfe53 100644
--- a/src/client/app/auth/views/index.vue
+++ b/src/client/app/auth/views/index.vue
@@ -94,7 +94,7 @@ export default Vue.extend({
margin 0 auto
text-align center
background #fff
- box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2)
+ box-shadow 0px 4px 16px rgba(#000, 0.2)
> .fetching
margin 0
diff --git a/src/client/app/base.pug b/src/client/app/base.pug
index 32a95a6c99..c182fd6f64 100644
--- a/src/client/app/base.pug
+++ b/src/client/app/base.pug
@@ -1,3 +1,5 @@
+block vars
+
doctype html
!= '\n<!-- Thank you for using Misskey! @syuilo -->\n'
@@ -9,9 +11,17 @@ html
meta(name='application-name' content='Misskey')
meta(name='theme-color' content=themeColor)
meta(name='referrer' content='origin')
+ meta(property='og:site_name' content='Misskey')
link(rel='manifest' href='/manifest.json')
- title Misskey
+ title
+ block title
+ | Misskey
+
+ block desc
+ meta(name='description' content='A SNS')
+
+ block meta
style
include ./../../../built/client/assets/init.css
diff --git a/src/client/app/boot.js b/src/client/app/boot.js
index a0709842b9..35d02cf9c5 100644
--- a/src/client/app/boot.js
+++ b/src/client/app/boot.js
@@ -11,14 +11,12 @@
'use strict';
-// Chromeで確認したことなのですが、constやletを用いたとしても
-// グローバルなスコープで定数/変数を定義するとwindowのプロパティ
-// としてそれがアクセスできるようになる訳ではありませんが、普通に
-// コンソールから定数/変数名を入力するとアクセスできてしまいます。
-// ブロック内に入れてスコープを非グローバル化するとそれが防げます
-// (Chrome以外のブラウザでは検証していません)
-{
- if (localStorage.getItem('shouldFlush') == 'true') refresh();
+(function() {
+ // キャッシュ削除要求があれば従う
+ if (localStorage.getItem('shouldFlush') == 'true') {
+ refresh();
+ return;
+ }
// Get the current url information
const url = new URL(location.href);
@@ -62,6 +60,11 @@
app = isMobile ? 'mobile' : 'desktop';
}
+ // Dark/Light
+ if (localStorage.getItem('darkmode') == 'true') {
+ document.documentElement.setAttribute('data-darkmode', 'true');
+ }
+
// Script version
const ver = localStorage.getItem('v') || VERSION;
@@ -72,11 +75,16 @@
const raw = (localStorage.getItem('useRawScript') == 'true' && isDebug)
|| ENV != 'production';
+ // Get salt query
+ const salt = localStorage.getItem('salt')
+ ? '?salt=' + localStorage.getItem('salt')
+ : '';
+
// Load an app script
// Note: 'async' make it possible to load the script asyncly.
// 'defer' make it possible to run the script when the dom loaded.
const script = document.createElement('script');
- script.setAttribute('src', `/assets/${app}.${ver}.${lang}.${raw ? 'raw' : 'min'}.js`);
+ script.setAttribute('src', `/assets/${app}.${ver}.${lang}.${raw ? 'raw' : 'min'}.js${salt}`);
script.setAttribute('async', 'true');
script.setAttribute('defer', 'true');
head.appendChild(script);
@@ -97,8 +105,8 @@
const meta = await res.json();
// Compare versions
- if (meta.version != ver) {
- localStorage.setItem('v', meta.version);
+ if (meta.clientVersion != ver) {
+ localStorage.setItem('v', meta.clientVersion);
alert(
'Misskeyの新しいバージョンがあります。ページを再度読み込みします。' +
@@ -112,6 +120,9 @@
function refresh() {
localStorage.setItem('shouldFlush', 'false');
+ // Random
+ localStorage.setItem('salt', Math.random().toString());
+
// Clear cache (serive worker)
try {
navigator.serviceWorker.controller.postMessage('clear');
@@ -126,4 +137,4 @@
// Force reload
location.reload(true);
}
-}
+})();
diff --git a/src/client/app/common/define-widget.ts b/src/client/app/common/define-widget.ts
index 7b98c0903f..0b2bc36566 100644
--- a/src/client/app/common/define-widget.ts
+++ b/src/client/app/common/define-widget.ts
@@ -18,61 +18,65 @@ export default function<T extends object>(data: {
default: false
}
},
+
computed: {
id(): string {
return this.widget.id;
+ },
+
+ props(): T {
+ return this.widget.data;
}
},
+
data() {
return {
- props: data.props ? data.props() : {} as T,
- bakedOldProps: null,
- preventSave: false
+ bakedOldProps: null
};
},
+
created() {
- if (this.props) {
- Object.keys(this.props).forEach(prop => {
- if (this.widget.data.hasOwnProperty(prop)) {
- this.props[prop] = this.widget.data[prop];
- }
- });
- }
+ this.mergeProps();
+
+ this.$watch('props', () => {
+ this.mergeProps();
+ });
this.bakeProps();
+ },
+
+ methods: {
+ bakeProps() {
+ this.bakedOldProps = JSON.stringify(this.props);
+ },
- this.$watch('props', newProps => {
- if (this.preventSave) {
- this.preventSave = false;
- this.bakeProps();
- return;
+ mergeProps() {
+ if (data.props) {
+ const defaultProps = data.props();
+ Object.keys(defaultProps).forEach(prop => {
+ if (!this.props.hasOwnProperty(prop)) {
+ Vue.set(this.props, prop, defaultProps[prop]);
+ }
+ });
}
- if (this.bakedOldProps == JSON.stringify(newProps)) return;
+ },
+
+ save() {
+ if (this.bakedOldProps == JSON.stringify(this.props)) return;
this.bakeProps();
if (this.isMobile) {
(this as any).api('i/update_mobile_home', {
id: this.id,
- data: newProps
- }).then(() => {
- (this as any).os.i.clientSettings.mobileHome.find(w => w.id == this.id).data = newProps;
+ data: this.props
});
} else {
(this as any).api('i/update_home', {
id: this.id,
- data: newProps
- }).then(() => {
- (this as any).os.i.clientSettings.home.find(w => w.id == this.id).data = newProps;
+ data: this.props
});
}
- }, {
- deep: true
- });
- },
- methods: {
- bakeProps() {
- this.bakedOldProps = JSON.stringify(this.props);
}
}
});
diff --git a/src/client/app/common/scripts/check-for-update.ts b/src/client/app/common/scripts/check-for-update.ts
index 20ce64ea85..1e303017eb 100644
--- a/src/client/app/common/scripts/check-for-update.ts
+++ b/src/client/app/common/scripts/check-for-update.ts
@@ -1,9 +1,9 @@
-import MiOS from '../mios';
+import MiOS from '../../mios';
import { version as current } from '../../config';
export default async function(mios: MiOS, force = false, silent = false) {
const meta = await mios.getMeta(force);
- const newer = meta.version;
+ const newer = meta.clientVersion;
if (newer != current) {
localStorage.setItem('should-refresh', 'true');
diff --git a/src/client/app/common/scripts/streaming/channel.ts b/src/client/app/common/scripts/streaming/channel.ts
index cab5f4edb4..be68ec0997 100644
--- a/src/client/app/common/scripts/streaming/channel.ts
+++ b/src/client/app/common/scripts/streaming/channel.ts
@@ -1,5 +1,5 @@
import Stream from './stream';
-import MiOS from '../../mios';
+import MiOS from '../../../mios';
/**
* Channel stream connection
diff --git a/src/client/app/common/scripts/streaming/drive.ts b/src/client/app/common/scripts/streaming/drive.ts
index 7ff85b5946..50fff05737 100644
--- a/src/client/app/common/scripts/streaming/drive.ts
+++ b/src/client/app/common/scripts/streaming/drive.ts
@@ -1,6 +1,6 @@
import Stream from './stream';
import StreamManager from './stream-manager';
-import MiOS from '../../mios';
+import MiOS from '../../../mios';
/**
* Drive stream connection
diff --git a/src/client/app/common/scripts/streaming/global-timeline.ts b/src/client/app/common/scripts/streaming/global-timeline.ts
index 452ddbac03..a639f1595c 100644
--- a/src/client/app/common/scripts/streaming/global-timeline.ts
+++ b/src/client/app/common/scripts/streaming/global-timeline.ts
@@ -1,6 +1,6 @@
import Stream from './stream';
import StreamManager from './stream-manager';
-import MiOS from '../../mios';
+import MiOS from '../../../mios';
/**
* Global timeline stream connection
diff --git a/src/client/app/common/scripts/streaming/home.ts b/src/client/app/common/scripts/streaming/home.ts
index 73f2c5302c..32685f3c2c 100644
--- a/src/client/app/common/scripts/streaming/home.ts
+++ b/src/client/app/common/scripts/streaming/home.ts
@@ -2,7 +2,7 @@ import * as merge from 'object-assign-deep';
import Stream from './stream';
import StreamManager from './stream-manager';
-import MiOS from '../../mios';
+import MiOS from '../../../mios';
/**
* Home stream connection
@@ -25,10 +25,31 @@ export class HomeStream extends Stream {
console.log('I updated:', i);
}
merge(me, i);
+
+ // キャッシュ更新
+ os.bakeMe();
+ });
+
+ this.on('clientSettingUpdated', x => {
+ os.store.commit('settings/set', {
+ key: x.key,
+ value: x.value
+ });
+ });
+
+ this.on('home_updated', x => {
+ if (x.home) {
+ os.store.commit('settings/setHome', x.home);
+ } else {
+ os.store.commit('settings/setHomeWidget', {
+ id: x.id,
+ data: x.data
+ });
+ }
});
// トークンが再生成されたとき
- // このままではAPIが利用できないので強制的にサインアウトさせる
+ // このままではMisskeyが利用できないので強制的にサインアウトさせる
this.on('my_token_regenerated', () => {
alert('%i18n:!common.my-token-regenerated%');
os.signout();
diff --git a/src/client/app/common/scripts/streaming/local-timeline.ts b/src/client/app/common/scripts/streaming/local-timeline.ts
index 3d04e05cd4..2834262bdc 100644
--- a/src/client/app/common/scripts/streaming/local-timeline.ts
+++ b/src/client/app/common/scripts/streaming/local-timeline.ts
@@ -1,6 +1,6 @@
import Stream from './stream';
import StreamManager from './stream-manager';
-import MiOS from '../../mios';
+import MiOS from '../../../mios';
/**
* Local timeline stream connection
diff --git a/src/client/app/common/scripts/streaming/messaging-index.ts b/src/client/app/common/scripts/streaming/messaging-index.ts
index 84e2174ec4..addcccb952 100644
--- a/src/client/app/common/scripts/streaming/messaging-index.ts
+++ b/src/client/app/common/scripts/streaming/messaging-index.ts
@@ -1,6 +1,6 @@
import Stream from './stream';
import StreamManager from './stream-manager';
-import MiOS from '../../mios';
+import MiOS from '../../../mios';
/**
* Messaging index stream connection
diff --git a/src/client/app/common/scripts/streaming/messaging.ts b/src/client/app/common/scripts/streaming/messaging.ts
index c1b5875cfb..a59377d867 100644
--- a/src/client/app/common/scripts/streaming/messaging.ts
+++ b/src/client/app/common/scripts/streaming/messaging.ts
@@ -1,5 +1,5 @@
import Stream from './stream';
-import MiOS from '../../mios';
+import MiOS from '../../../mios';
/**
* Messaging stream connection
diff --git a/src/client/app/common/scripts/streaming/othello-game.ts b/src/client/app/common/scripts/streaming/othello-game.ts
index b85af8f72b..9e36f647bb 100644
--- a/src/client/app/common/scripts/streaming/othello-game.ts
+++ b/src/client/app/common/scripts/streaming/othello-game.ts
@@ -1,5 +1,5 @@
import Stream from './stream';
-import MiOS from '../../mios';
+import MiOS from '../../../mios';
export class OthelloGameStream extends Stream {
constructor(os: MiOS, me, game) {
diff --git a/src/client/app/common/scripts/streaming/othello.ts b/src/client/app/common/scripts/streaming/othello.ts
index f5d47431cd..8f4f217e39 100644
--- a/src/client/app/common/scripts/streaming/othello.ts
+++ b/src/client/app/common/scripts/streaming/othello.ts
@@ -1,6 +1,6 @@
import StreamManager from './stream-manager';
import Stream from './stream';
-import MiOS from '../../mios';
+import MiOS from '../../../mios';
export class OthelloStream extends Stream {
constructor(os: MiOS, me) {
diff --git a/src/client/app/common/scripts/streaming/server.ts b/src/client/app/common/scripts/streaming/server.ts
index 3d35ef4d9d..2ea4239288 100644
--- a/src/client/app/common/scripts/streaming/server.ts
+++ b/src/client/app/common/scripts/streaming/server.ts
@@ -1,6 +1,6 @@
import Stream from './stream';
import StreamManager from './stream-manager';
-import MiOS from '../../mios';
+import MiOS from '../../../mios';
/**
* Server stream connection
diff --git a/src/client/app/common/scripts/streaming/stream.ts b/src/client/app/common/scripts/streaming/stream.ts
index 3912186ad3..fefa8e5ced 100644
--- a/src/client/app/common/scripts/streaming/stream.ts
+++ b/src/client/app/common/scripts/streaming/stream.ts
@@ -2,7 +2,7 @@ import { EventEmitter } from 'eventemitter3';
import * as uuid from 'uuid';
import * as ReconnectingWebsocket from 'reconnecting-websocket';
import { wsUrl } from '../../../config';
-import MiOS from '../../mios';
+import MiOS from '../../../mios';
/**
* Misskey stream connection
diff --git a/src/client/app/common/scripts/streaming/user-list.ts b/src/client/app/common/scripts/streaming/user-list.ts
new file mode 100644
index 0000000000..30a52b98dd
--- /dev/null
+++ b/src/client/app/common/scripts/streaming/user-list.ts
@@ -0,0 +1,17 @@
+import Stream from './stream';
+import MiOS from '../../mios';
+
+export class UserListStream extends Stream {
+ constructor(os: MiOS, me, listId) {
+ super(os, 'user-list', {
+ i: me.token,
+ listId
+ });
+
+ (this as any).on('_connected_', () => {
+ this.send({
+ i: me.token
+ });
+ });
+ }
+}
diff --git a/src/client/app/common/views/components/autocomplete.vue b/src/client/app/common/views/components/autocomplete.vue
index 5c8f61a2a2..84173d20b5 100644
--- a/src/client/app/common/views/components/autocomplete.vue
+++ b/src/client/app/common/views/components/autocomplete.vue
@@ -234,7 +234,7 @@ export default Vue.extend({
margin-top calc(1em + 8px)
overflow hidden
background #fff
- border solid 1px rgba(0, 0, 0, 0.1)
+ border solid 1px rgba(#000, 0.1)
border-radius 4px
transition top 0.1s ease, left 0.1s ease
@@ -253,7 +253,7 @@ export default Vue.extend({
white-space nowrap
overflow hidden
font-size 0.9em
- color rgba(0, 0, 0, 0.8)
+ color rgba(#000, 0.8)
cursor default
&, *
@@ -285,10 +285,10 @@ export default Vue.extend({
.name
margin 0 8px 0 0
- color rgba(0, 0, 0, 0.8)
+ color rgba(#000, 0.8)
.username
- color rgba(0, 0, 0, 0.3)
+ color rgba(#000, 0.3)
> .emojis > li
@@ -298,10 +298,10 @@ export default Vue.extend({
width 24px
.name
- color rgba(0, 0, 0, 0.8)
+ color rgba(#000, 0.8)
.alias
margin 0 0 0 8px
- color rgba(0, 0, 0, 0.3)
+ color rgba(#000, 0.3)
</style>
diff --git a/src/client/app/common/views/components/avatar.vue b/src/client/app/common/views/components/avatar.vue
new file mode 100644
index 0000000000..a4648c272e
--- /dev/null
+++ b/src/client/app/common/views/components/avatar.vue
@@ -0,0 +1,42 @@
+<template>
+ <router-link class="mk-avatar" :to="user | userPage" :title="user | acct" :target="target" :style="style" v-if="disablePreview"></router-link>
+ <router-link class="mk-avatar" :to="user | userPage" :title="user | acct" :target="target" :style="style" v-else v-user-preview="user.id"></router-link>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ target: {
+ required: false,
+ default: null
+ },
+ disablePreview: {
+ required: false,
+ default: false
+ }
+ },
+ computed: {
+ style(): any {
+ return {
+ backgroundColor: this.user.avatarColor ? `rgb(${ this.user.avatarColor.join(',') })` : null,
+ backgroundImage: `url(${ this.user.avatarUrl }?thumbnail)`,
+ borderRadius: (this as any).clientSettings.circleIcons ? '100%' : null
+ };
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-avatar
+ display inline-block
+ vertical-align bottom
+ background-size cover
+ background-position center center
+ transition border-radius 1s ease
+</style>
diff --git a/src/client/app/common/views/components/google.vue b/src/client/app/common/views/components/google.vue
new file mode 100644
index 0000000000..92817d3c1f
--- /dev/null
+++ b/src/client/app/common/views/components/google.vue
@@ -0,0 +1,67 @@
+<template>
+<div class="mk-google">
+ <input type="search" v-model="query" :placeholder="q">
+ <button @click="search">検索</button>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ props: ['q'],
+ data() {
+ return {
+ query: null
+ };
+ },
+ mounted() {
+ this.query = this.q;
+ },
+ methods: {
+ search() {
+ window.open(`https://www.google.com/?#q=${this.query}`, '_blank');
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+root(isDark)
+ display flex
+ margin 8px 0
+
+ > input
+ flex-shrink 1
+ padding 10px
+ width 100%
+ height 40px
+ font-family sans-serif
+ font-size 16px
+ color isDark ? #dee4e8 : #55595c
+ background isDark ? #191b22 : #fff
+ border solid 1px isDark ? #495156 : #dadada
+ border-radius 4px 0 0 4px
+
+ &:hover
+ border-color isDark ? #777c86 : #b0b0b0
+
+ > button
+ flex-shrink 0
+ padding 0 16px
+ border solid 1px isDark ? #495156 : #dadada
+ border-left none
+ border-radius 0 4px 4px 0
+
+ &:hover
+ background-color isDark ? #2e3440 : #eee
+
+ &:active
+ box-shadow 0 2px 4px rgba(#000, 0.15) inset
+
+.mk-google[data-darkmode]
+ root(true)
+
+.mk-google:not([data-darkmode])
+ root(false)
+
+</style>
diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts
index 6bfe43a800..69fed00c74 100644
--- a/src/client/app/common/views/components/index.ts
+++ b/src/client/app/common/views/components/index.ts
@@ -3,6 +3,7 @@ import Vue from 'vue';
import signin from './signin.vue';
import signup from './signup.vue';
import forkit from './forkit.vue';
+import avatar from './avatar.vue';
import nav from './nav.vue';
import noteHtml from './note-html';
import poll from './poll.vue';
@@ -28,6 +29,7 @@ import welcomeTimeline from './welcome-timeline.vue';
Vue.component('mk-signin', signin);
Vue.component('mk-signup', signup);
Vue.component('mk-forkit', forkit);
+Vue.component('mk-avatar', avatar);
Vue.component('mk-nav', nav);
Vue.component('mk-note-html', noteHtml);
Vue.component('mk-poll', poll);
diff --git a/src/client/app/common/views/components/media-list.vue b/src/client/app/common/views/components/media-list.vue
index 64172ad0b4..ff9d5e1022 100644
--- a/src/client/app/common/views/components/media-list.vue
+++ b/src/client/app/common/views/components/media-list.vue
@@ -2,7 +2,7 @@
<div class="mk-media-list" :data-count="mediaList.length">
<template v-for="media in mediaList">
<mk-media-video :video="media" :key="media.id" v-if="media.type.startsWith('video')" :inline-playable="mediaList.length === 1"/>
- <mk-media-image :image="media" :key="media.id" v-else />
+ <mk-media-image :image="media" :key="media.id" v-else :raw="raw"/>
</template>
</div>
</template>
@@ -11,7 +11,14 @@
import Vue from 'vue';
export default Vue.extend({
- props: ['mediaList'],
+ props: {
+ mediaList: {
+ required: true
+ },
+ raw: {
+ default: false
+ }
+ }
});
</script>
@@ -23,7 +30,7 @@ export default Vue.extend({
@media (max-width 500px)
height 192px
-
+
&[data-count="1"]
grid-template-rows 1fr
&[data-count="2"]
@@ -40,7 +47,7 @@ export default Vue.extend({
&[data-count="4"]
grid-template-columns 1fr 1fr
grid-template-rows 1fr 1fr
-
+
:nth-child(1)
grid-column 1 / 2
grid-row 1 / 2
@@ -53,5 +60,5 @@ export default Vue.extend({
:nth-child(4)
grid-column 2 / 3
grid-row 2 / 3
-
+
</style>
diff --git a/src/client/app/common/views/components/messaging-room.message.vue b/src/client/app/common/views/components/messaging-room.message.vue
index afd700e777..ba0ab3209f 100644
--- a/src/client/app/common/views/components/messaging-room.message.vue
+++ b/src/client/app/common/views/components/messaging-room.message.vue
@@ -1,8 +1,6 @@
<template>
<div class="message" :data-is-me="isMe">
- <router-link class="avatar-anchor" :to="message.user | userPage" :title="message.user | acct" target="_blank">
- <img class="avatar" :src="`${message.user.avatarUrl}?thumbnail&size=80`" alt=""/>
- </router-link>
+ <mk-avatar class="avatar" :user="message.user" target="_blank"/>
<div class="content">
<div class="balloon" :data-no-text="message.text == null">
<p class="read" v-if="isMe && message.isRead">%i18n:@is-read%</p>
@@ -67,20 +65,14 @@ export default Vue.extend({
padding 10px 12px 10px 12px
background-color transparent
- > .avatar-anchor
+ > .avatar
display block
position absolute
top 10px
-
- > .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
+ width 54px
+ height 54px
+ border-radius 8px
+ transition all 0.1s ease
> .content
@@ -134,7 +126,7 @@ export default Vue.extend({
bottom -4px
left -12px
margin 0
- color rgba(0, 0, 0, 0.5)
+ color rgba(#000, 0.5)
font-size 11px
> .content
@@ -146,7 +138,7 @@ export default Vue.extend({
overflow hidden
overflow-wrap break-word
font-size 1em
- color rgba(0, 0, 0, 0.5)
+ color rgba(#000, 0.5)
> .text
display block
@@ -155,7 +147,7 @@ export default Vue.extend({
overflow hidden
overflow-wrap break-word
font-size 1em
- color rgba(0, 0, 0, 0.8)
+ color rgba(#000, 0.8)
& + .file
> a
@@ -195,13 +187,13 @@ export default Vue.extend({
display block
margin 2px 0 0 0
font-size 10px
- color rgba(0, 0, 0, 0.4)
+ color rgba(#000, 0.4)
> [data-fa]
margin-left 4px
&:not([data-is-me])
- > .avatar-anchor
+ > .avatar
left 12px
> .content
@@ -225,7 +217,7 @@ export default Vue.extend({
text-align left
&[data-is-me]
- > .avatar-anchor
+ > .avatar
right 12px
> .content
diff --git a/src/client/app/common/views/components/messaging-room.vue b/src/client/app/common/views/components/messaging-room.vue
index 38202d7581..a45114e6bb 100644
--- a/src/client/app/common/views/components/messaging-room.vue
+++ b/src/client/app/common/views/components/messaging-room.vue
@@ -256,7 +256,7 @@ export default Vue.extend({
padding 16px 8px 8px 8px
text-align center
font-size 0.8em
- color rgba(0, 0, 0, 0.4)
+ color rgba(#000, 0.4)
[data-fa]
margin-right 4px
@@ -267,7 +267,7 @@ export default Vue.extend({
padding 16px 8px 8px 8px
text-align center
font-size 0.8em
- color rgba(0, 0, 0, 0.4)
+ color rgba(#000, 0.4)
[data-fa]
margin-right 4px
@@ -278,7 +278,7 @@ export default Vue.extend({
padding 16px
text-align center
font-size 0.8em
- color rgba(0, 0, 0, 0.4)
+ color rgba(#000, 0.4)
[data-fa]
margin-right 4px
@@ -289,14 +289,14 @@ export default Vue.extend({
padding 0 12px
line-height 24px
color #fff
- background rgba(0, 0, 0, 0.3)
+ background rgba(#000, 0.3)
border-radius 12px
&:hover
- background rgba(0, 0, 0, 0.4)
+ background rgba(#000, 0.4)
&:active
- background rgba(0, 0, 0, 0.5)
+ background rgba(#000, 0.5)
&.fetching
cursor wait
@@ -322,7 +322,7 @@ export default Vue.extend({
left 0
right 0
margin 0 auto
- background rgba(0, 0, 0, 0.1)
+ background rgba(#000, 0.1)
> span
display inline-block
@@ -330,7 +330,7 @@ export default Vue.extend({
padding 0 16px
//font-weight bold
line-height 32px
- color rgba(0, 0, 0, 0.3)
+ color rgba(#000, 0.3)
background #fff
> footer
diff --git a/src/client/app/common/views/components/messaging.vue b/src/client/app/common/views/components/messaging.vue
index f74d9643eb..11f9c366d4 100644
--- a/src/client/app/common/views/components/messaging.vue
+++ b/src/client/app/common/views/components/messaging.vue
@@ -13,7 +13,7 @@
@click="navigate(user)"
tabindex="-1"
>
- <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=32`" alt=""/>
+ <mk-avatar class="avatar" :user="user"/>
<span class="name">{{ user | userName }}</span>
<span class="username">@{{ user | acct }}</span>
</li>
@@ -31,7 +31,7 @@
:key="message.id"
>
<div>
- <img class="avatar" :src="`${isMe(message) ? message.recipient.avatarUrl : message.user.avatarUrl}?thumbnail&size=64`" alt=""/>
+ <mk-avatar class="avatar" :user="isMe(message) ? message.recipient : message.user"/>
<header>
<span class="name">{{ isMe(message) ? message.recipient : message.user | userName }}</span>
<span class="username">@{{ isMe(message) ? message.recipient : message.user | acct }}</span>
@@ -169,7 +169,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-messaging
+root(isDark)
&[data-compact]
font-size 0.8em
@@ -205,11 +205,11 @@ export default Vue.extend({
z-index 1
width 100%
background #fff
- box-shadow 0 0px 2px rgba(0, 0, 0, 0.2)
+ box-shadow 0 0px 2px rgba(#000, 0.2)
> .form
padding 8px
- background #f7f7f7
+ background isDark ? #282c37 : #f7f7f7
> label
display block
@@ -241,13 +241,14 @@ export default Vue.extend({
line-height 38px
color #000
outline none
- border solid 1px #eee
+ background isDark ? #191b22 : #fff
+ border solid 1px isDark ? #495156 : #eee
border-radius 5px
box-shadow none
transition color 0.5s ease, border 0.5s ease
&:hover
- border solid 1px #ddd
+ border solid 1px isDark ? #b0b0b0 : #ddd
transition border 0.2s ease
&:focus
@@ -278,7 +279,7 @@ export default Vue.extend({
vertical-align top
white-space nowrap
overflow hidden
- color rgba(0, 0, 0, 0.8)
+ color rgba(#000, 0.8)
text-decoration none
transition none
cursor pointer
@@ -317,32 +318,32 @@ export default Vue.extend({
margin 0 8px 0 0
/*font-weight bold*/
font-weight normal
- color rgba(0, 0, 0, 0.8)
+ color rgba(#000, 0.8)
.username
font-weight normal
- color rgba(0, 0, 0, 0.3)
+ color rgba(#000, 0.3)
> .history
> a
display block
text-decoration none
- background #fff
- border-bottom solid 1px #eee
+ background isDark ? #282c37 : #fff
+ border-bottom solid 1px isDark ? #1c2023 : #eee
*
pointer-events none
user-select none
&:hover
- background #fafafa
+ background isDark ? #1e2129 : #fafafa
> .avatar
filter saturate(200%)
&:active
- background #eee
+ background isDark ? #14161b : #eee
&[data-is-read]
&[data-is-me]
@@ -382,17 +383,17 @@ export default Vue.extend({
overflow hidden
text-overflow ellipsis
font-size 1em
- color rgba(0, 0, 0, 0.9)
+ color isDark ? #fff : rgba(#000, 0.9)
font-weight bold
transition all 0.1s ease
> .username
margin 0 8px
- color rgba(0, 0, 0, 0.5)
+ color isDark ? #606984 : rgba(#000, 0.5)
> .mk-time
margin 0 0 0 auto
- color rgba(0, 0, 0, 0.5)
+ color isDark ? #606984 : rgba(#000, 0.5)
font-size 80%
> .avatar
@@ -412,10 +413,10 @@ export default Vue.extend({
overflow hidden
overflow-wrap break-word
font-size 1.1em
- color rgba(0, 0, 0, 0.8)
+ color isDark ? #fff : rgba(#000, 0.8)
.me
- color rgba(0, 0, 0, 0.4)
+ color isDark ? rgba(#fff, 0.7) : rgba(#000, 0.4)
> .image
display block
@@ -460,4 +461,10 @@ export default Vue.extend({
> .avatar
margin 0 12px 0 0
+.mk-messaging[data-darkmode]
+ root(true)
+
+.mk-messaging:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/common/views/components/nav.vue b/src/client/app/common/views/components/nav.vue
index 2295957928..cd1f99288a 100644
--- a/src/client/app/common/views/components/nav.vue
+++ b/src/client/app/common/views/components/nav.vue
@@ -2,16 +2,10 @@
<span class="mk-nav">
<a :href="aboutUrl">%i18n:@about%</a>
<i>・</i>
- <a :href="statsUrl">%i18n:@stats%</a>
- <i>・</i>
- <a :href="statusUrl">%i18n:@status%</a>
- <i>・</i>
- <a href="http://zawazawa.jp/misskey/">%i18n:@wiki%</a>
- <i>・</i>
- <a href="https://github.com/syuilo/misskey/blob/master/DONORS.md">%i18n:@donors%</a>
- <i>・</i>
<a href="https://github.com/syuilo/misskey">%i18n:@repository%</a>
<i>・</i>
+ <a href="https://github.com/syuilo/misskey/issues/new" target="_blank">%i18n:@feedback%</a>
+ <i>・</i>
<a :href="devUrl">%i18n:@develop%</a>
<i>・</i>
<a href="https://twitter.com/misskey_xyz" target="_blank">Follow us on %fa:B twitter%</a>
diff --git a/src/client/app/common/views/components/note-html.ts b/src/client/app/common/views/components/note-html.ts
index 24e750a671..f86b50659e 100644
--- a/src/client/app/common/views/components/note-html.ts
+++ b/src/client/app/common/views/components/note-html.ts
@@ -4,6 +4,7 @@ import parse from '../../../../../text/parse';
import getAcct from '../../../../../acct/render';
import { url } from '../../../config';
import MkUrl from './url.vue';
+import MkGoogle from './google.vue';
const flatten = list => list.reduce(
(a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), []
@@ -97,7 +98,9 @@ export default Vue.component('mk-note-html', {
}, token.content);
case 'code':
- return createElement('pre', [
+ return createElement('pre', {
+ class: 'code'
+ }, [
createElement('code', {
domProps: {
innerHTML: token.html
@@ -132,10 +135,24 @@ export default Vue.component('mk-note-html', {
}, text2.replace(/\n/g, ' '));
}
+ case 'title':
+ return createElement('div', {
+ attrs: {
+ class: 'title'
+ }
+ }, token.title);
+
case 'emoji':
const emoji = emojilib.lib[token.emoji];
return createElement('span', emoji ? emoji.char : token.content);
+ case 'search':
+ return createElement(MkGoogle, {
+ props: {
+ q: token.query
+ }
+ });
+
default:
console.log('unknown ast type:', token.type);
}
@@ -144,7 +161,7 @@ export default Vue.component('mk-note-html', {
const _els = [];
els.forEach((el, i) => {
if (el.tag == 'br') {
- if (els[i - 1].tag != 'div') {
+ if (!['div', 'pre'].includes(els[i - 1].tag)) {
_els.push(el);
}
} else {
diff --git a/src/client/app/common/views/components/note-menu.vue b/src/client/app/common/views/components/note-menu.vue
index 877d2c16bb..88dc22aaf4 100644
--- a/src/client/app/common/views/components/note-menu.vue
+++ b/src/client/app/common/views/components/note-menu.vue
@@ -2,6 +2,7 @@
<div class="mk-note-menu">
<div class="backdrop" ref="backdrop" @click="close"></div>
<div class="popover" :class="{ compact }" ref="popover">
+ <button @click="favorite">%i18n:@favorite%</button>
<button v-if="note.userId == os.i.id" @click="pin">%i18n:@pin%</button>
<a v-if="note.uri" :href="note.uri" target="_blank">%i18n:@remote%</a>
</div>
@@ -58,6 +59,14 @@ export default Vue.extend({
});
},
+ favorite() {
+ (this as any).api('notes/favorites/create', {
+ noteId: this.note.id
+ }).then(() => {
+ this.$destroy();
+ });
+ },
+
close() {
(this.$refs.backdrop as any).style.pointerEvents = 'none';
anime({
@@ -96,7 +105,7 @@ $border-color = rgba(27, 31, 35, 0.15)
z-index 10000
width 100%
height 100%
- background rgba(0, 0, 0, 0.1)
+ background rgba(#000, 0.1)
opacity 0
> .popover
@@ -142,6 +151,7 @@ $border-color = rgba(27, 31, 35, 0.15)
> a
display block
padding 8px 16px
+ width 100%
&:hover
color $theme-color-foreground
diff --git a/src/client/app/common/views/components/othello.vue b/src/client/app/common/views/components/othello.vue
index 8f7d9dfd6a..a0971c45b4 100644
--- a/src/client/app/common/views/components/othello.vue
+++ b/src/client/app/common/views/components/othello.vue
@@ -31,7 +31,7 @@
<section v-if="invitations.length > 0">
<h2>対局の招待があります!:</h2>
<div class="invitation" v-for="i in invitations" tabindex="-1" @click="accept(i)">
- <img :src="`${i.parent.avatarUrl}?thumbnail&size=32`" alt="">
+ <mk-avatar class="avatar" :user="i.parent"/>
<span class="name"><b>{{ i.parent.name }}</b></span>
<span class="username">@{{ i.parent.username }}</span>
<mk-time :time="i.createdAt"/>
@@ -40,8 +40,8 @@
<section v-if="myGames.length > 0">
<h2>自分の対局</h2>
<a class="game" v-for="g in myGames" tabindex="-1" @click.prevent="go(g)" :href="`/othello/${g.id}`">
- <img :src="`${g.user1.avatarUrl}?thumbnail&size=32`" alt="">
- <img :src="`${g.user2.avatarUrl}?thumbnail&size=32`" alt="">
+ <mk-avatar class="avatar" :user="g.user1"/>
+ <mk-avatar class="avatar" :user="g.user2"/>
<span><b>{{ g.user1.name }}</b> vs <b>{{ g.user2.name }}</b></span>
<span class="state">{{ g.isEnded ? '終了' : '進行中' }}</span>
</a>
@@ -49,8 +49,8 @@
<section v-if="games.length > 0">
<h2>みんなの対局</h2>
<a class="game" v-for="g in games" tabindex="-1" @click.prevent="go(g)" :href="`/othello/${g.id}`">
- <img :src="`${g.user1.avatarUrl}?thumbnail&size=32`" alt="">
- <img :src="`${g.user2.avatarUrl}?thumbnail&size=32`" alt="">
+ <mk-avatar class="avatar" :user="g.user1"/>
+ <mk-avatar class="avatar" :user="g.user2"/>
<span><b>{{ g.user1.name }}</b> vs <b>{{ g.user2.name }}</b></span>
<span class="state">{{ g.isEnded ? '終了' : '進行中' }}</span>
</a>
@@ -271,8 +271,9 @@ export default Vue.extend({
&:active
background #eee
- > img
- vertical-align bottom
+ > .avatar
+ width 32px
+ height 32px
border-radius 100%
> span
@@ -301,8 +302,9 @@ export default Vue.extend({
&:active
background #eee
- > img
- vertical-align bottom
+ > .avatar
+ width 32px
+ height 32px
border-radius 100%
> span
diff --git a/src/client/app/common/views/components/poll-editor.vue b/src/client/app/common/views/components/poll-editor.vue
index 189172679b..95bcba996e 100644
--- a/src/client/app/common/views/components/poll-editor.vue
+++ b/src/client/app/common/views/components/poll-editor.vue
@@ -69,7 +69,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-poll-editor
+root(isDark)
padding 8px
> .caution
@@ -102,6 +102,8 @@ export default Vue.extend({
padding 6px 8px
width 300px
font-size 14px
+ color isDark ? #fff : #000
+ background isDark ? #191b22 : #fff
border solid 1px rgba($theme-color, 0.1)
border-radius 4px
@@ -139,4 +141,10 @@ export default Vue.extend({
&:active
color darken($theme-color, 30%)
+.mk-poll-editor[data-darkmode]
+ root(true)
+
+.mk-poll-editor:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/common/views/components/poll.vue b/src/client/app/common/views/components/poll.vue
index 1834d4ddc2..46e41cbcdb 100644
--- a/src/client/app/common/views/components/poll.vue
+++ b/src/client/app/common/views/components/poll.vue
@@ -68,7 +68,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-poll
+root(isDark)
> ul
display block
@@ -81,16 +81,17 @@ export default Vue.extend({
margin 4px 0
padding 4px 8px
width 100%
- border solid 1px #eee
+ color isDark ? #fff : #000
+ border solid 1px isDark ? #5e636f : #eee
border-radius 4px
overflow hidden
cursor pointer
&:hover
- background rgba(0, 0, 0, 0.05)
+ background rgba(#000, 0.05)
&:active
- background rgba(0, 0, 0, 0.1)
+ background rgba(#000, 0.1)
> .backdrop
position absolute
@@ -108,6 +109,8 @@ export default Vue.extend({
margin-left 4px
> p
+ color isDark ? #a3aebf : #000
+
a
color inherit
@@ -121,4 +124,10 @@ export default Vue.extend({
&:active
background transparent
+.mk-poll[data-darkmode]
+ root(true)
+
+.mk-poll:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/common/views/components/reaction-picker.vue b/src/client/app/common/views/components/reaction-picker.vue
index 267eeb3a14..e2c8a6ed3f 100644
--- a/src/client/app/common/views/components/reaction-picker.vue
+++ b/src/client/app/common/views/components/reaction-picker.vue
@@ -110,7 +110,7 @@ export default Vue.extend({
$border-color = rgba(27, 31, 35, 0.15)
-.mk-reaction-picker
+root(isDark)
position initial
> .backdrop
@@ -120,13 +120,14 @@ $border-color = rgba(27, 31, 35, 0.15)
z-index 10000
width 100%
height 100%
- background rgba(0, 0, 0, 0.1)
+ background isDark ? rgba(#000, 0.4) : rgba(#000, 0.1)
opacity 0
> .popover
+ $bgcolor = isDark ? #2c303c : #fff
position absolute
z-index 10001
- background #fff
+ background $bgcolor
border 1px solid $border-color
border-radius 4px
box-shadow 0 3px 12px rgba(27, 31, 35, 0.15)
@@ -159,15 +160,15 @@ $border-color = rgba(27, 31, 35, 0.15)
border-top solid $balloon-size transparent
border-left solid $balloon-size transparent
border-right solid $balloon-size transparent
- border-bottom solid $balloon-size #fff
+ border-bottom solid $balloon-size $bgcolor
> p
display block
margin 0
padding 8px 10px
font-size 14px
- color #586069
- border-bottom solid 1px #e1e4e8
+ color isDark ? #d6dce2 : #586069
+ border-bottom solid 1px isDark ? #1c2023 : #e1e4e8
> div
padding 4px
@@ -182,10 +183,16 @@ $border-color = rgba(27, 31, 35, 0.15)
border-radius 2px
&:hover
- background #eee
+ background isDark ? #252731 : #eee
&:active
background $theme-color
box-shadow inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15)
+.mk-reaction-picker[data-darkmode]
+ root(true)
+
+.mk-reaction-picker:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/common/views/components/reactions-viewer.vue b/src/client/app/common/views/components/reactions-viewer.vue
index 1afcf525d2..97cb6be17c 100644
--- a/src/client/app/common/views/components/reactions-viewer.vue
+++ b/src/client/app/common/views/components/reactions-viewer.vue
@@ -1,15 +1,15 @@
<template>
<div class="mk-reactions-viewer">
<template v-if="reactions">
- <span v-if="reactions.like"><mk-reaction-icon reaction='like'/><span>{{ reactions.like }}</span></span>
- <span v-if="reactions.love"><mk-reaction-icon reaction='love'/><span>{{ reactions.love }}</span></span>
- <span v-if="reactions.laugh"><mk-reaction-icon reaction='laugh'/><span>{{ reactions.laugh }}</span></span>
- <span v-if="reactions.hmm"><mk-reaction-icon reaction='hmm'/><span>{{ reactions.hmm }}</span></span>
- <span v-if="reactions.surprise"><mk-reaction-icon reaction='surprise'/><span>{{ reactions.surprise }}</span></span>
- <span v-if="reactions.congrats"><mk-reaction-icon reaction='congrats'/><span>{{ reactions.congrats }}</span></span>
- <span v-if="reactions.angry"><mk-reaction-icon reaction='angry'/><span>{{ reactions.angry }}</span></span>
- <span v-if="reactions.confused"><mk-reaction-icon reaction='confused'/><span>{{ reactions.confused }}</span></span>
- <span v-if="reactions.pudding"><mk-reaction-icon reaction='pudding'/><span>{{ reactions.pudding }}</span></span>
+ <span v-if="reactions.like"><mk-reaction-icon reaction="like"/><span>{{ reactions.like }}</span></span>
+ <span v-if="reactions.love"><mk-reaction-icon reaction="love"/><span>{{ reactions.love }}</span></span>
+ <span v-if="reactions.laugh"><mk-reaction-icon reaction="laugh"/><span>{{ reactions.laugh }}</span></span>
+ <span v-if="reactions.hmm"><mk-reaction-icon reaction="hmm"/><span>{{ reactions.hmm }}</span></span>
+ <span v-if="reactions.surprise"><mk-reaction-icon reaction="surprise"/><span>{{ reactions.surprise }}</span></span>
+ <span v-if="reactions.congrats"><mk-reaction-icon reaction="congrats"/><span>{{ reactions.congrats }}</span></span>
+ <span v-if="reactions.angry"><mk-reaction-icon reaction="angry"/><span>{{ reactions.angry }}</span></span>
+ <span v-if="reactions.confused"><mk-reaction-icon reaction="confused"/><span>{{ reactions.confused }}</span></span>
+ <span v-if="reactions.pudding"><mk-reaction-icon reaction="pudding"/><span>{{ reactions.pudding }}</span></span>
</template>
</div>
</template>
@@ -27,9 +27,10 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.mk-reactions-viewer
- border-top dashed 1px #eee
- border-bottom dashed 1px #eee
+root(isDark)
+ $borderColor = isDark ? #5e6673 : #eee
+ border-top dashed 1px $borderColor
+ border-bottom dashed 1px $borderColor
margin 4px 0
&:empty
@@ -44,6 +45,12 @@ export default Vue.extend({
> span
margin-left 4px
font-size 1.2em
- color #444
+ color isDark ? #d1d5dc : #444
+
+.mk-reactions-viewer[data-darkmode]
+ root(true)
+
+.mk-reactions-viewer:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/common/views/components/signin.vue b/src/client/app/common/views/components/signin.vue
index 25f90a2f13..7fb9fc3fd4 100644
--- a/src/client/app/common/views/components/signin.vue
+++ b/src/client/app/common/views/components/signin.vue
@@ -91,7 +91,7 @@ export default Vue.extend({
width 100%
line-height 44px
font-size 1em
- color rgba(0, 0, 0, 0.7)
+ color rgba(#000, 0.7)
background #fff
outline none
border solid 1px #eee
@@ -117,7 +117,7 @@ export default Vue.extend({
margin -6px 0 0 0
width 100%
font-size 1.2em
- color rgba(0, 0, 0, 0.5)
+ color rgba(#000, 0.5)
outline none
border none
border-radius 0
diff --git a/src/client/app/common/views/components/signup.vue b/src/client/app/common/views/components/signup.vue
index 33a559ff8f..516979acd0 100644
--- a/src/client/app/common/views/components/signup.vue
+++ b/src/client/app/common/views/components/signup.vue
@@ -234,13 +234,13 @@ export default Vue.extend({
color #333 !important
background #fff !important
outline none
- border solid 1px rgba(0, 0, 0, 0.1)
+ border solid 1px rgba(#000, 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)
+ border-color rgba(#000, 0.2)
transition all .1s ease
&:focus
diff --git a/src/client/app/common/views/components/stream-indicator.vue b/src/client/app/common/views/components/stream-indicator.vue
index 93758102de..d573db32e6 100644
--- a/src/client/app/common/views/components/stream-indicator.vue
+++ b/src/client/app/common/views/components/stream-indicator.vue
@@ -73,7 +73,7 @@ export default Vue.extend({
padding 6px 12px
font-size 0.9em
color #fff
- background rgba(0, 0, 0, 0.8)
+ background rgba(#000, 0.8)
border-radius 4px
> p
diff --git a/src/client/app/common/views/components/switch.vue b/src/client/app/common/views/components/switch.vue
index 19a4adc3de..32caab638a 100644
--- a/src/client/app/common/views/components/switch.vue
+++ b/src/client/app/common/views/components/switch.vue
@@ -87,7 +87,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-switch
+root(isDark)
display flex
margin 12px 0
cursor pointer
@@ -121,11 +121,12 @@ export default Vue.extend({
&:hover
> .label
> span
- color #2e3338
+ color isDark ? #fff : #2e3338
> .button
- background #ced2da
- border-color #ced2da
+ $color = isDark ? #15181d : #ced2da
+ background $color
+ border-color $color
> input
position absolute
@@ -147,14 +148,16 @@ export default Vue.extend({
border-radius 14px
> .button
+ $color = isDark ? #1c1f25 : #dcdfe6
+
display inline-block
margin 0
width 40px
min-width 40px
height 20px
min-height 20px
- background #dcdfe6
- border 1px solid #dcdfe6
+ background $color
+ border 1px solid $color
outline none
border-radius 10px
transition inherit
@@ -179,12 +182,18 @@ export default Vue.extend({
> span
display block
line-height 20px
- color #4a535a
+ color isDark ? #c4ccd2 : #4a535a
transition inherit
> p
margin 0
//font-size 90%
- color #9daab3
+ color isDark ? #78858e : #9daab3
+
+.mk-switch[data-darkmode]
+ root(true)
+
+.mk-switch:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/common/views/components/twitter-setting.vue b/src/client/app/common/views/components/twitter-setting.vue
index 6ca1037aba..ab07e6d09a 100644
--- a/src/client/app/common/views/components/twitter-setting.vue
+++ b/src/client/app/common/views/components/twitter-setting.vue
@@ -50,8 +50,6 @@ export default Vue.extend({
<style lang="stylus" scoped>
.mk-twitter-setting
- color #4a535a
-
.account
border solid 1px #e1e8ed
border-radius 4px
diff --git a/src/client/app/common/views/components/url-preview.vue b/src/client/app/common/views/components/url-preview.vue
index fd25480f61..3bae6e5078 100644
--- a/src/client/app/common/views/components/url-preview.vue
+++ b/src/client/app/common/views/components/url-preview.vue
@@ -2,8 +2,8 @@
<iframe v-if="youtubeId" type="text/html" height="250"
:src="`https://www.youtube.com/embed/${youtubeId}?origin=${misskeyUrl}`"
frameborder="0"/>
-<div v-else>
- <a class="mk-url-preview" :href="url" target="_blank" :title="url" v-if="!fetching">
+<div v-else class="mk-url-preview">
+ <a :href="url" target="_blank" :title="url" v-if="!fetching">
<div class="thumbnail" v-if="thumbnail" :style="`background-image: url(${thumbnail})`"></div>
<article>
<header>
@@ -45,7 +45,7 @@ export default Vue.extend({
} else if (url.hostname == 'youtu.be') {
this.youtubeId = url.pathname;
} else {
- fetch('/url?url=' + this.url).then(res => {
+ fetch('/url?url=' + encodeURIComponent(this.url)).then(res => {
res.json().then(info => {
this.title = info.title;
this.description = info.description;
@@ -65,78 +65,85 @@ export default Vue.extend({
iframe
width 100%
-.mk-url-preview
- display block
- font-size 16px
- border solid 1px #eee
- border-radius 4px
- overflow hidden
+root(isDark)
+ > a
+ display block
+ font-size 16px
+ border solid 1px isDark ? #191b1f : #eee
+ border-radius 4px
+ overflow hidden
- &:hover
- text-decoration none
- border-color #ddd
+ &:hover
+ text-decoration none
+ border-color isDark ? #4f5561 : #ddd
- > article > header > h1
- text-decoration underline
+ > article > header > h1
+ text-decoration underline
- > .thumbnail
- position absolute
- width 100px
- height 100%
- background-position center
- background-size cover
+ > .thumbnail
+ position absolute
+ width 100px
+ height 100%
+ background-position center
+ background-size cover
+
+ & + article
+ left 100px
+ width calc(100% - 100px)
- & + article
- left 100px
- width calc(100% - 100px)
+ > article
+ padding 16px
- > article
- padding 16px
+ > header
+ margin-bottom 8px
- > header
- margin-bottom 8px
+ > h1
+ margin 0
+ font-size 1em
+ color isDark ? #d6dae0 : #555
- > h1
+ > p
margin 0
- font-size 1em
- color #555
+ color isDark ? #a4aab3 : #777
+ font-size 0.8em
- > p
- margin 0
- color #777
- font-size 0.8em
+ > footer
+ margin-top 8px
+ height 16px
- > footer
- margin-top 8px
- height 16px
+ > img
+ display inline-block
+ width 16px
+ height 16px
+ margin-right 4px
+ vertical-align top
- > img
- display inline-block
- width 16px
- height 16px
- margin-right 4px
- vertical-align top
+ > p
+ display inline-block
+ margin 0
+ color isDark ? #b0b4bf : #666
+ font-size 0.8em
+ line-height 16px
+ vertical-align top
- > p
- display inline-block
- margin 0
- color #666
- font-size 0.8em
- line-height 16px
- vertical-align top
+ @media (max-width 500px)
+ font-size 8px
+ border none
- @media (max-width 500px)
- font-size 8px
- border none
+ > .thumbnail
+ width 70px
- > .thumbnail
- width 70px
+ & + article
+ left 70px
+ width calc(100% - 70px)
- & + article
- left 70px
- width calc(100% - 70px)
+ > article
+ padding 8px
- > article
- padding 8px
+.mk-url-preview[data-darkmode]
+ root(true)
+
+.mk-url-preview:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/common/views/components/visibility-chooser.vue b/src/client/app/common/views/components/visibility-chooser.vue
new file mode 100644
index 0000000000..50f0877ae9
--- /dev/null
+++ b/src/client/app/common/views/components/visibility-chooser.vue
@@ -0,0 +1,223 @@
+<template>
+<div class="mk-visibility-chooser">
+ <div class="backdrop" ref="backdrop" @click="close"></div>
+ <div class="popover" :class="{ compact }" ref="popover">
+ <div @click="choose('public')" :class="{ active: v == 'public' }">
+ <div>%fa:globe%</div>
+ <div>
+ <span>公開</span>
+ </div>
+ </div>
+ <div @click="choose('home')" :class="{ active: v == 'home' }">
+ <div>%fa:home%</div>
+ <div>
+ <span>ホーム</span>
+ <span>ホームタイムラインにのみ公開</span>
+ </div>
+ </div>
+ <div @click="choose('followers')" :class="{ active: v == 'followers' }">
+ <div>%fa:unlock%</div>
+ <div>
+ <span>フォロワー</span>
+ <span>自分のフォロワーにのみ公開</span>
+ </div>
+ </div>
+ <div @click="choose('specified')" :class="{ active: v == 'specified' }">
+ <div>%fa:envelope%</div>
+ <div>
+ <span>ダイレクト</span>
+ <span>指定したユーザーにのみ公開</span>
+ </div>
+ </div>
+ <div @click="choose('private')" :class="{ active: v == 'private' }">
+ <div>%fa:lock%</div>
+ <div>
+ <span>非公開</span>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as anime from 'animejs';
+
+export default Vue.extend({
+ props: ['source', 'compact', 'v'],
+ mounted() {
+ this.$nextTick(() => {
+ const popover = this.$refs.popover as any;
+
+ const rect = this.source.getBoundingClientRect();
+ const width = popover.offsetWidth;
+ const height = popover.offsetHeight;
+
+ let left;
+ let top;
+
+ if (this.compact) {
+ const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
+ const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2);
+ left = (x - (width / 2));
+ top = (y - (height / 2));
+ } else {
+ const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
+ const y = rect.top + window.pageYOffset + this.source.offsetHeight;
+ left = (x - (width / 2));
+ top = y;
+ }
+
+ if (left + width > window.innerWidth) {
+ left = window.innerWidth - width;
+ }
+
+ popover.style.left = left + 'px';
+ popover.style.top = top + 'px';
+
+ anime({
+ targets: this.$refs.backdrop,
+ opacity: 1,
+ duration: 100,
+ easing: 'linear'
+ });
+
+ anime({
+ targets: this.$refs.popover,
+ opacity: 1,
+ scale: [0.5, 1],
+ duration: 500
+ });
+ });
+ },
+ methods: {
+ choose(visibility) {
+ this.$emit('chosen', visibility);
+ this.$destroy();
+ },
+ close() {
+ (this.$refs.backdrop as any).style.pointerEvents = 'none';
+ anime({
+ targets: this.$refs.backdrop,
+ opacity: 0,
+ duration: 200,
+ easing: 'linear'
+ });
+
+ (this.$refs.popover as any).style.pointerEvents = 'none';
+ anime({
+ targets: this.$refs.popover,
+ opacity: 0,
+ scale: 0.5,
+ duration: 200,
+ easing: 'easeInBack',
+ complete: () => this.$destroy()
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+$border-color = rgba(27, 31, 35, 0.15)
+
+root(isDark)
+ position initial
+
+ > .backdrop
+ position fixed
+ top 0
+ left 0
+ z-index 10000
+ width 100%
+ height 100%
+ background isDark ? rgba(#000, 0.4) : rgba(#000, 0.1)
+ opacity 0
+
+ > .popover
+ $bgcolor = isDark ? #2c303c : #fff
+ position absolute
+ z-index 10001
+ width 240px
+ padding 8px 0
+ background $bgcolor
+ border 1px solid $border-color
+ border-radius 4px
+ box-shadow 0 3px 12px rgba(27, 31, 35, 0.15)
+ transform scale(0.5)
+ opacity 0
+
+ $balloon-size = 10px
+
+ &:not(.compact)
+ margin-top $balloon-size
+ transform-origin center -($balloon-size)
+
+ &:before
+ content ""
+ display block
+ position absolute
+ top -($balloon-size * 2)
+ left s('calc(50% - %s)', $balloon-size)
+ border-top solid $balloon-size transparent
+ border-left solid $balloon-size transparent
+ border-right solid $balloon-size transparent
+ border-bottom solid $balloon-size $border-color
+
+ &:after
+ content ""
+ display block
+ position absolute
+ top -($balloon-size * 2) + 1.5px
+ left s('calc(50% - %s)', $balloon-size)
+ border-top solid $balloon-size transparent
+ border-left solid $balloon-size transparent
+ border-right solid $balloon-size transparent
+ border-bottom solid $balloon-size $bgcolor
+
+ > div
+ display flex
+ padding 8px 14px
+ font-size 12px
+ color isDark ? #fff : #666
+ cursor pointer
+
+ &:hover
+ background isDark ? #252731 : #eee
+
+ &:active
+ background isDark ? #21242b : #ddd
+
+ &.active
+ color $theme-color-foreground
+ background $theme-color
+
+ > *
+ user-select none
+ pointer-events none
+
+ > *:first-child
+ display flex
+ justify-content center
+ align-items center
+ margin-right 10px
+
+ > *:last-child
+ flex 1 1 auto
+
+ > span:first-child
+ display block
+ font-weight bold
+
+ > span:last-child:not(:first-child)
+ opacity 0.6
+
+.mk-visibility-chooser[data-darkmode]
+ root(true)
+
+.mk-visibility-chooser:not([data-darkmode])
+ root(false)
+
+</style>
diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue
index a80bc04f7f..6fadb030c3 100644
--- a/src/client/app/common/views/components/welcome-timeline.vue
+++ b/src/client/app/common/views/components/welcome-timeline.vue
@@ -1,9 +1,7 @@
<template>
<div class="mk-welcome-timeline">
<div v-for="note in notes">
- <router-link class="avatar-anchor" :to="note.user | userPage" v-user-preview="note.user.id">
- <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=96`" alt="avatar"/>
- </router-link>
+ <mk-avatar class="avatar" :user="note.user" target="_blank"/>
<div class="body">
<header>
<router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id">{{ note.user | userName }}</router-link>
@@ -62,25 +60,22 @@ export default Vue.extend({
overflow-wrap break-word
font-size .9em
color #4C4C4C
- border-bottom 1px solid rgba(0, 0, 0, 0.05)
+ border-bottom 1px solid rgba(#000, 0.05)
&:after
content ""
display block
clear both
- > .avatar-anchor
+ > .avatar
display block
float left
position -webkit-sticky
position sticky
top 16px
-
- > img
- display block
- width 42px
- height 42px
- border-radius 6px
+ width 42px
+ height 42px
+ border-radius 6px
> .body
float right
diff --git a/src/client/app/common/views/widgets/access-log.vue b/src/client/app/common/views/widgets/access-log.vue
index 637ba328c6..8652e35645 100644
--- a/src/client/app/common/views/widgets/access-log.vue
+++ b/src/client/app/common/views/widgets/access-log.vue
@@ -61,6 +61,7 @@ export default define({
} else {
this.props.design++;
}
+ this.save();
}
}
});
@@ -78,7 +79,7 @@ export default define({
color #555
&:nth-child(odd)
- background rgba(0, 0, 0, 0.025)
+ background rgba(#000, 0.025)
> b
margin-right 4px
diff --git a/src/client/app/common/views/widgets/broadcast.vue b/src/client/app/common/views/widgets/broadcast.vue
index 96d1d0ef3a..75b1d60524 100644
--- a/src/client/app/common/views/widgets/broadcast.vue
+++ b/src/client/app/common/views/widgets/broadcast.vue
@@ -68,6 +68,7 @@ export default define({
} else {
this.props.design++;
}
+ this.save();
}
}
});
diff --git a/src/client/app/common/views/widgets/calendar.vue b/src/client/app/common/views/widgets/calendar.vue
index 03f69a7597..41e9253784 100644
--- a/src/client/app/common/views/widgets/calendar.vue
+++ b/src/client/app/common/views/widgets/calendar.vue
@@ -73,6 +73,7 @@ export default define({
} else {
this.props.design++;
}
+ this.save();
},
tick() {
const now = new Date();
@@ -109,11 +110,11 @@ export default define({
<style lang="stylus" scoped>
@import '~const.styl'
-.mkw-calendar
+root(isDark)
padding 16px 0
- color #777
- background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
+ color isDark ? #c5ced6 :#777
+ background isDark ? #282C37 : #fff
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
&[data-special='on-new-years-day']
@@ -126,7 +127,7 @@ export default define({
&[data-mobile]
border none
border-radius 8px
- box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+ box-shadow 0 0 0 1px rgba(#000, 0.2)
&:after
content ""
@@ -171,7 +172,7 @@ export default define({
margin 0 0 2px 0
font-size 12px
line-height 18px
- color #888
+ color isDark ? #7a8692 : #888
> b
margin-left 2px
@@ -179,7 +180,7 @@ export default define({
> .meter
width 100%
overflow hidden
- background #eee
+ background isDark ? #1c1f25 : #eee
border-radius 8px
> .val
@@ -198,4 +199,10 @@ export default define({
> .meter > .val
background #41ddde
+.mkw-calendar[data-darkmode]
+ root(true)
+
+.mkw-calendar:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/common/views/widgets/donation.vue b/src/client/app/common/views/widgets/donation.vue
index 6b5a6697ed..e35462611d 100644
--- a/src/client/app/common/views/widgets/donation.vue
+++ b/src/client/app/common/views/widgets/donation.vue
@@ -19,9 +19,9 @@ export default define({
</script>
<style lang="stylus" scoped>
-.mkw-donation
- background #fff
- border solid 1px #ead8bb
+root(isDark)
+ background isDark ? #282c37 : #fff
+ border solid 1px isDark ? #c3831c : #ead8bb
border-radius 6px
> article
@@ -30,7 +30,7 @@ export default define({
> h1
margin 0 0 5px 0
font-size 1em
- color #888
+ color isDark ? #b2bac1 : #888
> [data-fa]
margin-right 0.25em
@@ -40,13 +40,13 @@ export default define({
z-index 1
margin 0
font-size 0.8em
- color #999
+ color isDark ? #a1a6ab : #999
&[data-mobile]
border none
background #ead8bb
border-radius 8px
- box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+ box-shadow 0 0 0 1px rgba(#000, 0.2)
> article
> h1
@@ -55,4 +55,10 @@ export default define({
> p
color #777d71
+.mkw-donation[data-darkmode]
+ root(true)
+
+.mkw-donation:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/common/views/widgets/nav.vue b/src/client/app/common/views/widgets/nav.vue
index 7bd5a7832f..0cbf7c158e 100644
--- a/src/client/app/common/views/widgets/nav.vue
+++ b/src/client/app/common/views/widgets/nav.vue
@@ -1,7 +1,7 @@
<template>
<div class="mkw-nav">
<mk-widget-container>
- <div :class="$style.body">
+ <div class="mkw-nav--body">
<mk-nav/>
</div>
</mk-widget-container>
@@ -15,17 +15,24 @@ export default define({
});
</script>
-<style lang="stylus" module>
-.body
- padding 16px
- font-size 12px
- color #aaa
- background #fff
+<style lang="stylus" scoped>
+root(isDark)
+ .mkw-nav--body
+ padding 16px
+ font-size 12px
+ color isDark ? #9aa4b3 : #aaa
+ background isDark ? #282c37 : #fff
- a
- color #999
+ a
+ color isDark ? #9aa4b3 : #999
- i
- color #ccc
+ i
+ color isDark ? #9aa4b3 : #ccc
+
+.mkw-nav[data-darkmode]
+ root(true)
+
+.mkw-nav:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/common/views/widgets/photo-stream.vue b/src/client/app/common/views/widgets/photo-stream.vue
index c51d932bd1..ae5924bb10 100644
--- a/src/client/app/common/views/widgets/photo-stream.vue
+++ b/src/client/app/common/views/widgets/photo-stream.vue
@@ -59,6 +59,8 @@ export default define({
} else {
this.props.design++;
}
+
+ this.save();
}
}
});
diff --git a/src/client/app/common/views/widgets/rss.vue b/src/client/app/common/views/widgets/rss.vue
index 4d74b2f7a4..b5339add0b 100644
--- a/src/client/app/common/views/widgets/rss.vue
+++ b/src/client/app/common/views/widgets/rss.vue
@@ -4,9 +4,11 @@
<template slot="header">%fa:rss-square%RSS</template>
<button slot="func" title="設定" @click="setting">%fa:cog%</button>
- <p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
- <div :class="$style.feed" v-else>
- <a v-for="item in items" :href="item.link" target="_blank">{{ item.title }}</a>
+ <div class="mkw-rss--body">
+ <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+ <div class="feed" v-else>
+ <a v-for="item in items" :href="item.link" target="_blank">{{ item.title }}</a>
+ </div>
</div>
</mk-widget-container>
</div>
@@ -38,6 +40,7 @@ export default define({
methods: {
func() {
this.props.compact = !this.props.compact;
+ this.save();
},
fetch() {
fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.url}`, {
@@ -56,38 +59,46 @@ export default define({
});
</script>
-<style lang="stylus" module>
-.feed
- padding 12px 16px
- font-size 0.9em
+<style lang="stylus" scoped>
+root(isDark)
+ .mkw-rss--body
+ .feed
+ padding 12px 16px
+ font-size 0.9em
- > a
- display block
- padding 4px 0
- color #666
- border-bottom dashed 1px #eee
+ > a
+ display block
+ padding 4px 0
+ color isDark ? #9aa4b3 : #666
+ border-bottom dashed 1px isDark ? #1c2023 : #eee
- &:last-child
- border-bottom none
+ &:last-child
+ border-bottom none
-.fetching
- margin 0
- padding 16px
- text-align center
- color #aaa
+ .fetching
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
- > [data-fa]
- margin-right 4px
+ > [data-fa]
+ margin-right 4px
-&[data-mobile]
- .feed
- padding 0
- font-size 1em
+ &[data-mobile]
+ .feed
+ padding 0
+ font-size 1em
- > a
- padding 8px 16px
+ > a
+ padding 8px 16px
- &:nth-child(even)
- background rgba(0, 0, 0, 0.05)
+ &:nth-child(even)
+ background rgba(#000, 0.05)
+
+.mkw-rss[data-darkmode]
+ root(true)
+
+.mkw-rss:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/common/views/widgets/server.cpu-memory.vue b/src/client/app/common/views/widgets/server.cpu-memory.vue
index d75a142568..fbd36b255a 100644
--- a/src/client/app/common/views/widgets/server.cpu-memory.vue
+++ b/src/client/app/common/views/widgets/server.cpu-memory.vue
@@ -100,7 +100,7 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.cpu-memory
+root(isDark)
> svg
display block
padding 10px
@@ -115,7 +115,7 @@ export default Vue.extend({
> text
font-size 5px
- fill rgba(0, 0, 0, 0.55)
+ fill isDark ? rgba(#fff, 0.55) : rgba(#000, 0.55)
> tspan
opacity 0.5
@@ -124,4 +124,11 @@ export default Vue.extend({
content ""
display block
clear both
+
+.cpu-memory[data-darkmode]
+ root(true)
+
+.cpu-memory:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/common/views/widgets/server.cpu.vue b/src/client/app/common/views/widgets/server.cpu.vue
index 596c856da8..b9748bdf7c 100644
--- a/src/client/app/common/views/widgets/server.cpu.vue
+++ b/src/client/app/common/views/widgets/server.cpu.vue
@@ -38,7 +38,7 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.cpu
+root(isDark)
> .pie
padding 10px
height 100px
@@ -52,7 +52,7 @@ export default Vue.extend({
> p
margin 0
font-size 12px
- color #505050
+ color isDark ? #a8b4bd : #505050
&:first-child
font-weight bold
@@ -65,4 +65,10 @@ export default Vue.extend({
display block
clear both
+.cpu[data-darkmode]
+ root(true)
+
+.cpu:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/common/views/widgets/server.disk.vue b/src/client/app/common/views/widgets/server.disk.vue
index 2af1982a96..5c7e9678de 100644
--- a/src/client/app/common/views/widgets/server.disk.vue
+++ b/src/client/app/common/views/widgets/server.disk.vue
@@ -46,7 +46,7 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.disk
+root(isDark)
> .pie
padding 10px
height 100px
@@ -60,7 +60,7 @@ export default Vue.extend({
> p
margin 0
font-size 12px
- color #505050
+ color isDark ? #a8b4bd : #505050
&:first-child
font-weight bold
@@ -73,4 +73,10 @@ export default Vue.extend({
display block
clear both
+.disk[data-darkmode]
+ root(true)
+
+.disk:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/common/views/widgets/server.memory.vue b/src/client/app/common/views/widgets/server.memory.vue
index 834a62671d..9212f2271f 100644
--- a/src/client/app/common/views/widgets/server.memory.vue
+++ b/src/client/app/common/views/widgets/server.memory.vue
@@ -46,7 +46,7 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.memory
+root(isDark)
> .pie
padding 10px
height 100px
@@ -60,7 +60,7 @@ export default Vue.extend({
> p
margin 0
font-size 12px
- color #505050
+ color isDark ? #a8b4bd : #505050
&:first-child
font-weight bold
@@ -73,4 +73,10 @@ export default Vue.extend({
display block
clear both
+.memory[data-darkmode]
+ root(true)
+
+.memory:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/common/views/widgets/server.pie.vue b/src/client/app/common/views/widgets/server.pie.vue
index ce2cff1d00..d557c52ea5 100644
--- a/src/client/app/common/views/widgets/server.pie.vue
+++ b/src/client/app/common/views/widgets/server.pie.vue
@@ -45,7 +45,7 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-svg
+root(isDark)
display block
height 100%
@@ -56,6 +56,12 @@ svg
> text
font-size 0.15px
- fill rgba(0, 0, 0, 0.6)
+ fill isDark ? rgba(#fff, 0.6) : rgba(#000, 0.6)
+
+svg[data-darkmode]
+ root(true)
+
+svg:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/common/views/widgets/server.vue b/src/client/app/common/views/widgets/server.vue
index 2fbc07adf0..2fdd60499b 100644
--- a/src/client/app/common/views/widgets/server.vue
+++ b/src/client/app/common/views/widgets/server.vue
@@ -68,6 +68,7 @@ export default define({
} else {
this.props.view++;
}
+ this.save();
},
func() {
if (this.props.design == 2) {
@@ -75,6 +76,7 @@ export default define({
} else {
this.props.design++;
}
+ this.save();
}
}
});
diff --git a/src/client/app/common/views/widgets/slideshow.vue b/src/client/app/common/views/widgets/slideshow.vue
index ad32299f37..459b24a32f 100644
--- a/src/client/app/common/views/widgets/slideshow.vue
+++ b/src/client/app/common/views/widgets/slideshow.vue
@@ -64,6 +64,7 @@ export default define({
} else {
this.props.size++;
}
+ this.save();
this.applySize();
},
@@ -111,6 +112,7 @@ export default define({
choose() {
(this as any).apis.chooseDriveFolder().then(folder => {
this.props.folder = folder ? folder.id : null;
+ this.save();
this.fetch();
});
}
@@ -122,13 +124,13 @@ export default define({
.mkw-slideshow
overflow hidden
background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
&[data-mobile]
border none
border-radius 8px
- box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+ box-shadow 0 0 0 1px rgba(#000, 0.2)
> div
width 100%
diff --git a/src/client/app/common/views/widgets/tips.vue b/src/client/app/common/views/widgets/tips.vue
index bdecc068e1..08e665f92f 100644
--- a/src/client/app/common/views/widgets/tips.vue
+++ b/src/client/app/common/views/widgets/tips.vue
@@ -17,7 +17,7 @@ const tips = [
'ドライブでファイルをドラッグしてフォルダ移動できます',
'ドライブでフォルダをドラッグしてフォルダ移動できます',
'ホームは設定からカスタマイズできます',
- 'MisskeyはMIT Licenseです',
+ 'MisskeyはAGPLv3です',
'タイムマシンウィジェットを利用すると、簡単に過去のタイムラインに遡れます',
'投稿の ... をクリックして、投稿をユーザーページにピン留めできます',
'ドライブの容量は(デフォルトで)1GBです',
diff --git a/src/client/app/desktop/api/update-avatar.ts b/src/client/app/desktop/api/update-avatar.ts
index dc89adeb86..8ddaebc072 100644
--- a/src/client/app/desktop/api/update-avatar.ts
+++ b/src/client/app/desktop/api/update-avatar.ts
@@ -1,4 +1,4 @@
-import OS from '../../common/mios';
+import OS from '../../mios';
import { apiUrl } from '../../config';
import CropWindow from '../views/components/crop-window.vue';
import ProgressDialog from '../views/components/progress-dialog.vue';
diff --git a/src/client/app/desktop/api/update-banner.ts b/src/client/app/desktop/api/update-banner.ts
index bc3f783e35..1a5da272bd 100644
--- a/src/client/app/desktop/api/update-banner.ts
+++ b/src/client/app/desktop/api/update-banner.ts
@@ -1,4 +1,4 @@
-import OS from '../../common/mios';
+import OS from '../../mios';
import { apiUrl } from '../../config';
import CropWindow from '../views/components/crop-window.vue';
import ProgressDialog from '../views/components/progress-dialog.vue';
@@ -95,7 +95,7 @@ export default (os: OS) => {
multiple: false,
title: '%fa:image%バナーにする画像を選択'
});
-
+
return selectedFile
.then(cropImage)
.then(setBanner)
diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts
index b3152e708b..2658a86b95 100644
--- a/src/client/app/desktop/script.ts
+++ b/src/client/app/desktop/script.ts
@@ -2,6 +2,7 @@
* Desktop Client
*/
+import Vue from 'vue';
import VueRouter from 'vue-router';
// Style
@@ -24,8 +25,10 @@ import updateBanner from './api/update-banner';
import MkIndex from './views/pages/index.vue';
import MkUser from './views/pages/user/user.vue';
+import MkFavorites from './views/pages/favorites.vue';
import MkSelectDrive from './views/pages/selectdrive.vue';
import MkDrive from './views/pages/drive.vue';
+import MkUserList from './views/pages/user-list.vue';
import MkHomeCustomize from './views/pages/home-customize.vue';
import MkMessagingRoom from './views/pages/messaging-room.vue';
import MkNote from './views/pages/note.vue';
@@ -49,9 +52,11 @@ init(async (launch) => {
routes: [
{ path: '/', name: 'index', component: MkIndex },
{ path: '/i/customize-home', component: MkHomeCustomize },
+ { path: '/i/favorites', component: MkFavorites },
{ path: '/i/messaging/:user', component: MkMessagingRoom },
{ path: '/i/drive', component: MkDrive },
{ path: '/i/drive/folder/:folder', component: MkDrive },
+ { path: '/i/lists/:list', component: MkUserList },
{ path: '/selectdrive', component: MkSelectDrive },
{ path: '/search', component: MkSearch },
{ path: '/othello', component: MkOthello },
diff --git a/src/client/app/desktop/style.styl b/src/client/app/desktop/style.styl
index 49f71fbde7..ea48fbee3d 100644
--- a/src/client/app/desktop/style.styl
+++ b/src/client/app/desktop/style.styl
@@ -44,6 +44,26 @@ html
height 100%
background #f7f7f7
+ &[data-darkmode]
+ background #191B22
+
+ &, *
+ &::-webkit-scrollbar-track
+ background-color #282C37
+
+ &::-webkit-scrollbar
+ width 6px
+ height 6px
+
+ &::-webkit-scrollbar-thumb
+ background-color #454954
+
+ &:hover
+ background-color #535660
+
+ &:active
+ background-color $theme-color
+
body
display flex
flex-direction column
diff --git a/src/client/app/desktop/ui.styl b/src/client/app/desktop/ui.styl
index 5a8d1718e2..b66c8f4025 100644
--- a/src/client/app/desktop/ui.styl
+++ b/src/client/app/desktop/ui.styl
@@ -123,3 +123,59 @@ textarea.ui
font-size 90%
font-weight bold
color rgba(#373a3c, 0.9)
+
+html[data-darkmode]
+ button.ui
+ .button.ui
+ color #fff
+ background linear-gradient(to bottom, #313543 0%, #282c37 100%)
+ border-color #1c2023
+
+ &:hover
+ background linear-gradient(to bottom, #2c2f3c 0%, #22262f 100%)
+ border-color #151a1d
+
+ &:active
+ background #22262f
+ border-color #151a1d
+
+ &.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%)
+
+ &: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
+
+ input:not([type]).ui
+ input[type='text'].ui
+ input[type='password'].ui
+ input[type='email'].ui
+ input[type='date'].ui
+ input[type='number'].ui
+ textarea.ui
+ display block
+ padding 10px
+ width 100%
+ height 40px
+ font-family sans-serif
+ font-size 16px
+ color #dee4e8
+ background #191b22
+ border solid 1px #495156
+ border-radius 4px
+
+ &:hover
+ border-color #b0b0b0
+
+ &:focus
+ border-color $theme-color
+
+ .ui.from.group
+ > p:first-child
+ color #c0c7cc
diff --git a/src/client/app/desktop/views/components/activity.calendar.vue b/src/client/app/desktop/views/components/activity.calendar.vue
index 8b43536c2b..e488571070 100644
--- a/src/client/app/desktop/views/components/activity.calendar.vue
+++ b/src/client/app/desktop/views/components/activity.calendar.vue
@@ -61,6 +61,6 @@ svg
&.day
&:hover
- fill rgba(0, 0, 0, 0.05)
+ fill rgba(#000, 0.05)
</style>
diff --git a/src/client/app/desktop/views/components/activity.vue b/src/client/app/desktop/views/components/activity.vue
index ea33bf9ff6..bd952c39d2 100644
--- a/src/client/app/desktop/views/components/activity.vue
+++ b/src/client/app/desktop/views/components/activity.vue
@@ -1,14 +1,15 @@
<template>
-<div class="mk-activity" :data-melt="design == 2">
- <template v-if="design == 0">
- <p class="title">%fa:chart-bar%%i18n:@title%</p>
- <button @click="toggle" title="%i18n:@toggle%">%fa:sort%</button>
- </template>
- <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
- <template v-else>
- <x-calendar v-show="view == 0" :data="[].concat(activity)"/>
- <x-chart v-show="view == 1" :data="[].concat(activity)"/>
- </template>
+<div class="mk-activity">
+ <mk-widget-container :show-header="design == 0" :naked="design == 2">
+ <template slot="header">%fa:chart-bar%%i18n:@title%</template>
+ <button slot="func" title="%i18n:@toggle%" @click="toggle">%fa:sort%</button>
+
+ <p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+ <template v-else>
+ <x-calendar v-show="view == 0" :data="[].concat(activity)"/>
+ <x-chart v-show="view == 1" :data="[].concat(activity)"/>
+ </template>
+ </mk-widget-container>
</div>
</template>
@@ -64,53 +65,14 @@ export default Vue.extend({
});
</script>
-<style lang="stylus" scoped>
-.mk-activity
- background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
- border-radius 6px
-
- &[data-melt]
- background transparent !important
- border none !important
-
- > .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)
-
- > [data-fa]
- 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
-
- > .fetching
- margin 0
- padding 16px
- text-align center
- color #aaa
+<style lang="stylus" module>
+.fetching
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
- > [data-fa]
- margin-right 4px
+ > [data-fa]
+ margin-right 4px
</style>
diff --git a/src/client/app/desktop/views/components/calendar.vue b/src/client/app/desktop/views/components/calendar.vue
index a99b48d195..1d8cc4f3a9 100644
--- a/src/client/app/desktop/views/components/calendar.vue
+++ b/src/client/app/desktop/views/components/calendar.vue
@@ -133,10 +133,10 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-calendar
- color #777
- background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
+root(isDark)
+ color isDark ? #c5ced6 : #777
+ background isDark ? #282C37 : #fff
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
&[data-melt]
@@ -152,7 +152,7 @@ export default Vue.extend({
font-size 0.9em
font-weight bold
color #888
- box-shadow 0 1px rgba(0, 0, 0, 0.07)
+ box-shadow 0 1px rgba(#000, 0.07)
> [data-fa]
margin-right 4px
@@ -214,10 +214,10 @@ export default Vue.extend({
border-radius 6px
&:hover > div
- background rgba(0, 0, 0, 0.025)
+ background rgba(#000, 0.025)
&:active > div
- background rgba(0, 0, 0, 0.05)
+ background rgba(#000, 0.05)
&[data-is-donichi]
color #ef95a0
@@ -233,10 +233,10 @@ export default Vue.extend({
font-weight bold
> div
- background rgba(0, 0, 0, 0.025)
+ background rgba(#000, 0.025)
&:active > div
- background rgba(0, 0, 0, 0.05)
+ background rgba(#000, 0.05)
&[data-today]
> div
@@ -249,4 +249,10 @@ export default Vue.extend({
&:active > div
background darken($theme-color, 10%)
+.mk-calendar[data-darkmode]
+ root(true)
+
+.mk-calendar:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/components/context-menu.menu.vue b/src/client/app/desktop/views/components/context-menu.menu.vue
index 6359dbf1b4..843604a059 100644
--- a/src/client/app/desktop/views/components/context-menu.menu.vue
+++ b/src/client/app/desktop/views/components/context-menu.menu.vue
@@ -31,7 +31,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.menu
+root(isDark)
$width = 240px
$item-height = 38px
$padding = 10px
@@ -46,7 +46,7 @@ export default Vue.extend({
&.divider
margin-top $padding
padding-top $padding
- border-top solid 1px #eee
+ border-top solid 1px isDark ? #1c2023 : #eee
&.nest
> p
@@ -75,7 +75,7 @@ export default Vue.extend({
margin 0
padding 0 32px 0 38px
line-height $item-height
- color #868C8C
+ color isDark ? #c8cece : #868C8C
text-decoration none
cursor pointer
@@ -104,11 +104,17 @@ export default Vue.extend({
left $width
margin-top -($padding)
width $width
- background #fff
+ background isDark ? #282c37 :#fff
border-radius 0 4px 4px 4px
- box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2)
+ box-shadow 2px 2px 8px rgba(#000, 0.2)
transition visibility 0s linear 0.2s
+.menu[data-darkmode]
+ root(true)
+
+.menu:not([data-darkmode])
+ root(false)
+
</style>
<style lang="stylus" module>
diff --git a/src/client/app/desktop/views/components/context-menu.vue b/src/client/app/desktop/views/components/context-menu.vue
index 8bd9945840..60a33f9c93 100644
--- a/src/client/app/desktop/views/components/context-menu.vue
+++ b/src/client/app/desktop/views/components/context-menu.vue
@@ -54,7 +54,7 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.context-menu
+root(isDark)
$width = 240px
$item-height = 38px
$padding = 10px
@@ -66,9 +66,15 @@ export default Vue.extend({
z-index 4096
width $width
font-size 0.8em
- background #fff
+ background isDark ? #282c37 : #fff
border-radius 0 4px 4px 4px
- box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2)
+ box-shadow 2px 2px 8px rgba(#000, 0.2)
opacity 0
+.context-menu[data-darkmode]
+ root(true)
+
+.context-menu:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/components/dialog.vue b/src/client/app/desktop/views/components/dialog.vue
index fa17e4a9d2..aff21c1754 100644
--- a/src/client/app/desktop/views/components/dialog.vue
+++ b/src/client/app/desktop/views/components/dialog.vue
@@ -102,7 +102,7 @@ export default Vue.extend({
left 0
width 100%
height 100%
- background rgba(0, 0, 0, 0.7)
+ background rgba(#000, 0.7)
opacity 0
pointer-events none
diff --git a/src/client/app/desktop/views/components/drive.file.vue b/src/client/app/desktop/views/components/drive.file.vue
index d79cb6c09c..39881711fa 100644
--- a/src/client/app/desktop/views/components/drive.file.vue
+++ b/src/client/app/desktop/views/components/drive.file.vue
@@ -186,7 +186,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.root.file
+root(isDark)
padding 8px 0 0 0
height 180px
border-radius 4px
@@ -195,7 +195,7 @@ export default Vue.extend({
cursor pointer
&:hover
- background rgba(0, 0, 0, 0.05)
+ background rgba(#000, 0.05)
> .label
&:before
@@ -203,7 +203,7 @@ export default Vue.extend({
background #0b65a5
&:active
- background rgba(0, 0, 0, 0.1)
+ background rgba(#000, 0.1)
> .label
&:before
@@ -308,10 +308,16 @@ export default Vue.extend({
font-size 0.8em
text-align center
word-break break-all
- color #444
+ color isDark ? #fff : #444
overflow hidden
> .ext
opacity 0.5
+.root.file[data-darkmode]
+ root(true)
+
+.root.file:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/components/drive.vue b/src/client/app/desktop/views/components/drive.vue
index 5e91048d19..973df1014d 100644
--- a/src/client/app/desktop/views/components/drive.vue
+++ b/src/client/app/desktop/views/components/drive.vue
@@ -577,7 +577,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-drive
+root(isDark)
> nav
display block
@@ -585,10 +585,9 @@ export default Vue.extend({
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)
+ color isDark ? #d2d9dc : #555
+ background isDark ? #282c37 : #fff
+ box-shadow 0 1px 0 rgba(#000, 0.05)
&, *
user-select none
@@ -665,6 +664,7 @@ export default Vue.extend({
padding 8px
height calc(100% - 38px)
overflow auto
+ background isDark ? #191b22 : #fff
&, *
user-select none
@@ -733,7 +733,7 @@ export default Vue.extend({
display inline-block
position absolute
top 0
- background-color rgba(0, 0, 0, 0.3)
+ background-color rgba(#000, 0.3)
border-radius 100%
animation sk-bounce 2.0s infinite ease-in-out
@@ -770,4 +770,10 @@ export default Vue.extend({
> input
display none
+.mk-drive[data-darkmode]
+ root(true)
+
+.mk-drive:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/components/ellipsis-icon.vue b/src/client/app/desktop/views/components/ellipsis-icon.vue
index c54a7db29d..4a5a0f23dc 100644
--- a/src/client/app/desktop/views/components/ellipsis-icon.vue
+++ b/src/client/app/desktop/views/components/ellipsis-icon.vue
@@ -14,7 +14,7 @@
display inline-block
width 18px
height 18px
- background-color rgba(0, 0, 0, 0.3)
+ background-color rgba(#000, 0.3)
border-radius 100%
animation bounce 1.4s infinite ease-in-out both
diff --git a/src/client/app/desktop/views/components/follow-button.vue b/src/client/app/desktop/views/components/follow-button.vue
index 9eb22b0fb8..60c6129f61 100644
--- a/src/client/app/desktop/views/components/follow-button.vue
+++ b/src/client/app/desktop/views/components/follow-button.vue
@@ -19,6 +19,7 @@
<script lang="ts">
import Vue from 'vue';
+
export default Vue.extend({
props: {
user: {
@@ -30,6 +31,7 @@ export default Vue.extend({
default: 'compact'
}
},
+
data() {
return {
wait: false,
@@ -37,6 +39,7 @@ export default Vue.extend({
connectionId: null
};
},
+
mounted() {
this.connection = (this as any).os.stream.getConnection();
this.connectionId = (this as any).os.stream.use();
@@ -44,13 +47,14 @@ export default Vue.extend({
this.connection.on('follow', this.onFollow);
this.connection.on('unfollow', this.onUnfollow);
},
+
beforeDestroy() {
this.connection.off('follow', this.onFollow);
this.connection.off('unfollow', this.onUnfollow);
(this as any).os.stream.dispose(this.connectionId);
},
- methods: {
+ methods: {
onFollow(user) {
if (user.id == this.user.id) {
this.user.isFollowing = user.isFollowing;
@@ -94,7 +98,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-follow-button
+root(isDark)
display block
cursor pointer
padding 0
@@ -121,17 +125,17 @@ export default Vue.extend({
border-radius 8px
&.follow
- color #888
- background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%)
- border solid 1px #e2e2e2
+ color isDark ? #fff : #888
+ background isDark ? linear-gradient(to bottom, #313543 0%, #282c37 100%) : linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%)
+ border solid 1px isDark ? #1c2023 : #e2e2e2
&:hover
- background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%)
- border-color #dcdcdc
+ background isDark ? linear-gradient(to bottom, #2c2f3c 0%, #22262f 100%) : linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%)
+ border-color isDark ? #151a1d : #dcdcdc
&:active
- background #ececec
- border-color #dcdcdc
+ background isDark ? #22262f : #ececec
+ border-color isDark ? #151a1d : #dcdcdc
&.unfollow
color $theme-color-foreground
@@ -161,4 +165,10 @@ export default Vue.extend({
i
margin-right 8px
+.mk-follow-button[data-darkmode]
+ root(true)
+
+.mk-follow-button:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/components/friends-maker.vue b/src/client/app/desktop/views/components/friends-maker.vue
index af5bde3ad5..3c1f8b8257 100644
--- a/src/client/app/desktop/views/components/friends-maker.vue
+++ b/src/client/app/desktop/views/components/friends-maker.vue
@@ -3,9 +3,7 @@
<p class="title">気になるユーザーをフォロー:</p>
<div class="users" v-if="!fetching && users.length > 0">
<div class="user" v-for="user in users" :key="user.id">
- <router-link class="avatar-anchor" :to="user | userPage">
- <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=42`" alt="" v-user-preview="user.id"/>
- </router-link>
+ <mk-avatar class="avatar" :user="user" target="_blank"/>
<div class="body">
<router-link class="name" :to="user | userPage" v-user-preview="user.id">{{ user | userName }}</router-link>
<p class="username">@{{ user | acct }}</p>
@@ -86,18 +84,13 @@ export default Vue.extend({
display block
clear both
- > .avatar-anchor
+ > .avatar
display block
float left
margin 0 12px 0 0
-
- > .avatar
- display block
- width 42px
- height 42px
- margin 0
- border-radius 8px
- vertical-align bottom
+ width 42px
+ height 42px
+ border-radius 8px
> .body
float left
diff --git a/src/client/app/desktop/views/components/home.vue b/src/client/app/desktop/views/components/home.vue
index 90e9d1b785..cae6233cd8 100644
--- a/src/client/app/desktop/views/components/home.vue
+++ b/src/client/app/desktop/views/components/home.vue
@@ -53,7 +53,7 @@
<div class="main">
<a @click="hint">カスタマイズのヒント</a>
<div>
- <mk-post-form v-if="os.i.clientSettings.showPostFormOnTopOfTl"/>
+ <mk-post-form v-if="clientSettings.showPostFormOnTopOfTl"/>
<mk-timeline ref="tl" @loaded="onTlLoaded"/>
</div>
</div>
@@ -63,7 +63,7 @@
<component v-for="widget in widgets[place]" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" @chosen="warp"/>
</div>
<div class="main">
- <mk-post-form v-if="os.i.clientSettings.showPostFormOnTopOfTl"/>
+ <mk-post-form v-if="clientSettings.showPostFormOnTopOfTl"/>
<mk-timeline ref="tl" @loaded="onTlLoaded" v-if="mode == 'timeline'"/>
<mk-mentions @loaded="onTlLoaded" v-if="mode == 'mentions'"/>
</div>
@@ -81,6 +81,7 @@ export default Vue.extend({
components: {
XDraggable
},
+
props: {
customize: {
type: Boolean,
@@ -91,61 +92,43 @@ export default Vue.extend({
default: 'timeline'
}
},
+
data() {
return {
connection: null,
connectionId: null,
widgetAdderSelected: null,
- trash: [],
- widgets: {
- left: [],
- right: []
- }
+ trash: []
};
},
+
computed: {
- home: {
- get(): any[] {
- //#region 互換性のため
- (this as any).os.i.clientSettings.home.forEach(w => {
- if (w.name == 'rss-reader') w.name = 'rss';
- if (w.name == 'user-recommendation') w.name = 'users';
- if (w.name == 'recommended-polls') w.name = 'polls';
- });
- //#endregion
- return (this as any).os.i.clientSettings.home;
- },
- set(value) {
- (this as any).os.i.clientSettings.home = value;
- }
+ home(): any[] {
+ return this.$store.state.settings.data.home;
},
left(): any[] {
return this.home.filter(w => w.place == 'left');
},
right(): any[] {
return this.home.filter(w => w.place == 'right');
+ },
+ widgets(): any {
+ return {
+ left: this.left,
+ right: this.right
+ };
}
},
- created() {
- this.widgets.left = this.left;
- this.widgets.right = this.right;
- this.$watch('os.i.clientSettings', i => {
- this.widgets.left = this.left;
- this.widgets.right = this.right;
- }, {
- deep: true
- });
- },
+
mounted() {
this.connection = (this as any).os.stream.getConnection();
this.connectionId = (this as any).os.stream.use();
-
- this.connection.on('home_updated', this.onHomeUpdated);
},
+
beforeDestroy() {
- this.connection.off('home_updated', this.onHomeUpdated);
(this as any).os.stream.dispose(this.connectionId);
},
+
methods: {
hint() {
(this as any).apis.dialog({
@@ -159,56 +142,44 @@ export default Vue.extend({
}]
});
},
+
onTlLoaded() {
this.$emit('loaded');
},
- onHomeUpdated(data) {
- if (data.home) {
- (this as any).os.i.clientSettings.home = data.home;
- this.widgets.left = data.home.filter(w => w.place == 'left');
- this.widgets.right = data.home.filter(w => w.place == 'right');
- } else {
- const w = (this as any).os.i.clientSettings.home.find(w => w.id == data.id);
- if (w != null) {
- w.data = data.data;
- this.$refs[w.id][0].preventSave = true;
- this.$refs[w.id][0].props = w.data;
- this.widgets.left = (this as any).os.i.clientSettings.home.filter(w => w.place == 'left');
- this.widgets.right = (this as any).os.i.clientSettings.home.filter(w => w.place == 'right');
- }
- }
- },
+
onWidgetContextmenu(widgetId) {
const w = (this.$refs[widgetId] as any)[0];
if (w.func) w.func();
},
+
onWidgetSort() {
this.saveHome();
},
+
onTrash(evt) {
this.saveHome();
},
+
addWidget() {
- const widget = {
+ this.$store.dispatch('settings/addHomeWidget', {
name: this.widgetAdderSelected,
id: uuid(),
place: 'left',
data: {}
- };
-
- this.widgets.left.unshift(widget);
- this.saveHome();
+ });
},
+
saveHome() {
const left = this.widgets.left;
const right = this.widgets.right;
- this.home = left.concat(right);
+ this.$store.commit('settings/setHome', left.concat(right));
left.forEach(w => w.place = 'left');
right.forEach(w => w.place = 'right');
(this as any).api('i/update_home', {
home: this.home
});
},
+
warp(date) {
(this.$refs.tl as any).warp(date);
}
@@ -219,7 +190,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-home
+root(isDark)
display block
&[data-customize]
@@ -249,8 +220,9 @@ export default Vue.extend({
left 0
width 100%
height 48px
- background #f7f7f7
- box-shadow 0 1px 1px rgba(0, 0, 0, 0.075)
+ color isDark ? #fff : #000
+ background isDark ? #313543 : #f7f7f7
+ box-shadow 0 1px 1px rgba(#000, 0.075)
> a
display block
@@ -278,7 +250,7 @@ export default Vue.extend({
> div
display flex
margin 0 auto
- max-width 1200px - 32px
+ max-width 1220px - 32px
> div
width 50%
@@ -289,7 +261,7 @@ export default Vue.extend({
line-height 48px
&.trash
- border-left solid 1px #ddd
+ border-left solid 1px isDark ? #1c2023 : #ddd
> div
width 100%
@@ -309,7 +281,7 @@ export default Vue.extend({
display flex
justify-content center
margin 0 auto
- max-width 1200px
+ max-width 1220px
> *
.customize-container
@@ -329,7 +301,7 @@ export default Vue.extend({
.mk-post-form
margin-bottom 16px
- border solid 1px #e5e5e5
+ border solid 1px rgba(#000, 0.075)
border-radius 4px
> *:not(.main)
@@ -357,4 +329,10 @@ export default Vue.extend({
max-width 700px
margin 0 auto
+.mk-home[data-darkmode]
+ root(true)
+
+.mk-home:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/components/index.ts b/src/client/app/desktop/views/components/index.ts
index 4f61f43692..f58d0706df 100644
--- a/src/client/app/desktop/views/components/index.ts
+++ b/src/client/app/desktop/views/components/index.ts
@@ -28,6 +28,7 @@ import friendsMaker from './friends-maker.vue';
import followers from './followers.vue';
import following from './following.vue';
import usersList from './users-list.vue';
+import userListTimeline from './user-list-timeline.vue';
import widgetContainer from './widget-container.vue';
Vue.component('mk-ui', ui);
@@ -58,4 +59,5 @@ Vue.component('mk-friends-maker', friendsMaker);
Vue.component('mk-followers', followers);
Vue.component('mk-following', following);
Vue.component('mk-users-list', usersList);
+Vue.component('mk-user-list-timeline', userListTimeline);
Vue.component('mk-widget-container', widgetContainer);
diff --git a/src/client/app/desktop/views/components/media-image-dialog.vue b/src/client/app/desktop/views/components/media-image-dialog.vue
index dec140d1c9..026522d907 100644
--- a/src/client/app/desktop/views/components/media-image-dialog.vue
+++ b/src/client/app/desktop/views/components/media-image-dialog.vue
@@ -52,7 +52,7 @@ export default Vue.extend({
left 0
width 100%
height 100%
- background rgba(0, 0, 0, 0.7)
+ background rgba(#000, 0.7)
> img
position fixed
diff --git a/src/client/app/desktop/views/components/media-image.vue b/src/client/app/desktop/views/components/media-image.vue
index 51309a0578..e5803cc36e 100644
--- a/src/client/app/desktop/views/components/media-image.vue
+++ b/src/client/app/desktop/views/components/media-image.vue
@@ -14,12 +14,20 @@ import Vue from 'vue';
import MkMediaImageDialog from './media-image-dialog.vue';
export default Vue.extend({
- props: ['image'],
+ props: {
+ image: {
+ type: Object,
+ required: true
+ },
+ raw: {
+ default: false
+ }
+ },
computed: {
style(): any {
return {
'background-color': this.image.properties.avgColor ? `rgb(${this.image.properties.avgColor.join(',')})` : 'transparent',
- 'background-image': `url(${this.image.url}?thumbnail&size=512)`
+ 'background-image': this.raw ? `url(${this.image.url})` : `url(${this.image.url}?thumbnail&size=512)`
};
}
},
@@ -31,7 +39,7 @@ export default Vue.extend({
const xp = mouseX / this.$el.offsetWidth * 100;
const yp = mouseY / this.$el.offsetHeight * 100;
this.$el.style.backgroundPosition = xp + '% ' + yp + '%';
- this.$el.style.backgroundImage = 'url("' + this.image.url + '?thumbnail")';
+ this.$el.style.backgroundImage = `url("${this.image.url}")`;
},
onMouseleave() {
diff --git a/src/client/app/desktop/views/components/media-video-dialog.vue b/src/client/app/desktop/views/components/media-video-dialog.vue
index cbf862cd1c..959cefa42c 100644
--- a/src/client/app/desktop/views/components/media-video-dialog.vue
+++ b/src/client/app/desktop/views/components/media-video-dialog.vue
@@ -54,7 +54,7 @@ export default Vue.extend({
left 0
width 100%
height 100%
- background rgba(0, 0, 0, 0.7)
+ background rgba(#000, 0.7)
> video
position fixed
diff --git a/src/client/app/desktop/views/components/media-video.vue b/src/client/app/desktop/views/components/media-video.vue
index 4fd955a821..3635941e64 100644
--- a/src/client/app/desktop/views/components/media-video.vue
+++ b/src/client/app/desktop/views/components/media-video.vue
@@ -52,6 +52,7 @@ export default Vue.extend({
width 100%
height 100%
border-radius 4px
+
.mk-media-video-thumbnail
display flex
justify-content center
diff --git a/src/client/app/desktop/views/components/mentions.vue b/src/client/app/desktop/views/components/mentions.vue
index fc3a7af75d..66bdab5c08 100644
--- a/src/client/app/desktop/views/components/mentions.vue
+++ b/src/client/app/desktop/views/components/mentions.vue
@@ -1,8 +1,8 @@
<template>
<div class="mk-mentions">
<header>
- <span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて</span>
- <span :data-is-active="mode == 'following'" @click="mode = 'following'">フォロー中</span>
+ <span :data-active="mode == 'all'" @click="mode = 'all'">すべて</span>
+ <span :data-active="mode == 'following'" @click="mode = 'following'">フォロー中</span>
</header>
<div class="fetching" v-if="fetching">
<mk-ellipsis-icon/>
@@ -85,7 +85,7 @@ export default Vue.extend({
.mk-mentions
background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
> header
@@ -98,7 +98,7 @@ export default Vue.extend({
font-size 18px
color #555
- &:not([data-is-active])
+ &:not([data-active])
color $theme-color
cursor pointer
diff --git a/src/client/app/desktop/views/components/note-detail.sub.vue b/src/client/app/desktop/views/components/note-detail.sub.vue
index 16bc2a1d98..24550c4e94 100644
--- a/src/client/app/desktop/views/components/note-detail.sub.vue
+++ b/src/client/app/desktop/views/components/note-detail.sub.vue
@@ -1,8 +1,6 @@
<template>
<div class="sub" :title="title">
- <router-link class="avatar-anchor" :to="note.user | userPage">
- <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="note.userId"/>
- </router-link>
+ <mk-avatar class="avatar" :user="note.user"/>
<div class="main">
<header>
<div class="left">
@@ -16,8 +14,11 @@
</div>
</header>
<div class="body">
- <mk-note-html v-if="note.text" :text="note.text" :i="os.i" :class="$style.text"/>
- <div class="media" v-if="note.media > 0">
+ <div class="text">
+ <span v-if="note.isHidden" style="opacity: 0.5">(この投稿は非公開です)</span>
+ <mk-note-html v-if="note.text" :text="note.text" :i="os.i"/>
+ </div>
+ <div class="media" v-if="note.mediaIds.length > 0">
<mk-media-list :media-list="note.media"/>
</div>
</div>
@@ -40,10 +41,10 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.sub
+root(isDark)
margin 0
padding 20px 32px
- background #fdfdfd
+ background isDark ? #21242d : #fdfdfd
&:after
content ""
@@ -54,18 +55,13 @@ export default Vue.extend({
> .main > footer > button
color #888
- > .avatar-anchor
+ > .avatar
display block
float left
margin 0 16px 0 0
-
- > .avatar
- display block
- width 44px
- height 44px
- margin 0
- border-radius 4px
- vertical-align bottom
+ width 44px
+ height 44px
+ border-radius 4px
> .main
float left
@@ -87,7 +83,7 @@ export default Vue.extend({
display inline
margin 0
padding 0
- color #777
+ color isDark ? #fff : #777
font-size 1em
font-weight 700
text-align left
@@ -99,24 +95,29 @@ export default Vue.extend({
> .username
text-align left
margin 0 0 0 8px
- color #ccc
+ color isDark ? #606984 : #ccc
> .right
float right
> .time
font-size 0.9em
- color #c0c0c0
+ color isDark ? #606984 : #c0c0c0
-</style>
+ > .body
+ > .text
+ cursor default
+ display block
+ margin 0
+ padding 0
+ overflow-wrap break-word
+ font-size 1em
+ color isDark ? #959ba7 : #717171
+
+.sub[data-darkmode]
+ root(true)
+
+.sub:not([data-darkmode])
+ root(false)
-<style lang="stylus" module>
-.text
- cursor default
- display block
- margin 0
- padding 0
- overflow-wrap break-word
- font-size 1em
- color #717171
</style>
diff --git a/src/client/app/desktop/views/components/note-detail.vue b/src/client/app/desktop/views/components/note-detail.vue
index b62a7cfd61..a0e3915149 100644
--- a/src/client/app/desktop/views/components/note-detail.vue
+++ b/src/client/app/desktop/views/components/note-detail.vue
@@ -18,18 +18,14 @@
</div>
<div class="renote" v-if="isRenote">
<p>
- <router-link class="avatar-anchor" :to="note.user | userPage" v-user-preview="note.userId">
- <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=32`" alt="avatar"/>
- </router-link>
+ <mk-avatar class="avatar" :user="note.user"/>
%fa:retweet%
<router-link class="name" :href="note.user | userPage">{{ note.user | userName }}</router-link>
がRenote
</p>
</div>
<article>
- <router-link class="avatar-anchor" :to="p.user | userPage">
- <img class="avatar" :src="`${p.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="p.user.id"/>
- </router-link>
+ <mk-avatar class="avatar" :user="p.user"/>
<header>
<router-link class="name" :to="p.user | userPage" v-user-preview="p.user.id">{{ p.user | userName }}</router-link>
<span class="username">@{{ p.user | acct }}</span>
@@ -38,9 +34,12 @@
</router-link>
</header>
<div class="body">
- <mk-note-html :class="$style.text" v-if="p.text" :text="p.text" :i="os.i"/>
+ <div class="text">
+ <span v-if="p.isHidden" style="opacity: 0.5">(この投稿は非公開です)</span>
+ <mk-note-html v-if="p.text" :text="p.text" :i="os.i"/>
+ </div>
<div class="media" v-if="p.media.length > 0">
- <mk-media-list :media-list="p.media"/>
+ <mk-media-list :media-list="p.media" :raw="true"/>
</div>
<mk-poll v-if="p.poll" :note="p"/>
<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
@@ -56,7 +55,9 @@
<footer>
<mk-reactions-viewer :note="p"/>
<button @click="reply" title="返信">
- %fa:reply%<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p>
+ <template v-if="p.reply">%fa:reply-all%</template>
+ <template v-else>%fa:reply%</template>
+ <p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p>
</button>
<button @click="renote" title="Renote">
%fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p>
@@ -154,7 +155,7 @@ export default Vue.extend({
// Draw map
if (this.p.geo) {
- const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.clientSettings.showMaps : true;
+ const shouldShowMap = (this as any).os.isSignedIn ? (this as any).clientSettings.showMaps : true;
if (shouldShowMap) {
(this as any).os.getGoogleMaps().then(maps => {
const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]);
@@ -212,13 +213,13 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-note-detail
- margin 0
+root(isDark)
+ margin 0 auto
padding 0
overflow hidden
text-align left
- background #fff
- border solid 1px rgba(0, 0, 0, 0.1)
+ background isDark ? #282C37 : #fff
+ border solid 1px rgba(#000, 0.1)
border-radius 8px
> .read-more
@@ -230,44 +231,39 @@ export default Vue.extend({
text-align center
color #999
cursor pointer
- background #fafafa
+ background isDark ? #21242d : #fafafa
outline none
border none
- border-bottom solid 1px #eef0f2
+ border-bottom solid 1px isDark ? #1c2023 : #eef0f2
border-radius 6px 6px 0 0
&:hover
- background #f6f6f6
+ background isDark ? #2e3440 : #f6f6f6
&:active
- background #f0f0f0
+ background isDark ? #21242b : #f0f0f0
&:disabled
- color #ccc
+ color isDark ? #21242b : #ccc
> .context
> *
- border-bottom 1px solid #eef0f2
+ border-bottom 1px solid isDark ? #1c2023 : #eef0f2
> .renote
color #9dbb00
- background linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+ background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%)
> p
margin 0
padding 16px 32px
- .avatar-anchor
+ .avatar
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
+ width 28px
+ height 28px
+ margin 0 8px 0 0
+ border-radius 6px
[data-fa]
margin-right 4px
@@ -279,7 +275,7 @@ export default Vue.extend({
padding-top 8px
> .reply-to
- border-bottom 1px solid #eef0f2
+ border-bottom 1px solid isDark ? #1c2023 : #eef0f2
> article
padding 28px 32px 18px 32px
@@ -290,21 +286,13 @@ export default Vue.extend({
clear both
&:hover
- > .main > footer > button
- color #888
+ > footer > button
+ color isDark ? #707b97 : #888
- > .avatar-anchor
- display block
+ > .avatar
width 60px
height 60px
-
- > .avatar
- display block
- width 60px
- height 60px
- margin 0
- border-radius 8px
- vertical-align bottom
+ border-radius 8px
> header
position absolute
@@ -316,7 +304,7 @@ export default Vue.extend({
display inline-block
margin 0
line-height 24px
- color #777
+ color isDark ? #fff : #627079
font-size 18px
font-weight 700
text-align left
@@ -329,18 +317,27 @@ export default Vue.extend({
display block
text-align left
margin 0
- color #ccc
+ color isDark ? #606984 : #ccc
> .time
position absolute
top 0
right 32px
font-size 1em
- color #c0c0c0
+ color isDark ? #606984 : #c0c0c0
> .body
padding 8px 0
+ > .text
+ cursor default
+ display block
+ margin 0
+ padding 0
+ overflow-wrap break-word
+ font-size 1.5em
+ color isDark ? #fff : #717171
+
> .renote
margin 8px 0
@@ -402,11 +399,11 @@ export default Vue.extend({
background transparent
border none
font-size 1em
- color #ddd
+ color isDark ? #606984 : #ccc
cursor pointer
&:hover
- color #666
+ color isDark ? #9198af : #666
> .count
display inline
@@ -418,17 +415,12 @@ export default Vue.extend({
> .replies
> *
- border-top 1px solid #eef0f2
+ border-top 1px solid isDark ? #1c2023 : #eef0f2
-</style>
+.mk-note-detail[data-darkmode]
+ root(true)
+
+.mk-note-detail:not([data-darkmode])
+ root(false)
-<style lang="stylus" module>
-.text
- cursor default
- display block
- margin 0
- padding 0
- overflow-wrap break-word
- font-size 1.5em
- color #717171
</style>
diff --git a/src/client/app/desktop/views/components/note-preview.vue b/src/client/app/desktop/views/components/note-preview.vue
index ff3ecadc20..d04abfc5a7 100644
--- a/src/client/app/desktop/views/components/note-preview.vue
+++ b/src/client/app/desktop/views/components/note-preview.vue
@@ -1,8 +1,6 @@
<template>
<div class="mk-note-preview" :title="title">
- <router-link class="avatar-anchor" :to="note.user | userPage">
- <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="note.userId"/>
- </router-link>
+ <mk-avatar class="avatar" :user="note.user"/>
<div class="main">
<header>
<router-link class="name" :to="note.user | userPage" v-user-preview="note.userId">{{ note.user | userName }}</router-link>
@@ -33,31 +31,21 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.mk-note-preview
+root(isDark)
font-size 0.9em
- background #fff
&:after
content ""
display block
clear both
- &:hover
- > .main > footer > button
- color #888
-
- > .avatar-anchor
+ > .avatar
display block
float left
margin 0 16px 0 0
-
- > .avatar
- display block
- width 52px
- height 52px
- margin 0
- border-radius 8px
- vertical-align bottom
+ width 52px
+ height 52px
+ border-radius 8px
> .main
float left
@@ -65,12 +53,13 @@ export default Vue.extend({
> header
display flex
+ align-items baseline
white-space nowrap
> .name
margin 0 .5em 0 0
padding 0
- color #607073
+ color isDark ? #fff : #607073
font-size 1em
font-weight bold
text-decoration none
@@ -81,11 +70,11 @@ export default Vue.extend({
> .username
margin 0 .5em 0 0
- color #d1d8da
+ color isDark ? #606984 : #d1d8da
> .time
margin-left auto
- color #b2b8bb
+ color isDark ? #606984 : #b2b8bb
> .body
@@ -94,6 +83,12 @@ export default Vue.extend({
margin 0
padding 0
font-size 1.1em
- color #717171
+ color isDark ? #959ba7 : #717171
+
+.mk-note-preview[data-darkmode]
+ root(true)
+
+.mk-note-preview:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/desktop/views/components/notes.note.sub.vue b/src/client/app/desktop/views/components/notes.note.sub.vue
index e854785783..575d605203 100644
--- a/src/client/app/desktop/views/components/notes.note.sub.vue
+++ b/src/client/app/desktop/views/components/notes.note.sub.vue
@@ -1,15 +1,22 @@
<template>
<div class="sub" :title="title">
- <router-link class="avatar-anchor" :to="note.user | userPage">
- <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="note.userId"/>
- </router-link>
+ <mk-avatar class="avatar" :user="note.user"/>
<div class="main">
<header>
<router-link class="name" :to="note.user | userPage" v-user-preview="note.userId">{{ note.user | userName }}</router-link>
<span class="username">@{{ note.user | acct }}</span>
- <router-link class="created-at" :to="note | notePage">
- <mk-time :time="note.createdAt"/>
- </router-link>
+ <div class="info">
+ <span class="mobile" v-if="note.viaMobile">%fa:mobile-alt%</span>
+ <router-link class="created-at" :to="note | notePage">
+ <mk-time :time="note.createdAt"/>
+ </router-link>
+ <span class="visibility" v-if="note.visibility != 'public'">
+ <template v-if="note.visibility == 'home'">%fa:home%</template>
+ <template v-if="note.visibility == 'followers'">%fa:unlock%</template>
+ <template v-if="note.visibility == 'specified'">%fa:envelope%</template>
+ <template v-if="note.visibility == 'private'">%fa:lock%</template>
+ </span>
+ </div>
</header>
<div class="body">
<mk-sub-note-content class="text" :note="note"/>
@@ -33,32 +40,24 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.sub
+root(isDark)
margin 0
- padding 16px
+ padding 16px 32px
font-size 0.9em
+ background isDark ? #21242d : #fcfcfc
&:after
content ""
display block
clear both
- &:hover
- > .main > footer > button
- color #888
-
- > .avatar-anchor
+ > .avatar
display block
float left
margin 0 14px 0 0
-
- > .avatar
- display block
- width 52px
- height 52px
- margin 0
- border-radius 8px
- vertical-align bottom
+ width 52px
+ height 52px
+ border-radius 8px
> .main
float left
@@ -66,6 +65,7 @@ export default Vue.extend({
> header
display flex
+ align-items baseline
margin-bottom 2px
white-space nowrap
line-height 21px
@@ -75,7 +75,7 @@ export default Vue.extend({
margin 0 .5em 0 0
padding 0
overflow hidden
- color #607073
+ color isDark ? #fff : #607073
font-size 1em
font-weight bold
text-decoration none
@@ -86,23 +86,40 @@ export default Vue.extend({
> .username
margin 0 .5em 0 0
- color #d1d8da
+ color isDark ? #606984 : #d1d8da
- > .created-at
+ > .info
margin-left auto
- color #b2b8bb
+ font-size 0.9em
+
+ > *
+ color isDark ? #606984 : #b2b8bb
+
+ > .mobile
+ margin-right 6px
+
+ > .visibility
+ margin-left 6px
> .body
+ max-height 128px
+ overflow hidden
> .text
cursor default
margin 0
padding 0
font-size 1.1em
- color #717171
+ color isDark ? #959ba7 : #717171
pre
max-height 120px
font-size 80%
+.sub[data-darkmode]
+ root(true)
+
+.sub:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/components/notes.note.vue b/src/client/app/desktop/views/components/notes.note.vue
index 326ec4dc89..057c3c0956 100644
--- a/src/client/app/desktop/views/components/notes.note.vue
+++ b/src/client/app/desktop/views/components/notes.note.vue
@@ -1,24 +1,18 @@
<template>
<div class="note" tabindex="-1" :title="title" @keydown="onKeydown">
- <div class="reply-to" v-if="p.reply">
+ <div class="reply-to" v-if="p.reply && (!os.isSignedIn || clientSettings.showReplyTarget)">
<x-sub :note="p.reply"/>
</div>
<div class="renote" v-if="isRenote">
- <p>
- <router-link class="avatar-anchor" :to="note.user | userPage" v-user-preview="note.userId">
- <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=32`" alt="avatar"/>
- </router-link>
- %fa:retweet%
- <span>{{ '%i18n:!@reposted-by%'.substr(0, '%i18n:!@reposted-by%'.indexOf('{')) }}</span>
- <a class="name" :href="note.user | userPage" v-user-preview="note.userId">{{ note.user | userName }}</a>
- <span>{{ '%i18n:!@reposted-by%'.substr('%i18n:!@reposted-by%'.indexOf('}') + 1) }}</span>
- </p>
+ <mk-avatar class="avatar" :user="note.user"/>
+ %fa:retweet%
+ <span>{{ '%i18n:!@reposted-by%'.substr(0, '%i18n:!@reposted-by%'.indexOf('{')) }}</span>
+ <a class="name" :href="note.user | userPage" v-user-preview="note.userId">{{ note.user | userName }}</a>
+ <span>{{ '%i18n:!@reposted-by%'.substr('%i18n:!@reposted-by%'.indexOf('}') + 1) }}</span>
<mk-time :time="note.createdAt"/>
</div>
<article>
- <router-link class="avatar-anchor" :to="p.user | userPage">
- <img class="avatar" :src="`${p.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="p.user.id"/>
- </router-link>
+ <mk-avatar class="avatar" :user="p.user"/>
<div class="main">
<header>
<router-link class="name" :to="p.user | userPage" v-user-preview="p.user.id">{{ p.user | userName }}</router-link>
@@ -30,35 +24,50 @@
<router-link class="created-at" :to="p | notePage">
<mk-time :time="p.createdAt"/>
</router-link>
+ <span class="visibility" v-if="p.visibility != 'public'">
+ <template v-if="p.visibility == 'home'">%fa:home%</template>
+ <template v-if="p.visibility == 'followers'">%fa:unlock%</template>
+ <template v-if="p.visibility == 'specified'">%fa:envelope%</template>
+ <template v-if="p.visibility == 'private'">%fa:lock%</template>
+ </span>
</div>
</header>
<div class="body">
<p class="channel" v-if="p.channel">
<a :href="`${_CH_URL_}/${p.channel.id}`" target="_blank">{{ p.channel.title }}</a>:
</p>
- <div class="text">
- <a class="reply" v-if="p.reply">%fa:reply%</a>
- <mk-note-html v-if="p.textHtml" :text="p.text" :i="os.i" :class="$style.text"/>
- <a class="rp" v-if="p.renote">RP:</a>
- </div>
- <div class="media" v-if="p.media.length > 0">
- <mk-media-list :media-list="p.media"/>
- </div>
- <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
- <div class="tags" v-if="p.tags && p.tags.length > 0">
- <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link>
- </div>
- <a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a>
- <div class="map" v-if="p.geo" ref="map"></div>
- <div class="renote" v-if="p.renote">
- <mk-note-preview :note="p.renote"/>
+ <p v-if="p.cw != null" class="cw">
+ <span class="text" v-if="p.cw != ''">{{ p.cw }}</span>
+ <span class="toggle" @click="showContent = !showContent">{{ showContent ? '隠す' : 'もっと見る' }}</span>
+ </p>
+ <div class="content" v-show="p.cw == null || showContent">
+ <div class="text">
+ <span v-if="p.isHidden" style="opacity: 0.5">(この投稿は非公開です)</span>
+ <a class="reply" v-if="p.reply">%fa:reply%</a>
+ <mk-note-html v-if="p.text" :text="p.text" :i="os.i" :class="$style.text"/>
+ <a class="rp" v-if="p.renote">RP:</a>
+ </div>
+ <div class="media" v-if="p.media.length > 0">
+ <mk-media-list :media-list="p.media"/>
+ </div>
+ <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
+ <div class="tags" v-if="p.tags && p.tags.length > 0">
+ <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link>
+ </div>
+ <a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a>
+ <div class="map" v-if="p.geo" ref="map"></div>
+ <div class="renote" v-if="p.renote">
+ <mk-note-preview :note="p.renote"/>
+ </div>
+ <mk-url-preview v-for="url in urls" :url="url" :key="url"/>
</div>
- <mk-url-preview v-for="url in urls" :url="url" :key="url"/>
</div>
<footer>
<mk-reactions-viewer :note="p" ref="reactionsViewer"/>
<button @click="reply" title="%i18n:@reply%">
- %fa:reply%<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p>
+ <template v-if="p.reply">%fa:reply-all%</template>
+ <template v-else>%fa:reply%</template>
+ <p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p>
</button>
<button @click="renote" title="%i18n:@renote%">
%fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p>
@@ -113,6 +122,7 @@ export default Vue.extend({
data() {
return {
+ showContent: false,
isDetailOpened: false,
connection: null,
connectionId: null
@@ -168,7 +178,7 @@ export default Vue.extend({
// Draw map
if (this.p.geo) {
- const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.clientSettings.showMaps : true;
+ const shouldShowMap = (this as any).os.isSignedIn ? (this as any).clientSettings.showMaps : true;
if (shouldShowMap) {
(this as any).os.getGoogleMaps().then(maps => {
const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]);
@@ -289,20 +299,21 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.note
+root(isDark)
margin 0
padding 0
- background #fff
- border-bottom solid 1px #eaeaea
-
- &:first-child
- border-top-left-radius 6px
- border-top-right-radius 6px
+ background isDark ? #282C37 : #fff
+ border-bottom solid 1px isDark ? #1c2023 : #eaeaea
- > .renote
+ &[data-round]
+ &:first-child
border-top-left-radius 6px
border-top-right-radius 6px
+ > .renote
+ border-top-left-radius 6px
+ border-top-right-radius 6px
+
&:last-of-type
border-bottom none
@@ -321,47 +332,45 @@ export default Vue.extend({
border-radius 4px
> .renote
+ display flex
+ align-items center
+ padding 16px 32px
+ line-height 28px
color #9dbb00
- background linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+ background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%)
- > p
- margin 0
- padding 16px 32px
- line-height 28px
+ .avatar
+ display inline-block
+ width 28px
+ height 28px
+ margin 0 8px 0 0
+ border-radius 6px
- .avatar-anchor
- display inline-block
+ [data-fa]
+ margin-right 4px
- .avatar
- vertical-align bottom
- width 28px
- height 28px
- margin 0 8px 0 0
- border-radius 6px
+ > span
+ flex-shrink 0
- [data-fa]
- margin-right 4px
+ &:last-of-type
+ margin-right 8px
- .name
- font-weight bold
+ .name
+ overflow hidden
+ flex-shrink 1
+ text-overflow ellipsis
+ white-space nowrap
+ font-weight bold
> .mk-time
- position absolute
- top 16px
- right 32px
+ display block
+ margin-left auto
+ flex-shrink 0
font-size 0.9em
- line-height 28px
& + article
padding-top 8px
- > .reply-to
- padding 0 16px
- background rgba(0, 0, 0, 0.0125)
-
- > .mk-note-preview
- background transparent
-
> article
padding 28px 32px 18px 32px
@@ -372,31 +381,26 @@ export default Vue.extend({
&:hover
> .main > footer > button
- color #888
+ color isDark ? #707b97 : #888
- > .avatar-anchor
+ > .avatar
display block
float left
margin 0 16px 10px 0
+ width 58px
+ height 58px
+ border-radius 8px
//position -webkit-sticky
//position sticky
//top 74px
- > .avatar
- display block
- width 58px
- height 58px
- margin 0
- border-radius 8px
- vertical-align bottom
-
> .main
float left
width calc(100% - 74px)
> header
display flex
- align-items center
+ align-items baseline
margin-bottom 4px
white-space nowrap
@@ -405,7 +409,7 @@ export default Vue.extend({
margin 0 .5em 0 0
padding 0
overflow hidden
- color #627079
+ color isDark ? #fff : #627079
font-size 1em
font-weight bold
text-decoration none
@@ -418,114 +422,156 @@ export default Vue.extend({
margin 0 .5em 0 0
padding 1px 6px
font-size 12px
- color #aaa
- border solid 1px #ddd
+ color isDark ? #758188 : #aaa
+ border solid 1px isDark ? #57616f : #ddd
border-radius 3px
> .username
margin 0 .5em 0 0
- color #ccc
+ overflow hidden
+ text-overflow ellipsis
+ color isDark ? #606984 : #ccc
> .info
margin-left auto
font-size 0.9em
+ > *
+ color isDark ? #606984 : #c0c0c0
+
> .mobile
margin-right 8px
- color #ccc
> .app
margin-right 8px
padding-right 8px
- color #ccc
border-right solid 1px #eaeaea
- > .created-at
- color #c0c0c0
+ > .visibility
+ margin-left 8px
> .body
- > .text
+ > .cw
cursor default
display block
margin 0
padding 0
overflow-wrap break-word
font-size 1.1em
- color #717171
-
- >>> .quote
- margin 8px
- padding 6px 12px
- color #aaa
- border-left solid 3px #eee
+ color isDark ? #fff : #717171
- > .reply
+ > .text
margin-right 8px
- color #717171
- > .rp
- margin-left 4px
- font-style oblique
- color #a0bf46
+ > .toggle
+ display inline-block
+ padding 4px 8px
+ font-size 0.7em
+ color isDark ? #393f4f : #fff
+ background isDark ? #687390 : #b1b9c1
+ border-radius 2px
+ cursor pointer
+ user-select none
- > .location
- margin 4px 0
- font-size 12px
- color #ccc
+ &:hover
+ background isDark ? #707b97 : #bbc4ce
- > .map
- width 100%
- height 300px
+ > .content
- &:empty
- display none
+ > .text
+ cursor default
+ display block
+ margin 0
+ padding 0
+ overflow-wrap break-word
+ font-size 1.1em
+ color isDark ? #fff : #717171
- > .tags
- margin 4px 0 0 0
+ >>> .title
+ display block
+ margin-bottom 4px
+ padding 4px
+ font-size 90%
+ text-align center
+ background isDark ? #2f3944 : #eef1f3
+ border-radius 4px
- > *
- display inline-block
- margin 0 8px 0 0
- padding 2px 8px 2px 16px
- font-size 90%
- color #8d969e
- background #edf0f3
- border-radius 4px
+ >>> .code
+ margin 8px 0
- &:before
- content ""
- display block
- position absolute
- top 0
- bottom 0
- left 4px
- width 8px
- height 8px
- margin auto 0
- background #fff
- border-radius 100%
+ >>> .quote
+ margin 8px
+ padding 6px 12px
+ color isDark ? #6f808e : #aaa
+ border-left solid 3px isDark ? #637182 : #eee
- &:hover
- text-decoration none
- background #e2e7ec
+ > .reply
+ margin-right 8px
+ color isDark ? #99abbf : #717171
- .mk-url-preview
- margin-top 8px
+ > .rp
+ margin-left 4px
+ font-style oblique
+ color #a0bf46
- > .channel
- margin 0
+ > .location
+ margin 4px 0
+ font-size 12px
+ color #ccc
+
+ > .map
+ width 100%
+ height 300px
- > .mk-poll
- font-size 80%
+ &:empty
+ display none
- > .renote
- margin 8px 0
+ > .tags
+ margin 4px 0 0 0
- > .mk-note-preview
- padding 16px
- border dashed 1px #c0dac6
- border-radius 8px
+ > *
+ display inline-block
+ margin 0 8px 0 0
+ padding 2px 8px 2px 16px
+ font-size 90%
+ color #8d969e
+ background #edf0f3
+ border-radius 4px
+
+ &:before
+ content ""
+ display block
+ position absolute
+ top 0
+ bottom 0
+ left 4px
+ width 8px
+ height 8px
+ margin auto 0
+ background #fff
+ border-radius 100%
+
+ &:hover
+ text-decoration none
+ background #e2e7ec
+
+ .mk-url-preview
+ margin-top 8px
+
+ > .channel
+ margin 0
+
+ > .mk-poll
+ font-size 80%
+
+ > .renote
+ margin 8px 0
+
+ > .mk-note-preview
+ padding 16px
+ border dashed 1px isDark ? #4e945e : #c0dac6
+ border-radius 8px
> footer
> button
@@ -533,13 +579,13 @@ export default Vue.extend({
padding 0 8px
line-height 32px
font-size 1em
- color #ddd
+ color isDark ? #606984 : #ddd
background transparent
border none
cursor pointer
&:hover
- color #666
+ color isDark ? #9198af : #666
> .count
display inline
@@ -556,7 +602,13 @@ export default Vue.extend({
> .detail
padding-top 4px
- background rgba(0, 0, 0, 0.0125)
+ background rgba(#000, 0.0125)
+
+.note[data-darkmode]
+ root(true)
+
+.note:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue
index b5f6957a16..7e80e6f74a 100644
--- a/src/client/app/desktop/views/components/notes.vue
+++ b/src/client/app/desktop/views/components/notes.vue
@@ -1,32 +1,65 @@
<template>
<div class="mk-notes">
- <template v-for="(note, i) in _notes">
- <x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/>
- <p class="date" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date">
- <span>%fa:angle-up%{{ note._datetext }}</span>
- <span>%fa:angle-down%{{ _notes[i + 1]._datetext }}</span>
- </p>
- </template>
- <footer>
- <slot name="footer"></slot>
+ <div class="newer-indicator" :style="{ top: $store.state.uiHeaderHeight + 'px' }" v-show="queue.length > 0"></div>
+
+ <slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot>
+
+ <div v-if="!fetching && requestInitPromise != null">
+ <p>読み込みに失敗しました。</p>
+ <button @click="resolveInitPromise">リトライ</button>
+ </div>
+
+ <transition-group name="mk-notes" class="transition">
+ <template v-for="(note, i) in _notes">
+ <x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/>
+ <p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date">
+ <span>%fa:angle-up%{{ note._datetext }}</span>
+ <span>%fa:angle-down%{{ _notes[i + 1]._datetext }}</span>
+ </p>
+ </template>
+ </transition-group>
+
+ <footer v-if="more">
+ <button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+ <template v-if="!moreFetching">%i18n:@load-more%</template>
+ <template v-if="moreFetching">%fa:spinner .pulse .fw%</template>
+ </button>
</footer>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
+import { url } from '../../../config';
+import getNoteSummary from '../../../../../renderers/get-note-summary';
+
import XNote from './notes.note.vue';
+const displayLimit = 30;
+
export default Vue.extend({
components: {
XNote
},
+
props: {
- notes: {
- type: Array,
- default: () => []
+ more: {
+ type: Function,
+ required: false
}
},
+
+ data() {
+ return {
+ requestInitPromise: null as () => Promise<any[]>,
+ notes: [],
+ queue: [],
+ unreadCount: 0,
+ fetching: true,
+ moreFetching: false
+ };
+ },
+
computed: {
_notes(): any[] {
return (this.notes as any).map(note => {
@@ -38,52 +71,202 @@ export default Vue.extend({
});
}
},
+
+ mounted() {
+ document.addEventListener('visibilitychange', this.onVisibilitychange, false);
+ window.addEventListener('scroll', this.onScroll);
+ },
+
+ beforeDestroy() {
+ document.removeEventListener('visibilitychange', this.onVisibilitychange);
+ window.removeEventListener('scroll', this.onScroll);
+ },
+
methods: {
+ isScrollTop() {
+ return window.scrollY <= 8;
+ },
+
focus() {
(this.$el as any).children[0].focus();
},
+
onNoteUpdated(i, note) {
Vue.set((this as any).notes, i, note);
+ },
+
+ init(promiseGenerator: () => Promise<any[]>) {
+ this.requestInitPromise = promiseGenerator;
+ this.resolveInitPromise();
+ },
+
+ resolveInitPromise() {
+ this.queue = [];
+ this.notes = [];
+ this.fetching = true;
+
+ const promise = this.requestInitPromise();
+
+ promise.then(notes => {
+ this.notes = notes;
+ this.requestInitPromise = null;
+ this.fetching = false;
+ }, e => {
+ this.fetching = false;
+ });
+ },
+
+ prepend(note, silent = false) {
+ //#region 弾く
+ const isMyNote = note.userId == (this as any).os.i.id;
+ const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null;
+
+ if ((this as any).clientSettings.showMyRenotes === false) {
+ if (isMyNote && isPureRenote) {
+ return;
+ }
+ }
+
+ if ((this as any).clientSettings.showRenotedMyNotes === false) {
+ if (isPureRenote && (note.renote.userId == (this as any).os.i.id)) {
+ return;
+ }
+ }
+ //#endregion
+
+ // 投稿が自分のものではないかつ、タブが非表示またはスクロール位置が最上部ではないならタイトルで通知
+ if ((document.hidden || !this.isScrollTop()) && note.userId !== (this as any).os.i.id) {
+ this.unreadCount++;
+ document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`;
+ }
+
+ if (this.isScrollTop()) {
+ // Prepend the note
+ this.notes.unshift(note);
+
+ // サウンドを再生する
+ if ((this as any).os.isEnableSounds && !silent) {
+ const sound = new Audio(`${url}/assets/post.mp3`);
+ sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 0.5;
+ sound.play();
+ }
+
+ // オーバーフローしたら古い投稿は捨てる
+ if (this.notes.length >= displayLimit) {
+ this.notes = this.notes.slice(0, displayLimit);
+ }
+ } else {
+ this.queue.push(note);
+ }
+ },
+
+ append(note) {
+ this.notes.push(note);
+ },
+
+ tail() {
+ return this.notes[this.notes.length - 1];
+ },
+
+ releaseQueue() {
+ this.queue.forEach(n => this.prepend(n, true));
+ this.queue = [];
+ },
+
+ async loadMore() {
+ if (this.more == null) return;
+ if (this.moreFetching) return;
+
+ this.moreFetching = true;
+ await this.more();
+ this.moreFetching = false;
+ },
+
+ clearNotification() {
+ this.unreadCount = 0;
+ document.title = 'Misskey';
+ },
+
+ onVisibilitychange() {
+ if (!document.hidden) {
+ this.clearNotification();
+ }
+ },
+
+ onScroll() {
+ if (this.isScrollTop()) {
+ this.releaseQueue();
+ this.clearNotification();
+ }
+
+ if ((this as any).clientSettings.fetchOnScroll !== false) {
+ const current = window.scrollY + window.innerHeight;
+ if (current > document.body.offsetHeight - 8) this.loadMore();
+ }
}
}
});
</script>
<style lang="stylus" scoped>
-.mk-notes
+@import '~const.styl'
+
+root(isDark)
+ .transition
+ .mk-notes-enter
+ .mk-notes-leave-to
+ opacity 0
+ transform translateY(-30px)
+
+ > *
+ transition transform .3s ease, opacity .3s ease
- > .date
- display block
- margin 0
- line-height 32px
- font-size 14px
- text-align center
- color #aaa
- background #fdfdfd
- border-bottom solid 1px #eaeaea
+ > .date
+ display block
+ margin 0
+ line-height 32px
+ font-size 14px
+ text-align center
+ color isDark ? #666b79 : #aaa
+ background isDark ? #242731 : #fdfdfd
+ border-bottom solid 1px isDark ? #1c2023 : #eaeaea
- span
- margin 0 16px
+ span
+ margin 0 16px
- [data-fa]
- margin-right 8px
+ [data-fa]
+ margin-right 8px
+
+ > .newer-indicator
+ position -webkit-sticky
+ position sticky
+ z-index 100
+ height 3px
+ background $theme-color
> footer
- > *
+ > button
display block
margin 0
padding 16px
width 100%
text-align center
color #ccc
- border-top solid 1px #eaeaea
- border-bottom-left-radius 4px
- border-bottom-right-radius 4px
+ background isDark ? #282C37 : #fff
+ border-top solid 1px isDark ? #1c2023 : #eaeaea
+ border-bottom-left-radius 6px
+ border-bottom-right-radius 6px
- > button
&:hover
- background #f5f5f5
+ background isDark ? #2e3440 : #f5f5f5
&:active
- background #eee
+ background isDark ? #21242b : #eee
+
+.mk-notes[data-darkmode]
+ root(true)
+
+.mk-notes:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/components/notifications.vue b/src/client/app/desktop/views/components/notifications.vue
index 413a87755a..7923d1a62d 100644
--- a/src/client/app/desktop/views/components/notifications.vue
+++ b/src/client/app/desktop/views/components/notifications.vue
@@ -1,96 +1,84 @@
<template>
<div class="mk-notifications">
<div class="notifications" v-if="notifications.length != 0">
- <template v-for="(notification, i) in _notifications">
- <div class="notification" :class="notification.type" :key="notification.id">
- <mk-time :time="notification.createdAt"/>
- <template v-if="notification.type == 'reaction'">
- <router-link class="avatar-anchor" :to="notification.user | userPage" v-user-preview="notification.user.id">
- <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/>
- </router-link>
- <div class="text">
- <p>
- <mk-reaction-icon :reaction="notification.reaction"/>
- <router-link :to="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</router-link>
- </p>
- <router-link class="note-ref" :to="notification.note | notePage">
- %fa:quote-left%{{ getNoteSummary(notification.note) }}%fa:quote-right%
- </router-link>
- </div>
- </template>
- <template v-if="notification.type == 'renote'">
- <router-link class="avatar-anchor" :to="notification.note.user | userPage" v-user-preview="notification.note.userId">
- <img class="avatar" :src="`${notification.note.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/>
- </router-link>
- <div class="text">
- <p>%fa:retweet%
- <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link>
- </p>
- <router-link class="note-ref" :to="notification.note | notePage">
- %fa:quote-left%{{ getNoteSummary(notification.note.renote) }}%fa:quote-right%
- </router-link>
- </div>
- </template>
- <template v-if="notification.type == 'quote'">
- <router-link class="avatar-anchor" :to="notification.note.user | userPage" v-user-preview="notification.note.userId">
- <img class="avatar" :src="`${notification.note.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/>
- </router-link>
- <div class="text">
- <p>%fa:quote-left%
- <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link>
- </p>
- <router-link class="note-preview" :to="notification.note | notePage">{{ getNoteSummary(notification.note) }}</router-link>
- </div>
- </template>
- <template v-if="notification.type == 'follow'">
- <router-link class="avatar-anchor" :to="notification.user | userPage" v-user-preview="notification.user.id">
- <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/>
- </router-link>
- <div class="text">
- <p>%fa:user-plus%
- <router-link :to="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</router-link>
- </p>
- </div>
- </template>
- <template v-if="notification.type == 'reply'">
- <router-link class="avatar-anchor" :to="notification.note.user | userPage" v-user-preview="notification.note.userId">
- <img class="avatar" :src="`${notification.note.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/>
- </router-link>
- <div class="text">
- <p>%fa:reply%
- <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link>
- </p>
- <router-link class="note-preview" :to="notification.note | notePage">{{ getNoteSummary(notification.note) }}</router-link>
- </div>
- </template>
- <template v-if="notification.type == 'mention'">
- <router-link class="avatar-anchor" :to="notification.note.user | userPage" v-user-preview="notification.note.userId">
- <img class="avatar" :src="`${notification.note.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/>
- </router-link>
- <div class="text">
- <p>%fa:at%
- <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link>
- </p>
- <a class="note-preview" :href="notification.note | notePage">{{ getNoteSummary(notification.note) }}</a>
- </div>
- </template>
- <template v-if="notification.type == 'poll_vote'">
- <router-link class="avatar-anchor" :to="notification.user | userPage" v-user-preview="notification.user.id">
- <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/>
- </router-link>
- <div class="text">
- <p>%fa:chart-pie%<a :href="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</a></p>
- <router-link class="note-ref" :to="notification.note | notePage">
- %fa:quote-left%{{ getNoteSummary(notification.note) }}%fa:quote-right%
- </router-link>
- </div>
- </template>
- </div>
- <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'">
- <span>%fa:angle-up%{{ notification._datetext }}</span>
- <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span>
- </p>
- </template>
+ <transition-group name="mk-notifications" class="transition">
+ <template v-for="(notification, i) in _notifications">
+ <div class="notification" :class="notification.type" :key="notification.id">
+ <mk-time :time="notification.createdAt"/>
+ <template v-if="notification.type == 'reaction'">
+ <mk-avatar class="avatar" :user="notification.user"/>
+ <div class="text">
+ <p>
+ <mk-reaction-icon :reaction="notification.reaction"/>
+ <router-link :to="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</router-link>
+ </p>
+ <router-link class="note-ref" :to="notification.note | notePage">
+ %fa:quote-left%{{ getNoteSummary(notification.note) }}%fa:quote-right%
+ </router-link>
+ </div>
+ </template>
+ <template v-if="notification.type == 'renote'">
+ <mk-avatar class="avatar" :user="notification.note.user"/>
+ <div class="text">
+ <p>%fa:retweet%
+ <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link>
+ </p>
+ <router-link class="note-ref" :to="notification.note | notePage">
+ %fa:quote-left%{{ getNoteSummary(notification.note.renote) }}%fa:quote-right%
+ </router-link>
+ </div>
+ </template>
+ <template v-if="notification.type == 'quote'">
+ <mk-avatar class="avatar" :user="notification.note.user"/>
+ <div class="text">
+ <p>%fa:quote-left%
+ <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link>
+ </p>
+ <router-link class="note-preview" :to="notification.note | notePage">{{ getNoteSummary(notification.note) }}</router-link>
+ </div>
+ </template>
+ <template v-if="notification.type == 'follow'">
+ <mk-avatar class="avatar" :user="notification.user"/>
+ <div class="text">
+ <p>%fa:user-plus%
+ <router-link :to="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</router-link>
+ </p>
+ </div>
+ </template>
+ <template v-if="notification.type == 'reply'">
+ <mk-avatar class="avatar" :user="notification.note.user"/>
+ <div class="text">
+ <p>%fa:reply%
+ <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link>
+ </p>
+ <router-link class="note-preview" :to="notification.note | notePage">{{ getNoteSummary(notification.note) }}</router-link>
+ </div>
+ </template>
+ <template v-if="notification.type == 'mention'">
+ <mk-avatar class="avatar" :user="notification.note.user"/>
+ <div class="text">
+ <p>%fa:at%
+ <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link>
+ </p>
+ <a class="note-preview" :href="notification.note | notePage">{{ getNoteSummary(notification.note) }}</a>
+ </div>
+ </template>
+ <template v-if="notification.type == 'poll_vote'">
+ <mk-avatar class="avatar" :user="notification.user"/>
+ <div class="text">
+ <p>%fa:chart-pie%<a :href="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</a></p>
+ <router-link class="note-ref" :to="notification.note | notePage">
+ %fa:quote-left%{{ getNoteSummary(notification.note) }}%fa:quote-right%
+ </router-link>
+ </div>
+ </template>
+ </div>
+ <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'">
+ <span>%fa:angle-up%{{ notification._datetext }}</span>
+ <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span>
+ </p>
+ </template>
+ </transition-group>
</div>
<button class="more" :class="{ fetching: fetchingMoreNotifications }" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications">
<template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>{{ fetchingMoreNotifications ? '%i18n:!common.loading%' : '%i18n:!@more%' }}
@@ -185,111 +173,116 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.mk-notifications
- > .notifications
- > .notification
- margin 0
- padding 16px
- overflow-wrap break-word
- font-size 0.9em
- border-bottom solid 1px rgba(0, 0, 0, 0.05)
+root(isDark)
+ .transition
+ .mk-notifications-enter
+ .mk-notifications-leave-to
+ opacity 0
+ transform translateY(-30px)
- &:last-child
- border-bottom none
+ > *
+ transition transform .3s ease, opacity .3s ease
- > .mk-time
- display inline
- position absolute
- top 16px
- right 12px
- vertical-align top
- color rgba(0, 0, 0, 0.6)
- font-size small
+ > .notifications
+ > *
+ > .notification
+ margin 0
+ padding 16px
+ overflow-wrap break-word
+ font-size 0.9em
+ border-bottom solid 1px isDark ? #1c2023 : rgba(#000, 0.05)
- &:after
- content ""
- display block
- clear both
+ &:last-child
+ border-bottom none
- > .avatar-anchor
- display block
- float left
- position -webkit-sticky
- position sticky
- top 16px
+ > .mk-time
+ display inline
+ position absolute
+ top 16px
+ right 12px
+ vertical-align top
+ color isDark ? #606984 : rgba(#000, 0.6)
+ font-size small
+
+ &:after
+ content ""
+ display block
+ clear both
- > img
+ > .avatar
display block
- min-width 36px
- min-height 36px
- max-width 36px
- max-height 36px
+ float left
+ position -webkit-sticky
+ position sticky
+ top 16px
+ width 36px
+ height 36px
border-radius 6px
- > .text
- float right
- width calc(100% - 36px)
- padding-left 8px
+ > .text
+ float right
+ width calc(100% - 36px)
+ padding-left 8px
- p
- margin 0
+ p
+ margin 0
- i, .mk-reaction-icon
- margin-right 4px
+ i, .mk-reaction-icon
+ margin-right 4px
- .note-preview
- color rgba(0, 0, 0, 0.7)
+ .note-preview
+ color isDark ? #c2cad4 : rgba(#000, 0.7)
- .note-ref
- color rgba(0, 0, 0, 0.7)
+ .note-ref
+ color isDark ? #c2cad4 : rgba(#000, 0.7)
- [data-fa]
- font-size 1em
- font-weight normal
- font-style normal
- display inline-block
- margin-right 3px
+ [data-fa]
+ font-size 1em
+ font-weight normal
+ font-style normal
+ display inline-block
+ margin-right 3px
- &.renote, &.quote
- .text p i
- color #77B255
+ &.renote, &.quote
+ .text p i
+ color #77B255
- &.follow
- .text p i
- color #53c7ce
+ &.follow
+ .text p i
+ color #53c7ce
- &.reply, &.mention
- .text p i
- color #555
+ &.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)
+ > .date
+ display block
+ margin 0
+ line-height 32px
+ text-align center
+ font-size 0.8em
+ color isDark ? #666b79 : #aaa
+ background isDark ? #242731 : #fdfdfd
+ border-bottom solid 1px isDark ? #1c2023 : rgba(#000, 0.05)
- span
- margin 0 16px
+ span
+ margin 0 16px
- [data-fa]
- margin-right 8px
+ [data-fa]
+ margin-right 8px
> .more
display block
width 100%
padding 16px
color #555
- border-top solid 1px rgba(0, 0, 0, 0.05)
+ border-top solid 1px rgba(#000, 0.05)
&:hover
- background rgba(0, 0, 0, 0.025)
+ background rgba(#000, 0.025)
&:active
- background rgba(0, 0, 0, 0.05)
+ background rgba(#000, 0.05)
&.fetching
cursor wait
@@ -312,4 +305,10 @@ export default Vue.extend({
> [data-fa]
margin-right 4px
+.mk-notifications[data-darkmode]
+ root(true)
+
+.mk-notifications:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/components/post-form.vue b/src/client/app/desktop/views/components/post-form.vue
index ebb0193088..984fc9866c 100644
--- a/src/client/app/desktop/views/components/post-form.vue
+++ b/src/client/app/desktop/views/components/post-form.vue
@@ -6,6 +6,11 @@
@drop.stop="onDrop"
>
<div class="content">
+ <div v-if="visibility == 'specified'" class="visibleUsers">
+ <span v-for="u in visibleUsers">{{ u | userName }}<a @click="removeVisibleUser(u)">[x]</a></span>
+ <a @click="addVisibleUser">+ユーザーを追加</a>
+ </div>
+ <input v-show="useCw" v-model="cw" placeholder="内容への注釈 (オプション)">
<textarea :class="{ with: (files.length != 0 || poll) }"
ref="text" v-model="text" :disabled="posting"
@keydown="onKeydown" @paste="onPaste" :placeholder="placeholder"
@@ -27,8 +32,10 @@
<button class="drive" title="%i18n:@attach-media-from-drive%" @click="chooseFileFromDrive">%fa:cloud%</button>
<button class="kao" title="%i18n:@insert-a-kao%" @click="kao">%fa:R smile%</button>
<button class="poll" title="%i18n:@create-poll%" @click="poll = true">%fa:chart-pie%</button>
+ <button class="poll" title="内容を隠す" @click="useCw = !useCw">%fa:eye-slash%</button>
<button class="geo" title="位置情報を添付する" @click="geo ? removeGeo() : setGeo()">%fa:map-marker-alt%</button>
- <p class="text-count" :class="{ over: text.length > 1000 }">{{ '%i18n:!@text-remain%'.replace('{}', 1000 - text.length) }}</p>
+ <button class="visibility" title="公開範囲" @click="setVisibility" ref="visibilityButton">%fa:lock%</button>
+ <p class="text-count" :class="{ over: text.length > 1000 }">{{ 1000 - text.length }}</p>
<button :class="{ posting }" class="submit" :disabled="!canPost" @click="post">
{{ posting ? '%i18n:!@posting%' : submitText }}<mk-ellipsis v-if="posting"/>
</button>
@@ -41,12 +48,16 @@
import Vue from 'vue';
import * as XDraggable from 'vuedraggable';
import getKao from '../../../common/scripts/get-kao';
+import MkVisibilityChooser from '../../../common/views/components/visibility-chooser.vue';
export default Vue.extend({
components: {
- XDraggable
+ XDraggable,
+ MkVisibilityChooser
},
+
props: ['reply', 'renote'],
+
data() {
return {
posting: false,
@@ -54,11 +65,16 @@ export default Vue.extend({
files: [],
uploadings: [],
poll: false,
+ useCw: false,
+ cw: null,
geo: null,
+ visibility: 'public',
+ visibleUsers: [],
autocomplete: null,
draghover: false
};
},
+
computed: {
draftId(): string {
return this.renote
@@ -67,6 +83,7 @@ export default Vue.extend({
? 'reply:' + this.reply.id
: 'note';
},
+
placeholder(): string {
return this.renote
? '%i18n:!@quote-placeholder%'
@@ -74,6 +91,7 @@ export default Vue.extend({
? '%i18n:!@reply-placeholder%'
: '%i18n:!@note-placeholder%';
},
+
submitText(): string {
return this.renote
? '%i18n:!@renote%'
@@ -81,22 +99,17 @@ export default Vue.extend({
? '%i18n:!@reply%'
: '%i18n:!@note%';
},
+
canPost(): boolean {
return !this.posting && (this.text.length != 0 || this.files.length != 0 || this.poll || this.renote);
}
},
- watch: {
- text() {
- this.saveDraft();
- },
- poll() {
- this.saveDraft();
- },
- files() {
- this.saveDraft();
- }
- },
+
mounted() {
+ if (this.reply && this.reply.user.host != null) {
+ this.text = `@${this.reply.user.username}@${this.reply.user.host} `;
+ }
+
this.$nextTick(() => {
// 書きかけの投稿を復元
const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId];
@@ -111,15 +124,26 @@ export default Vue.extend({
}
this.$emit('change-attached-media', this.files);
}
+
+ this.$nextTick(() => this.watch());
});
},
+
methods: {
+ watch() {
+ this.$watch('text', () => this.saveDraft());
+ this.$watch('poll', () => this.saveDraft());
+ this.$watch('files', () => this.saveDraft());
+ },
+
focus() {
(this.$refs.text as any).focus();
},
+
chooseFile() {
(this.$refs.file as any).click();
},
+
chooseFileFromDrive() {
(this as any).apis.chooseDriveFile({
multiple: true
@@ -127,32 +151,40 @@ export default Vue.extend({
files.forEach(this.attachMedia);
});
},
+
attachMedia(driveFile) {
this.files.push(driveFile);
this.$emit('change-attached-media', this.files);
},
+
detachMedia(id) {
this.files = this.files.filter(x => x.id != id);
this.$emit('change-attached-media', this.files);
},
+
onChangeFile() {
Array.from((this.$refs.file as any).files).forEach(this.upload);
},
+
upload(file) {
(this.$refs.uploader as any).upload(file);
},
+
onChangeUploadings(uploads) {
this.$emit('change-uploadings', uploads);
},
+
clear() {
this.text = '';
this.files = [];
this.poll = false;
this.$emit('change-attached-media', this.files);
},
+
onKeydown(e) {
if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post();
},
+
onPaste(e) {
Array.from(e.clipboardData.items).forEach((item: any) => {
if (item.kind == 'file') {
@@ -160,6 +192,7 @@ export default Vue.extend({
}
});
},
+
onDragover(e) {
const isFile = e.dataTransfer.items[0].kind == 'file';
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
@@ -169,12 +202,15 @@ export default Vue.extend({
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
}
},
+
onDragenter(e) {
this.draghover = true;
},
+
onDragleave(e) {
this.draghover = false;
},
+
onDrop(e): void {
this.draghover = false;
@@ -195,6 +231,7 @@ export default Vue.extend({
}
//#endregion
},
+
setGeo() {
if (navigator.geolocation == null) {
alert('お使いの端末は位置情報に対応していません');
@@ -210,10 +247,38 @@ export default Vue.extend({
enableHighAccuracy: true
});
},
+
removeGeo() {
this.geo = null;
this.$emit('geo-dettached');
},
+
+ setVisibility() {
+ const w = (this as any).os.new(MkVisibilityChooser, {
+ source: this.$refs.visibilityButton,
+ v: this.visibility
+ });
+ w.$once('chosen', v => {
+ this.visibility = v;
+ });
+ },
+
+ addVisibleUser() {
+ (this as any).apis.input({
+ title: 'ユーザー名を入力してください'
+ }).then(username => {
+ (this as any).api('users/show', {
+ username
+ }).then(user => {
+ this.visibleUsers.push(user);
+ });
+ });
+ },
+
+ removeVisibleUser(user) {
+ this.visibleUsers = this.visibleUsers.filter(u => u != user);
+ },
+
post() {
this.posting = true;
@@ -223,6 +288,9 @@ export default Vue.extend({
replyId: this.reply ? this.reply.id : undefined,
renoteId: this.renote ? this.renote.id : undefined,
poll: this.poll ? (this.$refs.poll as any).get() : undefined,
+ cw: this.useCw ? this.cw || '' : undefined,
+ visibility: this.visibility,
+ visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined,
geo: this.geo ? {
coordinates: [this.geo.longitude, this.geo.latitude],
altitude: this.geo.altitude,
@@ -250,6 +318,7 @@ export default Vue.extend({
this.posting = false;
});
},
+
saveDraft() {
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
@@ -264,6 +333,7 @@ export default Vue.extend({
localStorage.setItem('drafts', JSON.stringify(data));
},
+
deleteDraft() {
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
@@ -271,6 +341,7 @@ export default Vue.extend({
localStorage.setItem('drafts', JSON.stringify(data));
},
+
kao() {
this.text += getKao();
}
@@ -281,10 +352,10 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-post-form
+root(isDark)
display block
padding 16px
- background lighten($theme-color, 95%)
+ background isDark ? #282C37 : lighten($theme-color, 95%)
&:after
content ""
@@ -292,56 +363,70 @@ export default Vue.extend({
clear both
> .content
-
- textarea
+ > input
+ > textarea
display block
- padding 12px
- margin 0
width 100%
- max-width 100%
- min-width 100%
- min-height calc(16px + 12px + 12px)
+ padding 12px
font-size 16px
- color #333
- background #fff
+ color isDark ? #fff : #333
+ background isDark ? #191d23 : #fff
outline none
border solid 1px rgba($theme-color, 0.1)
border-radius 4px
- transition border-color .3s ease
+ transition border-color .2s ease
&:hover
border-color rgba($theme-color, 0.2)
transition border-color .1s ease
+ &:focus
+ 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)
+
+ > input
+ margin-bottom 8px
+
+ > textarea
+ margin 0
+ max-width 100%
+ min-width 100%
+ min-height 64px
+
+ &: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
-
& + *
& + * + *
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)
-
&.with
border-bottom solid 1px rgba($theme-color, 0.1) !important
border-radius 4px 4px 0 0
+ > .visibleUsers
+ margin-bottom 8px
+ font-size 14px
+
+ > span
+ margin-right 16px
+ color isDark ? #fff : #666
+
> .medias
margin 0
padding 0
- background lighten($theme-color, 98%)
+ background isDark ? #181b23 : lighten($theme-color, 98%)
border solid 1px rgba($theme-color, 0.1)
border-top none
border-radius 0 0 4px 4px
@@ -392,7 +477,7 @@ export default Vue.extend({
cursor pointer
> .mk-poll-editor
- background lighten($theme-color, 98%)
+ background isDark ? #181b23 : lighten($theme-color, 98%)
border solid 1px rgba($theme-color, 0.1)
border-top none
border-radius 0 0 4px 4px
@@ -407,19 +492,6 @@ export default Vue.extend({
input[type='file']
display none
- .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
-
.submit
display block
position absolute
@@ -484,11 +556,25 @@ export default Vue.extend({
from {background-position: 0 0;}
to {background-position: -64px 32px;}
+ > .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
+
> .upload
> .drive
> .kao
> .poll
> .geo
+ > .visibility
display inline-block
cursor pointer
padding 0
@@ -496,7 +582,7 @@ export default Vue.extend({
width 40px
height 40px
font-size 1em
- color rgba($theme-color, 0.5)
+ color isDark ? $theme-color : rgba($theme-color, 0.5)
background transparent
outline none
border solid 1px transparent
@@ -504,13 +590,13 @@ export default Vue.extend({
&:hover
background transparent
- border-color rgba($theme-color, 0.3)
+ border-color isDark ? rgba($theme-color, 0.5) : 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%)
+ background isDark ? transparent : 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
+ box-shadow 0 2px 4px rgba(#000, 0.15) inset
&:focus
&:after
@@ -533,4 +619,10 @@ export default Vue.extend({
border dashed 2px rgba($theme-color, 0.5)
pointer-events none
+.mk-post-form[data-darkmode]
+ root(true)
+
+.mk-post-form:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/components/renote-form.vue b/src/client/app/desktop/views/components/renote-form.vue
index daae5df5e9..9c0154211b 100644
--- a/src/client/app/desktop/views/components/renote-form.vue
+++ b/src/client/app/desktop/views/components/renote-form.vue
@@ -4,8 +4,8 @@
<template v-if="!quote">
<footer>
<a class="quote" v-if="!quote" @click="onQuote">%i18n:@quote%</a>
- <button class="cancel" @click="cancel">%i18n:@cancel%</button>
- <button class="ok" @click="ok" :disabled="wait">{{ wait ? '%i18n:!@reposting%' : '%i18n:!@renote%' }}</button>
+ <button class="ui cancel" @click="cancel">%i18n:@cancel%</button>
+ <button class="ui primary ok" @click="ok" :disabled="wait">{{ wait ? '%i18n:!@reposting%' : '%i18n:!@renote%' }}</button>
</footer>
</template>
<template v-if="quote">
@@ -59,14 +59,14 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-renote-form
+root(isDark)
> .mk-note-preview
margin 16px 22px
> footer
height 72px
- background lighten($theme-color, 95%)
+ background isDark ? #313543 : lighten($theme-color, 95%)
> .quote
position absolute
@@ -78,54 +78,19 @@ export default Vue.extend({
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
- > .cancel
- right 148px
- color #888
- background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%)
- border solid 1px #e2e2e2
+ &.ok
+ right 16px
- &:hover
- background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%)
- border-color #dcdcdc
+.mk-renote-form[data-darkmode]
+ root(true)
- &: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
+.mk-renote-form:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/desktop/views/components/repost-form.vue b/src/client/app/desktop/views/components/repost-form.vue
deleted file mode 100644
index d5b1696757..0000000000
--- a/src/client/app/desktop/views/components/repost-form.vue
+++ /dev/null
@@ -1,131 +0,0 @@
-<template>
-<div class="mk-renote-form">
- <mk-note-preview :note="note"/>
- <template v-if="!quote">
- <footer>
- <a class="quote" v-if="!quote" @click="onQuote">%i18n:desktop.tags.mk-renote-form.quote%</a>
- <button class="cancel" @click="cancel">%i18n:desktop.tags.mk-renote-form.cancel%</button>
- <button class="ok" @click="ok" :disabled="wait">{{ wait ? '%i18n:!desktop.tags.mk-renote-form.reposting%' : '%i18n:!desktop.tags.mk-renote-form.renote%' }}</button>
- </footer>
- </template>
- <template v-if="quote">
- <mk-post-form ref="form" :renote="note" @posted="onChildFormPosted"/>
- </template>
-</div>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-
-export default Vue.extend({
- props: ['note'],
- data() {
- return {
- wait: false,
- quote: false
- };
- },
- methods: {
- ok() {
- this.wait = true;
- (this as any).api('notes/create', {
- renoteId: this.note.id
- }).then(data => {
- this.$emit('posted');
- (this as any).apis.notify('%i18n:!desktop.tags.mk-renote-form.success%');
- }).catch(err => {
- (this as any).apis.notify('%i18n:!desktop.tags.mk-renote-form.failure%');
- }).then(() => {
- this.wait = false;
- });
- },
- cancel() {
- this.$emit('canceled');
- },
- onQuote() {
- this.quote = true;
-
- this.$nextTick(() => {
- (this.$refs.form as any).focus();
- });
- },
- onChildFormPosted() {
- this.$emit('posted');
- }
- }
-});
-</script>
-
-<style lang="stylus" scoped>
-@import '~const.styl'
-
-.mk-renote-form
-
- > .mk-note-preview
- margin 16px 22px
-
- > 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
-
-</style>
diff --git a/src/client/app/desktop/views/components/settings.api.vue b/src/client/app/desktop/views/components/settings.api.vue
index a43c6e8ea6..377f2e689b 100644
--- a/src/client/app/desktop/views/components/settings.api.vue
+++ b/src/client/app/desktop/views/components/settings.api.vue
@@ -29,8 +29,6 @@ export default Vue.extend({
<style lang="stylus" scoped>
.root.api
- color #4a535a
-
code
display inline-block
padding 4px 6px
diff --git a/src/client/app/desktop/views/components/settings.vue b/src/client/app/desktop/views/components/settings.vue
index 3d88ccb6c2..9439ded2fc 100644
--- a/src/client/app/desktop/views/components/settings.vue
+++ b/src/client/app/desktop/views/components/settings.vue
@@ -20,7 +20,7 @@
<section class="web" v-show="page == 'web'">
<h1>動作</h1>
- <mk-switch v-model="os.i.clientSettings.fetchOnScroll" @change="onChangeFetchOnScroll" text="スクロールで自動読み込み">
+ <mk-switch v-model="clientSettings.fetchOnScroll" @change="onChangeFetchOnScroll" text="スクロールで自動読み込み">
<span>ページを下までスクロールしたときに自動で追加のコンテンツを読み込みます。</span>
</mk-switch>
<mk-switch v-model="autoPopout" text="ウィンドウの自動ポップアウト">
@@ -37,13 +37,20 @@
<section class="web" v-show="page == 'web'">
<h1>デザインと表示</h1>
<div class="div">
- <button class="ui button" @click="customizeHome">ホームをカスタマイズ</button>
+ <button class="ui button" @click="customizeHome" style="margin-bottom: 16px">ホームをカスタマイズ</button>
</div>
- <mk-switch v-model="os.i.clientSettings.showPostFormOnTopOfTl" @change="onChangeShowPostFormOnTopOfTl" text="タイムライン上部に投稿フォームを表示する"/>
- <mk-switch v-model="os.i.clientSettings.showMaps" @change="onChangeShowMaps" text="マップの自動展開">
+ <div class="div">
+ <mk-switch v-model="darkmode" text="ダークモード"/>
+ <mk-switch v-model="clientSettings.circleIcons" @change="onChangeCircleIcons" text="円形のアイコンを使用"/>
+ <mk-switch v-model="clientSettings.gradientWindowHeader" @change="onChangeGradientWindowHeader" text="ウィンドウのタイトルバーにグラデーションを使用"/>
+ </div>
+ <mk-switch v-model="clientSettings.showPostFormOnTopOfTl" @change="onChangeShowPostFormOnTopOfTl" text="タイムライン上部に投稿フォームを表示する"/>
+ <mk-switch v-model="clientSettings.showReplyTarget" @change="onChangeShowReplyTarget" text="リプライ先を表示する"/>
+ <mk-switch v-model="clientSettings.showMyRenotes" @change="onChangeShowMyRenotes" text="自分の行ったRenoteをタイムラインに表示する"/>
+ <mk-switch v-model="clientSettings.showRenotedMyNotes" @change="onChangeShowRenotedMyNotes" text="Renoteされた自分の投稿をタイムラインに表示する"/>
+ <mk-switch v-model="clientSettings.showMaps" @change="onChangeShowMaps" text="マップの自動展開">
<span>位置情報が添付された投稿のマップを自動的に展開します。</span>
</mk-switch>
- <mk-switch v-model="os.i.clientSettings.gradientWindowHeader" @change="onChangeGradientWindowHeader" text="ウィンドウのタイトルバーにグラデーションを使用"/>
</section>
<section class="web" v-show="page == 'web'">
@@ -63,7 +70,7 @@
<section class="web" v-show="page == 'web'">
<h1>モバイル</h1>
- <mk-switch v-model="os.i.clientSettings.disableViaMobile" @change="onChangeDisableViaMobile" text="「モバイルからの投稿」フラグを付けない"/>
+ <mk-switch v-model="clientSettings.disableViaMobile" @change="onChangeDisableViaMobile" text="「モバイルからの投稿」フラグを付けない"/>
</section>
<section class="web" v-show="page == 'web'">
@@ -76,6 +83,7 @@
<el-option label="ja-JP" value="ja"/>
<el-option label="en-US" value="en"/>
<el-option label="fr" value="fr"/>
+ <el-option label="pl" value="pl"/>
</el-option-group>
</el-select>
<div class="none ui info">
@@ -228,6 +236,7 @@ export default Vue.extend({
version,
latestVersion: undefined,
checkingForUpdate: false,
+ darkmode: localStorage.getItem('darkmode') == 'true',
enableSounds: localStorage.getItem('enableSounds') == 'true',
autoPopout: localStorage.getItem('autoPopout') == 'true',
apiViaStream: localStorage.getItem('apiViaStream') ? localStorage.getItem('apiViaStream') == 'true' : true,
@@ -251,6 +260,9 @@ export default Vue.extend({
apiViaStream() {
localStorage.setItem('apiViaStream', this.apiViaStream ? 'true' : 'false');
},
+ darkmode() {
+ (this as any)._updateDarkmode_(this.darkmode);
+ },
enableSounds() {
localStorage.setItem('enableSounds', this.enableSounds ? 'true' : 'false');
},
@@ -287,8 +299,8 @@ export default Vue.extend({
this.$emit('done');
},
onChangeFetchOnScroll(v) {
- (this as any).api('i/update_client_setting', {
- name: 'fetchOnScroll',
+ this.$store.dispatch('settings/set', {
+ key: 'fetchOnScroll',
value: v
});
},
@@ -297,27 +309,57 @@ export default Vue.extend({
autoWatch: v
});
},
+ onChangeDark(v) {
+ this.$store.dispatch('settings/set', {
+ key: 'dark',
+ value: v
+ });
+ },
onChangeShowPostFormOnTopOfTl(v) {
- (this as any).api('i/update_client_setting', {
- name: 'showPostFormOnTopOfTl',
+ this.$store.dispatch('settings/set', {
+ key: 'showPostFormOnTopOfTl',
+ value: v
+ });
+ },
+ onChangeShowReplyTarget(v) {
+ this.$store.dispatch('settings/set', {
+ key: 'showReplyTarget',
+ value: v
+ });
+ },
+ onChangeShowMyRenotes(v) {
+ this.$store.dispatch('settings/set', {
+ key: 'showMyRenotes',
+ value: v
+ });
+ },
+ onChangeShowRenotedMyNotes(v) {
+ this.$store.dispatch('settings/set', {
+ key: 'showRenotedMyNotes',
value: v
});
},
onChangeShowMaps(v) {
- (this as any).api('i/update_client_setting', {
- name: 'showMaps',
+ this.$store.dispatch('settings/set', {
+ key: 'showMaps',
+ value: v
+ });
+ },
+ onChangeCircleIcons(v) {
+ this.$store.dispatch('settings/set', {
+ key: 'circleIcons',
value: v
});
},
onChangeGradientWindowHeader(v) {
- (this as any).api('i/update_client_setting', {
- name: 'gradientWindowHeader',
+ this.$store.dispatch('settings/set', {
+ key: 'gradientWindowHeader',
value: v
});
},
onChangeDisableViaMobile(v) {
- (this as any).api('i/update_client_setting', {
- name: 'disableViaMobile',
+ this.$store.dispatch('settings/set', {
+ key: 'disableViaMobile',
value: v
});
},
@@ -358,7 +400,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-settings
+root(isDark)
display flex
width 100%
height 100%
@@ -369,13 +411,13 @@ export default Vue.extend({
height 100%
padding 16px 0 0 0
overflow auto
- border-right solid 1px #ddd
+ border-right solid 1px isDark ? #1c2023 : #ddd
> p
display block
padding 10px 16px
margin 0
- color #666
+ color isDark ? #9aa2a7 : #666
cursor pointer
user-select none
transition margin-left 0.2s ease
@@ -384,7 +426,7 @@ export default Vue.extend({
margin-right 4px
&:hover
- color #555
+ color isDark ? #fff : #555
&.active
margin-left 8px
@@ -398,14 +440,14 @@ export default Vue.extend({
> section
margin 32px
- color #4a535a
+ color isDark ? #c4ccd2 : #4a535a
> h1
margin 0 0 1em 0
padding 0 0 8px 0
font-size 1em
- color #555
- border-bottom solid 1px #eee
+ color isDark ? #e3e7ea : #555
+ border-bottom solid 1px isDark ? #1c2023 : #eee
&, >>> *
.ui.button.block
@@ -418,13 +460,18 @@ export default Vue.extend({
margin 0 0 1em 0
padding 0 0 8px 0
font-size 1em
- color #555
- border-bottom solid 1px #eee
+ color isDark ? #e3e7ea : #555
+ border-bottom solid 1px isDark ? #1c2023 : #eee
> .web
> .div
- border-bottom solid 1px #eee
- padding 0 0 16px 0
- margin 0 0 16px 0
+ border-bottom solid 1px isDark ? #1c2023 : #eee
+ margin 16px 0
+
+.mk-settings[data-darkmode]
+ root(true)
+
+.mk-settings:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/desktop/views/components/sub-note-content.vue b/src/client/app/desktop/views/components/sub-note-content.vue
index 51ee93cba6..dd4012039b 100644
--- a/src/client/app/desktop/views/components/sub-note-content.vue
+++ b/src/client/app/desktop/views/components/sub-note-content.vue
@@ -1,6 +1,7 @@
<template>
<div class="mk-sub-note-content">
<div class="body">
+ <span v-if="note.isHidden" style="opacity: 0.5">(この投稿は非公開です)</span>
<a class="reply" v-if="note.replyId">%fa:reply%</a>
<mk-note-html :text="note.text" :i="os.i"/>
<a class="rp" v-if="note.renoteId" :href="`/note:${note.renoteId}`">RP: ...</a>
diff --git a/src/client/app/desktop/views/components/timeline.core.vue b/src/client/app/desktop/views/components/timeline.core.vue
index 1e98f087e1..254a5b9d63 100644
--- a/src/client/app/desktop/views/components/timeline.core.vue
+++ b/src/client/app/desktop/views/components/timeline.core.vue
@@ -1,24 +1,23 @@
<template>
-<div class="mk-home-timeline">
+<div class="mk-timeline-core">
<mk-friends-maker v-if="src == 'home' && alone"/>
<div class="fetching" v-if="fetching">
<mk-ellipsis-icon/>
</div>
- <p class="empty" v-if="notes.length == 0 && !fetching">
- %fa:R comments%%i18n:@empty%
- </p>
- <mk-notes :notes="notes" ref="timeline">
- <button slot="footer" @click="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
- <template v-if="!moreFetching">%i18n:@load-more%</template>
- <template v-if="moreFetching">%fa:spinner .pulse .fw%</template>
- </button>
+
+ <mk-notes ref="timeline" :more="canFetchMore ? more : null">
+ <p :class="$style.empty" slot="empty">
+ %fa:R comments%%i18n:@empty%
+ </p>
</mk-notes>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
-import { url } from '../../../config';
+import getNoteSummary from '../../../../../renderers/get-note-summary';
+
+const fetchLimit = 10;
export default Vue.extend({
props: {
@@ -33,9 +32,9 @@ export default Vue.extend({
fetching: true,
moreFetching: false,
existMore: false,
- notes: [],
connection: null,
connectionId: null,
+ unreadCount: 0,
date: null
};
},
@@ -59,6 +58,10 @@ export default Vue.extend({
: this.src == 'local'
? 'notes/local-timeline'
: 'notes/global-timeline';
+ },
+
+ canFetchMore(): boolean {
+ return !this.moreFetching && !this.fetching && this.existMore;
}
},
@@ -72,6 +75,9 @@ export default Vue.extend({
this.connection.on('unfollow', this.onChangeFollowing);
}
+ document.addEventListener('keydown', this.onKeydown);
+ document.addEventListener('visibilitychange', this.onVisibilitychange, false);
+
this.fetch();
},
@@ -82,56 +88,62 @@ export default Vue.extend({
this.connection.off('unfollow', this.onChangeFollowing);
}
this.stream.dispose(this.connectionId);
+
+ document.removeEventListener('keydown', this.onKeydown);
+ document.removeEventListener('visibilitychange', this.onVisibilitychange);
},
methods: {
- fetch(cb?) {
+ fetch() {
this.fetching = true;
- (this as any).api(this.endpoint, {
- limit: 11,
- untilDate: this.date ? this.date.getTime() : undefined
- }).then(notes => {
- if (notes.length == 11) {
- notes.pop();
- this.existMore = true;
- }
- this.notes = notes;
- this.fetching = false;
- this.$emit('loaded');
- if (cb) cb();
- });
+ (this.$refs.timeline as any).init(() => new Promise((res, rej) => {
+ (this as any).api(this.endpoint, {
+ limit: fetchLimit + 1,
+ untilDate: this.date ? this.date.getTime() : undefined,
+ includeMyRenotes: (this as any).clientSettings.showMyRenotes,
+ includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes
+ }).then(notes => {
+ if (notes.length == fetchLimit + 1) {
+ notes.pop();
+ this.existMore = true;
+ }
+ res(notes);
+ this.fetching = false;
+ this.$emit('loaded');
+ }, rej);
+ }));
},
more() {
- if (this.moreFetching || this.fetching || this.notes.length == 0 || !this.existMore) return;
+ if (!this.canFetchMore) return;
+
this.moreFetching = true;
+
(this as any).api(this.endpoint, {
- limit: 11,
- untilId: this.notes[this.notes.length - 1].id
+ limit: fetchLimit + 1,
+ untilId: (this.$refs.timeline as any).tail().id,
+ includeMyRenotes: (this as any).clientSettings.showMyRenotes,
+ includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes
}).then(notes => {
- if (notes.length == 11) {
+ if (notes.length == fetchLimit + 1) {
notes.pop();
} else {
this.existMore = false;
}
- this.notes = this.notes.concat(notes);
+ notes.forEach(n => (this.$refs.timeline as any).append(n));
this.moreFetching = false;
});
},
onNote(note) {
- // サウンドを再生する
- if ((this as any).os.isEnableSounds) {
- const sound = new Audio(`${url}/assets/post.mp3`);
- sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 0.5;
- sound.play();
+ if (document.hidden && note.userId !== (this as any).os.i.id) {
+ this.unreadCount++;
+ document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`;
}
- this.notes.unshift(note);
-
- const isTop = window.scrollY > 8;
- if (isTop) this.notes.pop();
+ // Prepend a note
+ (this.$refs.timeline as any).prepend(note);
},
onChangeFollowing() {
@@ -145,31 +157,51 @@ export default Vue.extend({
warp(date) {
this.date = date;
this.fetch();
+ },
+
+ onVisibilitychange() {
+ if (!document.hidden) {
+ this.unreadCount = 0;
+ document.title = 'Misskey';
+ }
+ },
+
+ onKeydown(e) {
+ if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') {
+ if (e.which == 84) { // t
+ this.focus();
+ }
+ }
}
}
});
</script>
<style lang="stylus" scoped>
-.mk-home-timeline
+@import '~const.styl'
+
+.mk-timeline-core
> .mk-friends-maker
border-bottom solid 1px #eee
> .fetching
padding 64px 0
- > .empty
- display block
- margin 0 auto
- padding 32px
- max-width 400px
- text-align center
- color #999
+</style>
+
+<style lang="stylus" module>
+.empty
+ display block
+ margin 0 auto
+ padding 32px
+ max-width 400px
+ text-align center
+ color #999
- > [data-fa]
- display block
- margin-bottom 16px
- font-size 3em
- color #ccc
+ > [data-fa]
+ display block
+ margin-bottom 16px
+ font-size 3em
+ color #ccc
</style>
diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue
index e0215ad1a2..a776e40a24 100644
--- a/src/client/app/desktop/views/components/timeline.vue
+++ b/src/client/app/desktop/views/components/timeline.vue
@@ -1,19 +1,23 @@
<template>
<div class="mk-timeline">
<header>
- <span :data-is-active="src == 'home'" @click="src = 'home'">%fa:home% ホーム</span>
- <span :data-is-active="src == 'local'" @click="src = 'local'">%fa:R comments% ローカル</span>
- <span :data-is-active="src == 'global'" @click="src = 'global'">%fa:globe% グローバル</span>
+ <span :data-active="src == 'home'" @click="src = 'home'">%fa:home% %i18n:@home%</span>
+ <span :data-active="src == 'local'" @click="src = 'local'">%fa:R comments% %i18n:@local%</span>
+ <span :data-active="src == 'global'" @click="src = 'global'">%fa:globe% %i18n:@global%</span>
+ <span :data-active="src == 'list'" @click="src = 'list'" v-if="list">%fa:list% {{ list.title }}</span>
+ <button @click="chooseList" title="%i18n:@list%">%fa:list%</button>
</header>
<x-core v-if="src == 'home'" ref="tl" key="home" src="home"/>
<x-core v-if="src == 'local'" ref="tl" key="local" src="local"/>
<x-core v-if="src == 'global'" ref="tl" key="global" src="global"/>
+ <mk-user-list-timeline v-if="src == 'list'" ref="tl" :key="list.id" :list="list"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XCore from './timeline.core.vue';
+import MkUserListsWindow from './user-lists-window.vue';
export default Vue.extend({
components: {
@@ -22,44 +26,35 @@ export default Vue.extend({
data() {
return {
- src: 'home'
+ src: 'home',
+ list: null
};
},
- mounted() {
- document.addEventListener('keydown', this.onKeydown);
- window.addEventListener('scroll', this.onScroll);
-
- console.log(this.$refs.tl);
+ created() {
+ if ((this as any).os.i.followingCount == 0) {
+ this.src = 'local';
+ }
+ },
+ mounted() {
(this.$refs.tl as any).$once('loaded', () => {
this.$emit('loaded');
});
},
- beforeDestroy() {
- document.removeEventListener('keydown', this.onKeydown);
- window.removeEventListener('scroll', this.onScroll);
- },
-
methods: {
- onScroll() {
- if ((this as any).os.i.clientSettings.fetchOnScroll !== false) {
- const current = window.scrollY + window.innerHeight;
- if (current > document.body.offsetHeight - 8) (this.$refs.tl as any).more();
- }
- },
-
- onKeydown(e) {
- if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') {
- if (e.which == 84) { // t
- (this.$refs.tl as any).focus();
- }
- }
- },
-
warp(date) {
(this.$refs.tl as any).warp(date);
+ },
+
+ chooseList() {
+ const w = (this as any).os.new(MkUserListsWindow);
+ w.$once('choosen', list => {
+ this.list = list;
+ this.src = 'list';
+ w.close();
+ });
}
}
});
@@ -68,26 +63,68 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-timeline
- background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
+root(isDark)
+ background isDark ? #282C37 : #fff
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
> header
- padding 8px 16px
- border-bottom solid 1px #eee
+ padding 0 8px
+ z-index 10
+ background isDark ? #313543 : #fff
+ border-radius 6px 6px 0 0
+ box-shadow 0 1px isDark ? rgba(#000, 0.15) : rgba(#000, 0.08)
+
+ > button
+ position absolute
+ z-index 2
+ top 0
+ right 0
+ padding 0
+ width 42px
+ font-size 0.9em
+ line-height 42px
+ color isDark ? #9baec8 : #ccc
+
+ &:hover
+ color isDark ? #b2c1d5 : #aaa
+
+ &:active
+ color isDark ? #b2c1d5 : #999
> span
- margin-right 16px
- line-height 27px
- font-size 14px
- color #555
+ display inline-block
+ padding 0 10px
+ line-height 42px
+ font-size 12px
+ user-select none
- &:not([data-is-active])
+ &[data-active]
color $theme-color
+ cursor default
+ font-weight bold
+
+ &:before
+ content ""
+ display block
+ position absolute
+ bottom 0
+ left -8px
+ width calc(100% + 16px)
+ height 2px
+ background $theme-color
+
+ &:not([data-active])
+ color isDark ? #9aa2a7 : #6f7477
cursor pointer
&:hover
- text-decoration underline
+ color isDark ? #d9dcde : #525a5f
+
+.mk-timeline[data-darkmode]
+ root(true)
+
+.mk-timeline:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/desktop/views/components/ui.header.account.vue b/src/client/app/desktop/views/components/ui.header.account.vue
index 558aaa6dc8..fd15ea6006 100644
--- a/src/client/app/desktop/views/components/ui.header.account.vue
+++ b/src/client/app/desktop/views/components/ui.header.account.vue
@@ -2,32 +2,40 @@
<div class="account">
<button class="header" :data-active="isOpen" @click="toggle">
<span class="username">{{ os.i.username }}<template v-if="!isOpen">%fa:angle-down%</template><template v-if="isOpen">%fa:angle-up%</template></span>
- <img class="avatar" :src="`${ os.i.avatarUrl }?thumbnail&size=64`" alt="avatar"/>
+ <mk-avatar class="avatar" :user="os.i"/>
</button>
<transition name="zoom-in-top">
<div class="menu" v-if="isOpen">
<ul>
<li>
- <router-link :to="`/@${ os.i.username }`">%fa:user%%i18n:@profile%%fa:angle-right%</router-link>
+ <router-link :to="`/@${ os.i.username }`">%fa:user%<span>%i18n:@profile%</span>%fa:angle-right%</router-link>
</li>
<li @click="drive">
- <p>%fa:cloud%%i18n:@drive%%fa:angle-right%</p>
+ <p>%fa:cloud%<span>%i18n:@drive%</span>%fa:angle-right%</p>
</li>
<li>
- <a href="/i/mentions">%fa:at%%i18n:@mentions%%fa:angle-right%</a>
+ <router-link to="/i/favorites">%fa:star%<span>%i18n:@favorites%</span>%fa:angle-right%</router-link>
+ </li>
+ <li @click="list">
+ <p>%fa:list%<span>%i18n:@lists%</span>%fa:angle-right%</p>
</li>
</ul>
<ul>
<li>
- <a href="/i/customize-home">%fa:wrench%%i18n:@customize%%fa:angle-right%</a>
+ <router-link to="/i/customize-home">%fa:wrench%<span>%i18n:@customize%</span>%fa:angle-right%</router-link>
</li>
<li @click="settings">
- <p>%fa:cog%%i18n:@settings%%fa:angle-right%</p>
+ <p>%fa:cog%<span>%i18n:@settings%</span>%fa:angle-right%</p>
</li>
</ul>
<ul>
<li @click="signout">
- <p>%fa:power-off%%i18n:@signout%%fa:angle-right%</p>
+ <p class="signout">%fa:power-off%<span>%i18n:@signout%</span></p>
+ </li>
+ </ul>
+ <ul>
+ <li @click="dark">
+ <p><span>%i18n:@dark%</span><template v-if="_darkmode_">%fa:moon%</template><template v-else>%fa:R moon%</template></p>
</li>
</ul>
</div>
@@ -37,6 +45,7 @@
<script lang="ts">
import Vue from 'vue';
+import MkUserListsWindow from './user-lists-window.vue';
import MkSettingsWindow from './settings-window.vue';
import MkDriveWindow from './drive-window.vue';
import contains from '../../../common/scripts/contains';
@@ -75,12 +84,22 @@ export default Vue.extend({
this.close();
(this as any).os.new(MkDriveWindow);
},
+ list() {
+ this.close();
+ const w = (this as any).os.new(MkUserListsWindow);
+ w.$once('choosen', list => {
+ this.$router.push(`i/lists/${ list.id }`);
+ });
+ },
settings() {
this.close();
(this as any).os.new(MkSettingsWindow);
},
signout() {
(this as any).os.signout();
+ },
+ dark() {
+ (this as any)._updateDarkmode_(!(this as any)._darkmode_);
}
}
});
@@ -89,7 +108,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.account
+root(isDark)
> .header
display block
margin 0
@@ -104,13 +123,13 @@ export default Vue.extend({
&:hover
&[data-active='true']
- color darken(#9eaba8, 20%)
+ color isDark ? #fff : darken(#9eaba8, 20%)
> .avatar
filter saturate(150%)
&:active
- color darken(#9eaba8, 30%)
+ color isDark ? #fff : darken(#9eaba8, 30%)
> .username
display block
@@ -137,15 +156,16 @@ export default Vue.extend({
transition filter 100ms ease
> .menu
+ $bgcolor = isDark ? #282c37 : #fff
display block
position absolute
top 56px
right -2px
width 230px
font-size 0.8em
- background #fff
+ background $bgcolor
border-radius 4px
- box-shadow 0 1px 4px rgba(0, 0, 0, 0.25)
+ box-shadow 0 1px 4px rgba(#000, 0.25)
&:before
content ""
@@ -156,7 +176,7 @@ export default Vue.extend({
right 12px
border-top solid 14px transparent
border-right solid 14px transparent
- border-bottom solid 14px rgba(0, 0, 0, 0.1)
+ border-bottom solid 14px rgba(#000, 0.1)
border-left solid 14px transparent
&:after
@@ -168,7 +188,7 @@ export default Vue.extend({
right 12px
border-top solid 14px transparent
border-right solid 14px transparent
- border-bottom solid 14px #fff
+ border-bottom solid 14px $bgcolor
border-left solid 14px transparent
ul
@@ -179,7 +199,7 @@ export default Vue.extend({
& + ul
padding-top 10px
- border-top solid 1px #eee
+ border-top solid 1px isDark ? #1c2023 : #eee
> li
display block
@@ -193,16 +213,20 @@ export default Vue.extend({
padding 0 28px
margin 0
line-height 40px
- color #868C8C
+ color isDark ? #c8cece : #868C8C
cursor pointer
*
pointer-events none
- > [data-fa]:first-of-type
+ > span:first-child
+ padding-left 22px
+
+ > [data-fa]:first-child
margin-right 6px
+ width 16px
- > [data-fa]:last-of-type
+ > [data-fa]:last-child
display block
position absolute
top 0
@@ -220,9 +244,25 @@ export default Vue.extend({
&:active
background darken($theme-color, 10%)
+ &.signout
+ $color = #e64137
+
+ &:hover, &:active
+ background $color
+ color #fff
+
+ &:active
+ background darken($color, 10%)
+
.zoom-in-top-enter-active,
.zoom-in-top-leave-active {
transform-origin: center -16px;
}
+.account[data-darkmode]
+ root(true)
+
+.account:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/components/ui.header.nav.vue b/src/client/app/desktop/views/components/ui.header.nav.vue
index 19f72a86d7..0800d96eb6 100644
--- a/src/client/app/desktop/views/components/ui.header.nav.vue
+++ b/src/client/app/desktop/views/components/ui.header.nav.vue
@@ -99,7 +99,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.nav
+root(isDark)
display inline-block
margin 0
padding 0
@@ -131,7 +131,7 @@ export default Vue.extend({
padding 0 24px
font-size 13px
font-variant small-caps
- color #9eaba8
+ color isDark ? #b8c5ca : #9eaba8
text-decoration none
transition none
cursor pointer
@@ -140,7 +140,7 @@ export default Vue.extend({
pointer-events none
&:hover
- color darken(#9eaba8, 20%)
+ color isDark ? #fff : darken(#9eaba8, 20%)
text-decoration none
> [data-fa]:first-child
@@ -164,4 +164,10 @@ export default Vue.extend({
@media (max-width 700px)
padding 0 12px
+.nav[data-darkmode]
+ root(true)
+
+.nav:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/components/ui.header.notifications.vue b/src/client/app/desktop/views/components/ui.header.notifications.vue
index e9a6b9b04f..ea814dd7a3 100644
--- a/src/client/app/desktop/views/components/ui.header.notifications.vue
+++ b/src/client/app/desktop/views/components/ui.header.notifications.vue
@@ -84,7 +84,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.notifications
+root(isDark)
> button
display block
@@ -101,10 +101,10 @@ export default Vue.extend({
&:hover
&[data-active='true']
- color darken(#9eaba8, 20%)
+ color isDark ? #fff : darken(#9eaba8, 20%)
&:active
- color darken(#9eaba8, 30%)
+ color isDark ? #fff : darken(#9eaba8, 30%)
> [data-fa].bell
font-size 1.2em
@@ -117,14 +117,15 @@ export default Vue.extend({
color $theme-color
> .pop
+ $bgcolor = isDark ? #282c37 : #fff
display block
position absolute
top 56px
right -72px
width 300px
- background #fff
+ background $bgcolor
border-radius 4px
- box-shadow 0 1px 4px rgba(0, 0, 0, 0.25)
+ box-shadow 0 1px 4px rgba(#000, 0.25)
&:before
content ""
@@ -135,7 +136,7 @@ export default Vue.extend({
right 74px
border-top solid 14px transparent
border-right solid 14px transparent
- border-bottom solid 14px rgba(0, 0, 0, 0.1)
+ border-bottom solid 14px rgba(#000, 0.1)
border-left solid 14px transparent
&:after
@@ -147,7 +148,7 @@ export default Vue.extend({
right 74px
border-top solid 14px transparent
border-right solid 14px transparent
- border-bottom solid 14px #fff
+ border-bottom solid 14px $bgcolor
border-left solid 14px transparent
> .mk-notifications
@@ -155,4 +156,10 @@ export default Vue.extend({
font-size 1rem
overflow auto
+.notifications[data-darkmode]
+ root(true)
+
+.notifications:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/components/ui.header.search.vue b/src/client/app/desktop/views/components/ui.header.search.vue
index 3167aab8ab..1ed28ba3a8 100644
--- a/src/client/app/desktop/views/components/ui.header.search.vue
+++ b/src/client/app/desktop/views/components/ui.header.search.vue
@@ -50,7 +50,7 @@ export default Vue.extend({
width 14em
height 32px
font-size 1em
- background rgba(0, 0, 0, 0.05)
+ background rgba(#000, 0.05)
outline none
//border solid 1px #ddd
border none
@@ -62,7 +62,7 @@ export default Vue.extend({
color #9eaba8
&:hover
- background rgba(0, 0, 0, 0.08)
+ background rgba(#000, 0.08)
&:focus
box-shadow 0 0 0 2px rgba($theme-color, 0.5) !important
diff --git a/src/client/app/desktop/views/components/ui.header.vue b/src/client/app/desktop/views/components/ui.header.vue
index 2b63030cd2..7729575b56 100644
--- a/src/client/app/desktop/views/components/ui.header.vue
+++ b/src/client/app/desktop/views/components/ui.header.vue
@@ -43,10 +43,13 @@ export default Vue.extend({
XClock,
},
mounted() {
+ this.$store.commit('setUiHeaderHeight', 48);
+
if ((this as any).os.isSignedIn) {
- const ago = (new Date().getTime() - new Date((this as any).os.i.lastUsedAt).getTime()) / 1000
+ const ago = (new Date().getTime() - new Date((this as any).os.i.lastUsedAt).getTime()) / 1000;
const isHisasiburi = ago >= 3600;
(this as any).os.i.lastUsedAt = new Date();
+ (this as any).os.bakeMe();
if (isHisasiburi) {
(this.$refs.welcomeback as any).style.display = 'block';
(this.$refs.main as any).style.overflow = 'hidden';
@@ -101,7 +104,7 @@ root(isDark)
top 0
z-index 1000
width 100%
- box-shadow 0 1px 1px rgba(0, 0, 0, 0.075)
+ box-shadow 0 1px 1px rgba(#000, 0.075)
> .main
height 48px
@@ -130,7 +133,7 @@ root(isDark)
line-height 48px
margin 0
text-align center
- color #888
+ color isDark ? #fff : #888
opacity 0
> .container
@@ -169,10 +172,10 @@ root(isDark)
> .mk-ui-header-search
display none
-.header[data-is-darkmode]
+.header[data-darkmode]
root(true)
-.header
+.header:not([data-darkmode])
root(false)
</style>
diff --git a/src/client/app/desktop/views/components/user-list-timeline.vue b/src/client/app/desktop/views/components/user-list-timeline.vue
new file mode 100644
index 0000000000..59d6abbbc1
--- /dev/null
+++ b/src/client/app/desktop/views/components/user-list-timeline.vue
@@ -0,0 +1,93 @@
+<template>
+<div>
+ <mk-notes ref="timeline" :more="existMore ? more : null"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { UserListStream } from '../../../common/scripts/streaming/user-list';
+
+const fetchLimit = 10;
+
+export default Vue.extend({
+ props: ['list'],
+ data() {
+ return {
+ fetching: true,
+ moreFetching: false,
+ existMore: false,
+ connection: null
+ };
+ },
+ watch: {
+ $route: 'init'
+ },
+ mounted() {
+ this.init();
+ },
+ beforeDestroy() {
+ this.connection.close();
+ },
+ methods: {
+ init() {
+ if (this.connection) this.connection.close();
+ this.connection = new UserListStream((this as any).os, (this as any).os.i, this.list.id);
+ this.connection.on('note', this.onNote);
+ this.connection.on('userAdded', this.onUserAdded);
+ this.connection.on('userRemoved', this.onUserRemoved);
+
+ this.fetch();
+ },
+ fetch() {
+ this.fetching = true;
+
+ (this.$refs.timeline as any).init(() => new Promise((res, rej) => {
+ (this as any).api('notes/user-list-timeline', {
+ listId: this.list.id,
+ limit: fetchLimit + 1,
+ includeMyRenotes: (this as any).clientSettings.showMyRenotes,
+ includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes
+ }).then(notes => {
+ if (notes.length == fetchLimit + 1) {
+ notes.pop();
+ this.existMore = true;
+ }
+ res(notes);
+ this.fetching = false;
+ this.$emit('loaded');
+ }, rej);
+ }));
+ },
+ more() {
+ this.moreFetching = true;
+
+ (this as any).api('notes/user-list-timeline', {
+ listId: this.list.id,
+ limit: fetchLimit + 1,
+ untilId: (this.$refs.timeline as any).tail().id,
+ includeMyRenotes: (this as any).clientSettings.showMyRenotes,
+ includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes
+ }).then(notes => {
+ if (notes.length == fetchLimit + 1) {
+ notes.pop();
+ } else {
+ this.existMore = false;
+ }
+ notes.forEach(n => (this.$refs.timeline as any).append(n));
+ this.moreFetching = false;
+ });
+ },
+ onNote(note) {
+ // Prepend a note
+ (this.$refs.timeline as any).prepend(note);
+ },
+ onUserAdded() {
+ this.fetch();
+ },
+ onUserRemoved() {
+ this.fetch();
+ }
+ }
+});
+</script>
diff --git a/src/client/app/desktop/views/components/user-lists-window.vue b/src/client/app/desktop/views/components/user-lists-window.vue
new file mode 100644
index 0000000000..d082610132
--- /dev/null
+++ b/src/client/app/desktop/views/components/user-lists-window.vue
@@ -0,0 +1,69 @@
+<template>
+<mk-window ref="window" is-modal width="450px" height="500px" @closed="$destroy">
+ <span slot="header">%fa:list% リスト</span>
+
+ <div data-id="6e4caea3-d8f9-4ab7-96de-ab67fe8d5c82" :data-darkmode="_darkmode_">
+ <button class="ui" @click="add">リストを作成</button>
+ <a v-for="list in lists" :key="list.id" @click="choice(list)">{{ list.title }}</a>
+ </div>
+</mk-window>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+ data() {
+ return {
+ fetching: true,
+ lists: []
+ };
+ },
+ mounted() {
+ (this as any).api('users/lists/list').then(lists => {
+ this.fetching = false;
+ this.lists = lists;
+ });
+ },
+ methods: {
+ add() {
+ (this as any).apis.input({
+ title: 'リスト名',
+ }).then(async title => {
+ const list = await (this as any).api('users/lists/create', {
+ title
+ });
+
+ this.$emit('choosen', list);
+ });
+ },
+ choice(list) {
+ this.$emit('choosen', list);
+ },
+ close() {
+ (this as any).$refs.window.close();
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+
+root(isDark)
+ padding 16px
+
+ > button
+ margin-bottom 16px
+
+ > a
+ display block
+ padding 16px
+ border solid 1px isDark ? #1c2023 : #eee
+ border-radius 4px
+
+[data-id="6e4caea3-d8f9-4ab7-96de-ab67fe8d5c82"][data-darkmode]
+ root(true)
+
+[data-id="6e4caea3-d8f9-4ab7-96de-ab67fe8d5c82"]:not([data-darkmode])
+ root(false)
+
+</style>
diff --git a/src/client/app/desktop/views/components/user-preview.vue b/src/client/app/desktop/views/components/user-preview.vue
index bcd79dc2af..cc5e021390 100644
--- a/src/client/app/desktop/views/components/user-preview.vue
+++ b/src/client/app/desktop/views/components/user-preview.vue
@@ -2,11 +2,9 @@
<div class="mk-user-preview">
<template v-if="u != null">
<div class="banner" :style="u.bannerUrl ? `background-image: url(${u.bannerUrl}?thumbnail&size=512)` : ''"></div>
- <router-link class="avatar" :to="u | userPage">
- <img :src="`${u.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
- </router-link>
+ <mk-avatar class="avatar" :user="u" :disable-preview="true"/>
<div class="title">
- <router-link class="name" :to="u | userPage">{{ u.name }}</router-link>
+ <router-link class="name" :to="u | userPage">{{ u | userName }}</router-link>
<p class="username">@{{ u | acct }}</p>
</div>
<div class="description">{{ u.description }}</div>
@@ -87,21 +85,21 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-user-preview
+root(isDark)
position absolute
z-index 2048
margin-top -8px
width 250px
- background #fff
+ background isDark ? #282c37 : #fff
background-clip content-box
- border solid 1px rgba(0, 0, 0, 0.1)
+ border solid 1px rgba(#000, 0.1)
border-radius 4px
overflow hidden
opacity 0
> .banner
height 84px
- background-color #f5f5f5
+ background-color isDark ? #1c1e26 : #f5f5f5
background-size cover
background-position center
@@ -111,14 +109,10 @@ export default Vue.extend({
top 62px
left 13px
z-index 2
-
- > img
- display block
- width 58px
- height 58px
- margin 0
- border solid 3px #fff
- border-radius 8px
+ width 58px
+ height 58px
+ border solid 3px isDark ? #282c37 : #fff
+ border-radius 8px
> .title
display block
@@ -129,19 +123,19 @@ export default Vue.extend({
margin 0
font-weight bold
line-height 16px
- color #656565
+ color isDark ? #fff : #656565
> .username
display block
margin 0
line-height 16px
font-size 0.8em
- color #999
+ color isDark ? #606984 : #999
> .description
padding 0 16px
font-size 0.7em
- color #555
+ color isDark ? #9ea4ad : #555
> .status
padding 8px 16px
@@ -164,4 +158,10 @@ export default Vue.extend({
top 92px
right 8px
+.mk-user-preview[data-darkmode]
+ root(true)
+
+.mk-user-preview:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/components/users-list.item.vue b/src/client/app/desktop/views/components/users-list.item.vue
index 005c9cd6d3..dbad295178 100644
--- a/src/client/app/desktop/views/components/users-list.item.vue
+++ b/src/client/app/desktop/views/components/users-list.item.vue
@@ -1,8 +1,6 @@
<template>
<div class="root item">
- <router-link class="avatar-anchor" :to="user | userPage" v-user-preview="user.id">
- <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
- </router-link>
+ <mk-avatar class="avatar" :user="user"/>
<div class="main">
<header>
<router-link class="name" :to="user | userPage" v-user-preview="user.id">{{ user | userName }}</router-link>
@@ -35,18 +33,13 @@ export default Vue.extend({
display block
clear both
- > .avatar-anchor
+ > .avatar
display block
float left
margin 0 16px 0 0
-
- > .avatar
- display block
- width 58px
- height 58px
- margin 0
- border-radius 8px
- vertical-align bottom
+ width 58px
+ height 58px
+ border-radius 8px
> .main
float left
diff --git a/src/client/app/desktop/views/components/users-list.vue b/src/client/app/desktop/views/components/users-list.vue
index a08e76f573..13d0d07bbc 100644
--- a/src/client/app/desktop/views/components/users-list.vue
+++ b/src/client/app/desktop/views/components/users-list.vue
@@ -2,8 +2,8 @@
<div class="mk-users-list">
<nav>
<div>
- <span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて<span>{{ count }}</span></span>
- <span v-if="os.isSignedIn && youKnowCount" :data-is-active="mode == 'iknow'" @click="mode = 'iknow'">知り合い<span>{{ youKnowCount }}</span></span>
+ <span :data-active="mode == 'all'" @click="mode = 'all'">すべて<span>{{ count }}</span></span>
+ <span v-if="os.isSignedIn && youKnowCount" :data-active="mode == 'iknow'" @click="mode = 'iknow'">知り合い<span>{{ youKnowCount }}</span></span>
</div>
</nav>
<div class="users" v-if="!fetching && users.length != 0">
@@ -98,7 +98,7 @@ export default Vue.extend({
*
pointer-events none
- &[data-is-active]
+ &[data-active]
font-weight bold
color $theme-color
border-color $theme-color
@@ -119,7 +119,7 @@ export default Vue.extend({
overflow auto
> *
- border-bottom solid 1px rgba(0, 0, 0, 0.05)
+ border-bottom solid 1px rgba(#000, 0.05)
> *
max-width 600px
diff --git a/src/client/app/desktop/views/components/widget-container.vue b/src/client/app/desktop/views/components/widget-container.vue
index 188a67313e..ab8327d39e 100644
--- a/src/client/app/desktop/views/components/widget-container.vue
+++ b/src/client/app/desktop/views/components/widget-container.vue
@@ -24,8 +24,8 @@ export default Vue.extend({
computed: {
withGradient(): boolean {
return (this as any).os.isSignedIn
- ? (this as any).os.i.clientSettings.gradientWindowHeader != null
- ? (this as any).os.i.clientSettings.gradientWindowHeader
+ ? (this as any).clientSettings.gradientWindowHeader != null
+ ? (this as any).clientSettings.gradientWindowHeader
: false
: false;
}
@@ -34,9 +34,9 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.mk-widget-container
- background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
+root(isDark)
+ background isDark ? #282C37 : #fff
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
overflow hidden
@@ -45,6 +45,8 @@ export default Vue.extend({
border none !important
> header
+ background isDark ? #313543 : #fff
+
> .title
z-index 1
margin 0
@@ -52,11 +54,11 @@ export default Vue.extend({
line-height 42px
font-size 0.9em
font-weight bold
- color #888
- box-shadow 0 1px rgba(0, 0, 0, 0.07)
+ color isDark ? #e3e5e8 : #888
+ box-shadow 0 1px rgba(#000, 0.07)
> [data-fa]
- margin-right 4px
+ margin-right 6px
&:empty
display none
@@ -70,16 +72,23 @@ export default Vue.extend({
width 42px
font-size 0.9em
line-height 42px
- color #ccc
+ color isDark ? #9baec8 : #ccc
&:hover
- color #aaa
+ color isDark ? #b2c1d5 : #aaa
&:active
- color #999
+ color isDark ? #b2c1d5 : #999
&.withGradient
> .title
- background linear-gradient(to bottom, #fff, #ececec)
+ background isDark ? linear-gradient(to bottom, #313543, #1d2027) : linear-gradient(to bottom, #fff, #ececec)
box-shadow 0 1px rgba(#000, 0.11)
+
+.mk-widget-container[data-darkmode]
+ root(true)
+
+.mk-widget-container:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/components/window.vue b/src/client/app/desktop/views/components/window.vue
index e2cab21799..2e7eb557b4 100644
--- a/src/client/app/desktop/views/components/window.vue
+++ b/src/client/app/desktop/views/components/window.vue
@@ -4,7 +4,7 @@
<div class="main" ref="main" tabindex="-1" :data-is-modal="isModal" @mousedown="onBodyMousedown" @keydown="onKeydown" :style="{ width, height }">
<div class="body">
<header ref="header"
- :class="{ withGradient }"
+ :class="{ withGradient: clientSettings.gradientWindowHeader }"
@contextmenu.prevent="() => {}" @mousedown.prevent="onHeaderMousedown"
>
<h1><slot name="header"></slot></h1>
@@ -17,14 +17,16 @@
<slot></slot>
</div>
</div>
- <div class="handle top" v-if="canResize" @mousedown.prevent="onTopHandleMousedown"></div>
- <div class="handle right" v-if="canResize" @mousedown.prevent="onRightHandleMousedown"></div>
- <div class="handle bottom" v-if="canResize" @mousedown.prevent="onBottomHandleMousedown"></div>
- <div class="handle left" v-if="canResize" @mousedown.prevent="onLeftHandleMousedown"></div>
- <div class="handle top-left" v-if="canResize" @mousedown.prevent="onTopLeftHandleMousedown"></div>
- <div class="handle top-right" v-if="canResize" @mousedown.prevent="onTopRightHandleMousedown"></div>
- <div class="handle bottom-right" v-if="canResize" @mousedown.prevent="onBottomRightHandleMousedown"></div>
- <div class="handle bottom-left" v-if="canResize" @mousedown.prevent="onBottomLeftHandleMousedown"></div>
+ <template v-if="canResize">
+ <div class="handle top" @mousedown.prevent="onTopHandleMousedown"></div>
+ <div class="handle right" @mousedown.prevent="onRightHandleMousedown"></div>
+ <div class="handle bottom" @mousedown.prevent="onBottomHandleMousedown"></div>
+ <div class="handle left" @mousedown.prevent="onLeftHandleMousedown"></div>
+ <div class="handle top-left" @mousedown.prevent="onTopLeftHandleMousedown"></div>
+ <div class="handle top-right" @mousedown.prevent="onTopRightHandleMousedown"></div>
+ <div class="handle bottom-right" @mousedown.prevent="onBottomRightHandleMousedown"></div>
+ <div class="handle bottom-left" @mousedown.prevent="onBottomLeftHandleMousedown"></div>
+ </template>
</div>
</div>
</template>
@@ -85,17 +87,10 @@ export default Vue.extend({
computed: {
isFlexible(): boolean {
- return this.height == null;
+ return this.height == 'auto';
},
canResize(): boolean {
return !this.isFlexible;
- },
- withGradient(): boolean {
- return (this as any).os.isSignedIn
- ? (this as any).os.i.clientSettings.gradientWindowHeader != null
- ? (this as any).os.i.clientSettings.gradientWindowHeader
- : false
- : false;
}
},
@@ -465,7 +460,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-window
+root(isDark)
display block
> .bg
@@ -476,7 +471,7 @@ export default Vue.extend({
left 0
width 100%
height 100%
- background rgba(0, 0, 0, 0.7)
+ background rgba(#000, 0.7)
opacity 0
pointer-events none
@@ -493,7 +488,7 @@ export default Vue.extend({
&: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)
+ box-shadow 0 0 0px 1px rgba($theme-color, 0.5), 0 2px 6px 0 rgba(#000, 0.2)
> .handle
$size = 8px
@@ -559,9 +554,9 @@ export default Vue.extend({
> .body
height 100%
overflow hidden
- background #fff
+ background isDark ? #282C37 : #fff
border-radius 6px
- box-shadow 0 2px 6px 0 rgba(0, 0, 0, 0.2)
+ box-shadow 0 2px 6px 0 rgba(#000, 0.2)
> header
$header-height = 40px
@@ -571,12 +566,12 @@ export default Vue.extend({
overflow hidden
white-space nowrap
cursor move
- background #fff
+ background isDark ? #313543 : #fff
border-radius 6px 6px 0 0
box-shadow 0 1px 0 rgba(#000, 0.1)
&.withGradient
- background linear-gradient(to bottom, #fff, #ececec)
+ background isDark ? linear-gradient(to bottom, #313543, #1d2027) : linear-gradient(to bottom, #fff, #ececec)
box-shadow 0 1px 0 rgba(#000, 0.15)
&, *
@@ -593,7 +588,7 @@ export default Vue.extend({
font-size 1em
line-height $header-height
font-weight normal
- color #666
+ color isDark ? #e3e5e8 : #666
> div:last-child
position absolute
@@ -608,16 +603,16 @@ export default Vue.extend({
padding 0
cursor pointer
font-size 1em
- color rgba(#000, 0.4)
+ color isDark ? #9baec8 : rgba(#000, 0.4)
border none
outline none
background transparent
&:hover
- color rgba(#000, 0.6)
+ color isDark ? #b2c1d5 : rgba(#000, 0.6)
&:active
- color darken(#000, 30%)
+ color isDark ? #b2c1d5 : darken(#000, 30%)
> [data-fa]
padding 0
@@ -632,4 +627,10 @@ export default Vue.extend({
> .main > .body > .content
height calc(100% - 40px)
+.mk-window[data-darkmode]
+ root(true)
+
+.mk-window:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/pages/favorites.vue b/src/client/app/desktop/views/pages/favorites.vue
new file mode 100644
index 0000000000..d908c08f7c
--- /dev/null
+++ b/src/client/app/desktop/views/pages/favorites.vue
@@ -0,0 +1,73 @@
+<template>
+<mk-ui>
+ <main v-if="!fetching">
+ <template v-for="favorite in favorites">
+ <mk-note-detail :note="favorite.note" :key="favorite.note.id"/>
+ </template>
+ <a v-if="existMore" @click="more">さらに読み込む</a>
+ </main>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import Progress from '../../../common/scripts/loading';
+
+export default Vue.extend({
+ data() {
+ return {
+ fetching: true,
+ favorites: [],
+ existMore: false,
+ moreFetching: false
+ };
+ },
+ created() {
+ this.fetch();
+ },
+ methods: {
+ fetch() {
+ Progress.start();
+ this.fetching = true;
+
+ (this as any).api('i/favorites', {
+ limit: 11
+ }).then(favorites => {
+ if (favorites.length == 11) {
+ this.existMore = true;
+ favorites.pop();
+ }
+
+ this.favorites = favorites;
+ this.fetching = false;
+
+ Progress.done();
+ });
+ },
+ more() {
+ this.moreFetching = true;
+ (this as any).api('i/favorites', {
+ limit: 11,
+ maxId: this.favorites[this.favorites.length - 1].id
+ }).then(favorites => {
+ if (favorites.length == 11) {
+ this.existMore = true;
+ favorites.pop();
+ } else {
+ this.existMore = false;
+ }
+
+ this.favorites = this.favorites.concat(favorites);
+ this.moreFetching = false;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+main
+ margin 0 auto
+ padding 16px
+ max-width 700px
+</style>
diff --git a/src/client/app/desktop/views/pages/note.vue b/src/client/app/desktop/views/pages/note.vue
index e92b0ff105..8502dd3d58 100644
--- a/src/client/app/desktop/views/pages/note.vue
+++ b/src/client/app/desktop/views/pages/note.vue
@@ -1,9 +1,11 @@
<template>
<mk-ui>
<main v-if="!fetching">
- <a v-if="note.next" :href="note.next">%fa:angle-up%%i18n:@next%</a>
<mk-note-detail :note="note"/>
- <a v-if="note.prev" :href="note.prev">%fa:angle-down%%i18n:@prev%</a>
+ <footer>
+ <router-link v-if="note.next" :to="note.next">%fa:angle-left% %i18n:@next%</router-link>
+ <router-link v-if="note.prev" :to="note.prev">%i18n:@prev% %fa:angle-right%</router-link>
+ </footer>
</main>
</mk-ui>
</template>
@@ -48,17 +50,12 @@ main
padding 16px
text-align center
- > a
- display inline-block
+ > footer
+ margin-top 16px
- &:first-child
- margin-bottom 4px
-
- &:last-child
- margin-top 4px
-
- > [data-fa]
- margin-right 4px
+ > a
+ display inline-block
+ margin 0 16px
> .mk-note-detail
margin 0 auto
diff --git a/src/client/app/desktop/views/pages/search.vue b/src/client/app/desktop/views/pages/search.vue
index 698154e667..67e1e3bfe0 100644
--- a/src/client/app/desktop/views/pages/search.vue
+++ b/src/client/app/desktop/views/pages/search.vue
@@ -114,7 +114,7 @@ export default Vue.extend({
.notes
max-width 600px
margin 0 auto
- border solid 1px rgba(0, 0, 0, 0.075)
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
overflow hidden
diff --git a/src/client/app/desktop/views/pages/user-list.users.vue b/src/client/app/desktop/views/pages/user-list.users.vue
new file mode 100644
index 0000000000..4236cdbb14
--- /dev/null
+++ b/src/client/app/desktop/views/pages/user-list.users.vue
@@ -0,0 +1,124 @@
+<template>
+<div>
+ <mk-widget-container>
+ <template slot="header">%fa:users% ユーザー</template>
+ <button slot="func" title="ユーザーを追加" @click="add">%fa:plus%</button>
+
+ <div data-id="d0b63759-a822-4556-a5ce-373ab966e08a">
+ <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw% %i18n:common.loading%<mk-ellipsis/></p>
+ <template v-else-if="users.length != 0">
+ <div class="user" v-for="_user in users">
+ <mk-avatar class="avatar" :user="_user"/>
+ <div class="body">
+ <router-link class="name" :to="_user | userPage" v-user-preview="_user.id">{{ _user | userName }}</router-link>
+ <p class="username">@{{ _user | acct }}</p>
+ </div>
+ </div>
+ </template>
+ <p class="empty" v-else>%i18n:@no-one%</p>
+ </div>
+ </mk-widget-container>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+export default Vue.extend({
+ props: {
+ list: {
+ type: Object,
+ required: true
+ }
+ },
+ data() {
+ return {
+ fetching: true,
+ users: []
+ };
+ },
+ mounted() {
+ (this as any).api('users/show', {
+ userIds: this.list.userIds
+ }).then(users => {
+ this.users = users;
+ this.fetching = false;
+ });
+ },
+ methods: {
+ add() {
+ (this as any).apis.input({
+ title: 'ユーザー名',
+ }).then(async username => {
+ const user = await (this as any).api('users/show', {
+ username
+ });
+
+ (this as any).api('users/lists/push', {
+ listId: this.list.id,
+ userId: user.id
+ });
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+root(isDark)
+ > .user
+ padding 16px
+ border-bottom solid 1px isDark ? #1c2023 : #eee
+
+ &:last-child
+ border-bottom none
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .avatar
+ display block
+ float left
+ margin 0 12px 0 0
+ width 42px
+ height 42px
+ border-radius 8px
+
+ > .body
+ float left
+ width calc(100% - 54px)
+
+ > .name
+ margin 0
+ font-size 16px
+ line-height 24px
+ color isDark ? #fff : #555
+
+ > .username
+ display block
+ margin 0
+ font-size 15px
+ line-height 16px
+ color isDark ? #606984 : #ccc
+
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > .fetching
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+[data-id="d0b63759-a822-4556-a5ce-373ab966e08a"][data-darkmode]
+ root(true)
+
+[data-id="d0b63759-a822-4556-a5ce-373ab966e08a"]:not([data-darkmode])
+ root(false)
+
+</style>
diff --git a/src/client/app/desktop/views/pages/user-list.vue b/src/client/app/desktop/views/pages/user-list.vue
new file mode 100644
index 0000000000..2241b84e5e
--- /dev/null
+++ b/src/client/app/desktop/views/pages/user-list.vue
@@ -0,0 +1,71 @@
+<template>
+<mk-ui>
+ <div v-if="!fetching" data-id="02010e15-cc48-4245-8636-16078a9b623c">
+ <div>
+ <div><h1>{{ list.title }}</h1></div>
+ <x-users :list="list"/>
+ </div>
+ <main>
+ <mk-user-list-timeline :list="list"/>
+ </main>
+ </div>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XUsers from './user-list.users.vue';
+
+export default Vue.extend({
+ components: {
+ XUsers
+ },
+ data() {
+ return {
+ fetching: true,
+ list: null
+ };
+ },
+ watch: {
+ $route: 'fetch'
+ },
+ mounted() {
+ this.fetch();
+ },
+ methods: {
+ fetch() {
+ this.fetching = true;
+
+ (this as any).api('users/lists/show', {
+ listId: this.$route.params.list
+ }).then(list => {
+ this.list = list;
+ this.fetching = false;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+[data-id="02010e15-cc48-4245-8636-16078a9b623c"]
+ display flex
+ justify-content center
+ margin 0 auto
+ max-width 1200px
+
+ > main
+ > div > div
+ > *:not(:last-child)
+ margin-bottom 16px
+
+ > main
+ padding 16px
+ width calc(100% - 275px * 2)
+
+ > div
+ width 275px
+ margin 0
+ padding 16px 0 16px 16px
+
+</style>
diff --git a/src/client/app/desktop/views/pages/user/user.followers-you-know.vue b/src/client/app/desktop/views/pages/user/user.followers-you-know.vue
index 9ccbc7a310..4c1b91e7a6 100644
--- a/src/client/app/desktop/views/pages/user/user.followers-you-know.vue
+++ b/src/client/app/desktop/views/pages/user/user.followers-you-know.vue
@@ -38,7 +38,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
.followers-you-know
background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
> .title
@@ -49,7 +49,7 @@ export default Vue.extend({
font-size 0.9em
font-weight bold
color #888
- box-shadow 0 1px rgba(0, 0, 0, 0.07)
+ box-shadow 0 1px rgba(#000, 0.07)
> i
margin-right 4px
diff --git a/src/client/app/desktop/views/pages/user/user.friends.vue b/src/client/app/desktop/views/pages/user/user.friends.vue
index 203f936478..4af0f0bca6 100644
--- a/src/client/app/desktop/views/pages/user/user.friends.vue
+++ b/src/client/app/desktop/views/pages/user/user.friends.vue
@@ -4,9 +4,7 @@
<p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:@loading%<mk-ellipsis/></p>
<template v-if="!fetching && users.length != 0">
<div class="user" v-for="friend in users">
- <router-link class="avatar-anchor" :to="friend | userPage">
- <img class="avatar" :src="`${friend.avatarUrl}?thumbnail&size=42`" alt="" v-user-preview="friend.id"/>
- </router-link>
+ <mk-avatar class="avatar" :user="friend"/>
<div class="body">
<router-link class="name" :to="friend | userPage" v-user-preview="friend.id">{{ friend.name }}</router-link>
<p class="username">@{{ friend | acct }}</p>
@@ -44,7 +42,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
.friends
background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
> .title
@@ -55,7 +53,7 @@ export default Vue.extend({
font-size 0.9em
font-weight bold
color #888
- box-shadow 0 1px rgba(0, 0, 0, 0.07)
+ box-shadow 0 1px rgba(#000, 0.07)
> i
margin-right 4px
@@ -82,18 +80,13 @@ export default Vue.extend({
display block
clear both
- > .avatar-anchor
+ > .avatar
display block
float left
margin 0 12px 0 0
-
- > .avatar
- display block
- width 42px
- height 42px
- margin 0
- border-radius 8px
- vertical-align bottom
+ width 42px
+ height 42px
+ border-radius 8px
> .body
float left
diff --git a/src/client/app/desktop/views/pages/user/user.header.vue b/src/client/app/desktop/views/pages/user/user.header.vue
index 7a0672d3d7..60dc15b15d 100644
--- a/src/client/app/desktop/views/pages/user/user.header.vue
+++ b/src/client/app/desktop/views/pages/user/user.header.vue
@@ -1,12 +1,13 @@
<template>
<div class="header" :data-is-dark-background="user.bannerUrl != null">
- <div class="is-remote" v-if="user.host != null"><p>%fa:exclamation-triangle% %i18n:@is-remote% <a :href="user.url || user.uri" target="_blank">%i18n:@view-remote%</a></p></div>
- <div class="banner-container" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl}?thumbnail&size=2048)` : ''">
- <div class="banner" ref="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl}?thumbnail&size=2048)` : ''" @click="onBannerClick"></div>
+ <div class="is-suspended" v-if="user.isSuspended"><p>%fa:exclamation-triangle% %i18n:@is-suspended%</p></div>
+ <div class="is-remote" v-if="user.host != null"><p>%fa:exclamation-triangle% %i18n:@is-remote%<a :href="user.url || user.uri" target="_blank">%i18n:@view-remote%</a></p></div>
+ <div class="banner-container" :style="style">
+ <div class="banner" ref="banner" :style="style" @click="onBannerClick"></div>
+ <div class="fade"></div>
</div>
- <div class="fade"></div>
<div class="container">
- <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=150`" alt="avatar"/>
+ <mk-avatar class="avatar" :user="user" :disable-preview="true"/>
<div class="title">
<p class="name">{{ user | userName }}</p>
<p class="username">@{{ user | acct }}</p>
@@ -24,6 +25,15 @@ import Vue from 'vue';
export default Vue.extend({
props: ['user'],
+ computed: {
+ style(): any {
+ if (this.user.bannerUrl == null) return {};
+ return {
+ backgroundColor: this.user.bannerColor ? `rgb(${ this.user.bannerColor.join(',') })` : null,
+ backgroundImage: `url(${ this.user.bannerUrl })`
+ };
+ }
+ },
mounted() {
if (this.user.bannerUrl) {
window.addEventListener('load', this.onScroll);
@@ -67,21 +77,27 @@ export default Vue.extend({
@import '~const.styl'
.header
- $banner-height = 320px
$footer-height = 58px
overflow hidden
background #f7f7f7
- box-shadow 0 1px 1px rgba(0, 0, 0, 0.075)
+ box-shadow 0 1px 1px rgba(#000, 0.075)
+ > .is-suspended
> .is-remote
- padding 16px
- color #573c08
- background #fff0db
+ &.is-suspended
+ color #570808
+ background #ffdbdb
+
+ &.is-remote
+ color #573c08
+ background #fff0db
> p
margin 0 auto
- max-width 1024px
+ padding 14px 16px
+ max-width 1200px
+ font-size 14px
> a
font-weight bold
@@ -91,8 +107,8 @@ export default Vue.extend({
> .banner
background-color #383838
- > .fade
- background linear-gradient(transparent, rgba(0, 0, 0, 0.7))
+ > .fade
+ background linear-gradient(transparent, rgba(#000, 0.7))
> .container
> .title
@@ -102,7 +118,7 @@ export default Vue.extend({
text-shadow 0 0 8px #000
> .banner-container
- height $banner-height
+ height 320px
overflow hidden
background-size cover
background-position center
@@ -113,14 +129,12 @@ export default Vue.extend({
background-size cover
background-position center
- > .fade
- $fade-hight = 78px
-
- position absolute
- top ($banner-height - $fade-hight)
- left 0
- width 100%
- height $fade-hight
+ > .fade
+ position absolute
+ bottom 0
+ left 0
+ width 100%
+ height 78px
> .container
max-width 1200px
@@ -134,10 +148,9 @@ export default Vue.extend({
z-index 2
width 160px
height 160px
- margin 0
border solid 3px #fff
border-radius 8px
- box-shadow 1px 1px 3px rgba(0, 0, 0, 0.2)
+ box-shadow 1px 1px 3px rgba(#000, 0.2)
> .title
position absolute
diff --git a/src/client/app/desktop/views/pages/user/user.home.vue b/src/client/app/desktop/views/pages/user/user.home.vue
index 7ca520ea7f..6b242a6129 100644
--- a/src/client/app/desktop/views/pages/user/user.home.vue
+++ b/src/client/app/desktop/views/pages/user/user.home.vue
@@ -65,7 +65,7 @@ export default Vue.extend({
width calc(100% - 275px * 2)
> .timeline
- border solid 1px rgba(0, 0, 0, 0.075)
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
> div
@@ -91,7 +91,7 @@ export default Vue.extend({
font-size 12px
color #aaa
background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
a
diff --git a/src/client/app/desktop/views/pages/user/user.photos.vue b/src/client/app/desktop/views/pages/user/user.photos.vue
index 9f749d5cc9..01c4c7b31e 100644
--- a/src/client/app/desktop/views/pages/user/user.photos.vue
+++ b/src/client/app/desktop/views/pages/user/user.photos.vue
@@ -41,7 +41,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
.photos
background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
> .title
@@ -52,7 +52,7 @@ export default Vue.extend({
font-size 0.9em
font-weight bold
color #888
- box-shadow 0 1px rgba(0, 0, 0, 0.07)
+ box-shadow 0 1px rgba(#000, 0.07)
> i
margin-right 4px
diff --git a/src/client/app/desktop/views/pages/user/user.profile.vue b/src/client/app/desktop/views/pages/user/user.profile.vue
index 72750e1b3d..29e49f36a6 100644
--- a/src/client/app/desktop/views/pages/user/user.profile.vue
+++ b/src/client/app/desktop/views/pages/user/user.profile.vue
@@ -3,8 +3,17 @@
<div class="friend-form" v-if="os.isSignedIn && os.i.id != user.id">
<mk-follow-button :user="user" size="big"/>
<p class="followed" v-if="user.isFollowed">%i18n:@follows-you%</p>
- <p v-if="user.isMuted">%i18n:@muted% <a @click="unmute">%i18n:@unmute%</a></p>
- <p v-if="!user.isMuted"><a @click="mute">%i18n:@mute%</a></p>
+ <p class="stalk" v-if="user.isFollowing">
+ <span v-if="user.isStalking">%i18n:@stalking% <a @click="unstalk">%fa:meh% %i18n:@unstalk%</a></span>
+ <span v-if="!user.isStalking"><a @click="stalk">%fa:user-secret% %i18n:@stalk%</a></span>
+ </p>
+ </div>
+ <div class="action-form">
+ <button class="mute ui" @click="user.isMuted ? unmute() : mute()">
+ <span v-if="user.isMuted">%fa:eye% %i18n:@unmute%</span>
+ <span v-if="!user.isMuted">%fa:eye-slash% %i18n:@mute%</span>
+ </button>
+ <button class="mute ui" @click="list">%fa:list% リストに追加</button>
</div>
<div class="description" v-if="user.description">{{ user.description }}</div>
<div class="birthday" v-if="user.host === null && user.profile.birthday">
@@ -26,6 +35,7 @@ import Vue from 'vue';
import * as age from 's-age';
import MkFollowingWindow from '../../components/following-window.vue';
import MkFollowersWindow from '../../components/followers-window.vue';
+import MkUserListsWindow from '../../components/user-lists-window.vue';
export default Vue.extend({
props: ['user'],
@@ -47,6 +57,26 @@ export default Vue.extend({
});
},
+ stalk() {
+ (this as any).api('following/stalk', {
+ userId: this.user.id
+ }).then(() => {
+ this.user.isStalking = true;
+ }, () => {
+ alert('error');
+ });
+ },
+
+ unstalk() {
+ (this as any).api('following/unstalk', {
+ userId: this.user.id
+ }).then(() => {
+ this.user.isStalking = false;
+ }, () => {
+ alert('error');
+ });
+ },
+
mute() {
(this as any).api('mute/create', {
userId: this.user.id
@@ -65,6 +95,21 @@ export default Vue.extend({
}, () => {
alert('error');
});
+ },
+
+ list() {
+ const w = (this as any).os.new(MkUserListsWindow);
+ w.$once('choosen', async list => {
+ w.close();
+ await (this as any).api('users/lists/push', {
+ listId: list.id,
+ userId: this.user.id
+ });
+ (this as any).apis.dialog({
+ title: 'Done!',
+ text: `${this.user.name}を${list.title}に追加しました。`
+ });
+ });
}
}
});
@@ -73,7 +118,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
.profile
background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
> *:first-child
@@ -81,11 +126,9 @@ export default Vue.extend({
> .friend-form
padding 16px
+ text-align center
border-top solid 1px #eee
- > .mk-big-follow-button
- width 100%
-
> .followed
margin 12px 0 0 0
padding 0
@@ -96,6 +139,20 @@ export default Vue.extend({
background #eefaff
border-radius 4px
+ > .stalk
+ margin 12px 0 0 0
+
+ > .action-form
+ padding 16px
+ text-align center
+ border-top solid 1px #eee
+
+ > *
+ width 100%
+
+ &:not(:last-child)
+ margin-bottom 12px
+
> .description
padding 16px
color #555
diff --git a/src/client/app/desktop/views/pages/user/user.timeline.vue b/src/client/app/desktop/views/pages/user/user.timeline.vue
index 55d6072a9d..9c9840c190 100644
--- a/src/client/app/desktop/views/pages/user/user.timeline.vue
+++ b/src/client/app/desktop/views/pages/user/user.timeline.vue
@@ -1,42 +1,36 @@
<template>
<div class="timeline">
<header>
- <span :data-is-active="mode == 'default'" @click="mode = 'default'">投稿</span>
- <span :data-is-active="mode == 'with-replies'" @click="mode = 'with-replies'">投稿と返信</span>
- <span :data-is-active="mode == 'with-media'" @click="mode = 'with-media'">メディア</span>
+ <span :data-active="mode == 'default'" @click="mode = 'default'">投稿</span>
+ <span :data-active="mode == 'with-replies'" @click="mode = 'with-replies'">投稿と返信</span>
+ <span :data-active="mode == 'with-media'" @click="mode = 'with-media'">メディア</span>
</header>
<div class="loading" v-if="fetching">
<mk-ellipsis-icon/>
</div>
- <p class="empty" v-if="empty">%fa:R comments%このユーザーはまだ何も投稿していないようです。</p>
- <mk-notes ref="timeline" :notes="notes">
- <div slot="footer">
- <template v-if="!moreFetching">%fa:moon%</template>
- <template v-if="moreFetching">%fa:spinner .pulse .fw%</template>
- </div>
+ <mk-notes ref="timeline" :more="existMore ? more : null">
+ <p class="empty" slot="empty">%fa:R comments%このユーザーはまだ何も投稿していないようです。</p>
</mk-notes>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
+
+const fetchLimit = 10;
+
export default Vue.extend({
props: ['user'],
data() {
return {
fetching: true,
moreFetching: false,
+ existMore: false,
mode: 'default',
unreadCount: 0,
- notes: [],
date: null
};
},
- computed: {
- empty(): boolean {
- return this.notes.length == 0;
- }
- },
watch: {
mode() {
this.fetch();
@@ -44,13 +38,11 @@ export default Vue.extend({
},
mounted() {
document.addEventListener('keydown', this.onDocumentKeydown);
- window.addEventListener('scroll', this.onScroll);
this.fetch(() => this.$emit('loaded'));
},
beforeDestroy() {
document.removeEventListener('keydown', this.onDocumentKeydown);
- window.removeEventListener('scroll', this.onScroll);
},
methods: {
onDocumentKeydown(e) {
@@ -61,36 +53,43 @@ export default Vue.extend({
}
},
fetch(cb?) {
- (this as any).api('users/notes', {
- userId: this.user.id,
- untilDate: this.date ? this.date.getTime() : undefined,
- includeReplies: this.mode == 'with-replies',
- withMedia: this.mode == 'with-media'
- }).then(notes => {
- this.notes = notes;
- this.fetching = false;
- if (cb) cb();
- });
+ this.fetching = true;
+ (this.$refs.timeline as any).init(() => new Promise((res, rej) => {
+ (this as any).api('users/notes', {
+ userId: this.user.id,
+ limit: fetchLimit + 1,
+ untilDate: this.date ? this.date.getTime() : undefined,
+ includeReplies: this.mode == 'with-replies',
+ withMedia: this.mode == 'with-media'
+ }).then(notes => {
+ if (notes.length == fetchLimit + 1) {
+ notes.pop();
+ this.existMore = true;
+ }
+ res(notes);
+ this.fetching = false;
+ if (cb) cb();
+ }, rej);
+ }));
},
more() {
- if (this.moreFetching || this.fetching || this.notes.length == 0) return;
this.moreFetching = true;
(this as any).api('users/notes', {
userId: this.user.id,
+ limit: fetchLimit + 1,
includeReplies: this.mode == 'with-replies',
withMedia: this.mode == 'with-media',
- untilId: this.notes[this.notes.length - 1].id
+ untilId: (this.$refs.timeline as any).tail().id
}).then(notes => {
+ if (notes.length == fetchLimit + 1) {
+ notes.pop();
+ } else {
+ this.existMore = false;
+ }
+ notes.forEach(n => (this.$refs.timeline as any).append(n));
this.moreFetching = false;
- this.notes = this.notes.concat(notes);
});
},
- onScroll() {
- const current = window.scrollY + window.innerHeight;
- if (current > document.body.offsetHeight - 16/*遊び*/) {
- this.more();
- }
- },
warp(date) {
this.date = date;
this.fetch();
@@ -115,7 +114,7 @@ export default Vue.extend({
font-size 18px
color #555
- &:not([data-is-active])
+ &:not([data-active])
color $theme-color
cursor pointer
diff --git a/src/client/app/desktop/views/pages/welcome.vue b/src/client/app/desktop/views/pages/welcome.vue
index 93d17b58fe..898b6b2179 100644
--- a/src/client/app/desktop/views/pages/welcome.vue
+++ b/src/client/app/desktop/views/pages/welcome.vue
@@ -8,9 +8,7 @@
<p>ようこそ! <b>Misskey</b>はTwitter風ミニブログSNSです。思ったことや皆と共有したいことを投稿しましょう。タイムラインを見れば、皆の関心事をすぐにチェックすることもできます。<a :href="aboutUrl">詳しく...</a></p>
<p><button class="signup" @click="signup">はじめる</button><button class="signin" @click="signin">ログイン</button></p>
<div class="users">
- <router-link v-for="user in users" :key="user.id" class="avatar-anchor" :to="user | userPage" v-user-preview="user.id">
- <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
- </router-link>
+ <mk-avatar class="avatar" v-for="user in users" :key="user.id" :user="user"/>
</div>
</div>
<div>
@@ -125,7 +123,8 @@ export default Vue.extend({
flex 1
$width = 1000px
- background-image url('/assets/welcome-bg.svg')
+ background linear-gradient(to bottom, #1e1d65, #bd6659)
+ //background-image url('/assets/welcome-bg.svg')
background-size cover
background-position top center
@@ -216,13 +215,9 @@ export default Vue.extend({
> *
display inline-block
margin 4px
-
- > *
- display inline-block
- width 38px
- height 38px
- vertical-align top
- border-radius 6px
+ width 38px
+ height 38px
+ border-radius 6px
> div:last-child
@@ -230,14 +225,14 @@ export default Vue.extend({
width 410px
background #fff
border-radius 8px
- box-shadow 0 0 0 12px rgba(0, 0, 0, 0.1)
+ box-shadow 0 0 0 12px rgba(#000, 0.1)
overflow hidden
> header
z-index 1
padding 12px 16px
color #888d94
- box-shadow 0 1px 0px rgba(0, 0, 0, 0.1)
+ box-shadow 0 1px 0px rgba(#000, 0.1)
> div
position absolute
@@ -309,9 +304,3 @@ export default Vue.extend({
a
color #666
</style>
-
-<style lang="stylus">
-html
-body
- background linear-gradient(to bottom, #1e1d65, #bd6659)
-</style>
diff --git a/src/client/app/desktop/views/widgets/activity.vue b/src/client/app/desktop/views/widgets/activity.vue
index 0bdf4622af..1be87f590c 100644
--- a/src/client/app/desktop/views/widgets/activity.vue
+++ b/src/client/app/desktop/views/widgets/activity.vue
@@ -22,9 +22,11 @@ export default define({
} else {
this.props.design++;
}
+ this.save();
},
viewChanged(view) {
this.props.view = view;
+ this.save();
}
}
});
diff --git a/src/client/app/desktop/views/widgets/channel.vue b/src/client/app/desktop/views/widgets/channel.vue
index 7e96f8ee3d..d21aed40fd 100644
--- a/src/client/app/desktop/views/widgets/channel.vue
+++ b/src/client/app/desktop/views/widgets/channel.vue
@@ -37,6 +37,7 @@ export default define({
methods: {
func() {
this.props.compact = !this.props.compact;
+ this.save();
},
settings() {
const id = window.prompt('チャンネルID');
@@ -61,7 +62,7 @@ export default define({
<style lang="stylus" scoped>
.mkw-channel
background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
overflow hidden
@@ -73,7 +74,7 @@ export default define({
font-size 0.9em
font-weight bold
color #888
- box-shadow 0 1px rgba(0, 0, 0, 0.07)
+ box-shadow 0 1px rgba(#000, 0.07)
> [data-fa]
margin-right 4px
diff --git a/src/client/app/desktop/views/widgets/messaging.vue b/src/client/app/desktop/views/widgets/messaging.vue
index 0f197fb2d7..791d2ff1bb 100644
--- a/src/client/app/desktop/views/widgets/messaging.vue
+++ b/src/client/app/desktop/views/widgets/messaging.vue
@@ -1,13 +1,18 @@
<template>
<div class="mkw-messaging">
- <p class="title" v-if="props.design == 0">%fa:comments%%i18n:@title%</p>
- <mk-messaging ref="index" compact @navigate="navigate"/>
+ <mk-widget-container :show-header="props.design == 0">
+ <template slot="header">%fa:comments%%i18n:@title%</template>
+ <button slot="func" @click="add">%fa:plus%</button>
+
+ <mk-messaging ref="index" compact @navigate="navigate"/>
+ </mk-widget-container>
</div>
</template>
<script lang="ts">
import define from '../../../common/define-widget';
import MkMessagingRoomWindow from '../components/messaging-room-window.vue';
+import MkMessagingWindow from '../components/messaging-window.vue';
export default define({
name: 'messaging',
@@ -21,12 +26,16 @@ export default define({
user: user
});
},
+ add() {
+ (this as any).os.new(MkMessagingWindow);
+ },
func() {
if (this.props.design == 1) {
this.props.design = 0;
} else {
this.props.design++;
}
+ this.save();
}
}
});
@@ -34,25 +43,7 @@ export default define({
<style lang="stylus" scoped>
.mkw-messaging
- overflow hidden
- background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
- border-radius 6px
-
- > .title
- z-index 2
- 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)
-
- > [data-fa]
- margin-right 4px
-
- > .mk-messaging
+ .mk-messaging
max-height 250px
overflow auto
diff --git a/src/client/app/desktop/views/widgets/notifications.vue b/src/client/app/desktop/views/widgets/notifications.vue
index 0c2fa0434d..f75a091480 100644
--- a/src/client/app/desktop/views/widgets/notifications.vue
+++ b/src/client/app/desktop/views/widgets/notifications.vue
@@ -1,10 +1,11 @@
<template>
<div class="mkw-notifications">
- <template v-if="!props.compact">
- <p class="title">%fa:R bell%%i18n:@title%</p>
- <button @click="settings" title="%i18n:@settings%">%fa:cog%</button>
- </template>
- <mk-notifications/>
+ <mk-widget-container :show-header="!props.compact">
+ <template slot="header">%fa:R bell%%i18n:@title%</template>
+ <button slot="func" title="%i18n:@settings%" @click="settings">%fa:cog%</button>
+
+ <mk-notifications :class="$style.notifications"/>
+ </mk-widget-container>
</div>
</template>
@@ -22,49 +23,15 @@ export default define({
},
func() {
this.props.compact = !this.props.compact;
+ this.save();
}
}
});
</script>
-<style lang="stylus" scoped>
-.mkw-notifications
- background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
- border-radius 6px
-
- > .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)
-
- > [data-fa]
- 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
+<style lang="stylus" module>
+.notifications
+ max-height 300px
+ overflow auto
</style>
diff --git a/src/client/app/desktop/views/widgets/polls.vue b/src/client/app/desktop/views/widgets/polls.vue
index 6cb1192c24..36fcc20636 100644
--- a/src/client/app/desktop/views/widgets/polls.vue
+++ b/src/client/app/desktop/views/widgets/polls.vue
@@ -1,16 +1,19 @@
<template>
<div class="mkw-polls">
- <template v-if="!props.compact">
- <p class="title">%fa:chart-pie%%i18n:@title%</p>
- <button @click="fetch" title="%i18n:@refresh%">%fa:sync%</button>
- </template>
- <div class="poll" v-if="!fetching && poll != null">
- <p v-if="poll.text"><router-link to="poll | notePage">{{ poll.text }}</router-link></p>
- <p v-if="!poll.text"><router-link to="poll | notePage">%fa:link%</router-link></p>
- <mk-poll :note="poll"/>
- </div>
- <p class="empty" v-if="!fetching && poll == null">%i18n:@nothing%</p>
- <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+ <mk-widget-container :show-header="!props.compact">
+ <template slot="header">%fa:chart-pie%%i18n:@title%</template>
+ <button slot="func" title="%i18n:@refresh%" @click="fetch">%fa:sync%</button>
+
+ <div class="mkw-polls--body" :data-darkmode="_darkmode_">
+ <div class="poll" v-if="!fetching && poll != null">
+ <p v-if="poll.text"><router-link to="poll | notePage">{{ poll.text }}</router-link></p>
+ <p v-if="!poll.text"><router-link to="poll | notePage">%fa:link%</router-link></p>
+ <mk-poll :note="poll"/>
+ </div>
+ <p class="empty" v-if="!fetching && poll == null">%i18n:@nothing%</p>
+ <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+ </div>
+ </mk-widget-container>
</div>
</template>
@@ -36,6 +39,7 @@ export default define({
methods: {
func() {
this.props.compact = !this.props.compact;
+ this.save();
},
fetch() {
this.fetching = true;
@@ -60,44 +64,11 @@ export default define({
</script>
<style lang="stylus" scoped>
-.mkw-polls
- background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
- border-radius 6px
-
- > .title
- margin 0
- padding 0 16px
- line-height 42px
- font-size 0.9em
- font-weight bold
- color #888
- border-bottom solid 1px #eee
-
- > [data-fa]
- 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
-
+root(isDark)
> .poll
padding 16px
font-size 12px
- color #555
+ color isDark ? #9ea4ad : #555
> p
margin 0 0 8px 0
@@ -120,4 +91,10 @@ export default define({
> [data-fa]
margin-right 4px
+.mkw-polls--body[data-darkmode]
+ root(true)
+
+.mkw-polls--body:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/desktop/views/widgets/post-form.vue b/src/client/app/desktop/views/widgets/post-form.vue
index 627943588f..69b21ad37a 100644
--- a/src/client/app/desktop/views/widgets/post-form.vue
+++ b/src/client/app/desktop/views/widgets/post-form.vue
@@ -29,6 +29,7 @@ export default define({
} else {
this.props.design++;
}
+ this.save();
},
onKeydown(e) {
if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post();
@@ -59,7 +60,7 @@ export default define({
.mkw-post-form
background #fff
overflow hidden
- border solid 1px rgba(0, 0, 0, 0.075)
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
> .title
@@ -70,7 +71,7 @@ export default define({
font-size 0.9em
font-weight bold
color #888
- box-shadow 0 1px rgba(0, 0, 0, 0.07)
+ box-shadow 0 1px rgba(#000, 0.07)
> [data-fa]
margin-right 4px
diff --git a/src/client/app/desktop/views/widgets/profile.vue b/src/client/app/desktop/views/widgets/profile.vue
index 1b4b11de3c..3b01ed034d 100644
--- a/src/client/app/desktop/views/widgets/profile.vue
+++ b/src/client/app/desktop/views/widgets/profile.vue
@@ -8,12 +8,9 @@
title="クリックでバナー編集"
@click="os.apis.updateBanner"
></div>
- <img class="avatar"
- :src="`${os.i.avatarUrl}?thumbnail&size=96`"
+ <mk-avatar class="avatar" :user="os.i"
@click="os.apis.updateAvatar"
- alt="avatar"
title="クリックでアバター編集"
- v-user-preview="os.i.id"
/>
<router-link class="name" :to="os.i | userPage">{{ os.i | userName }}</router-link>
<p class="username">@{{ os.i | acct }}</p>
@@ -36,16 +33,17 @@ export default define({
} else {
this.props.design++;
}
+ this.save();
}
}
});
</script>
<style lang="stylus" scoped>
-.mkw-profile
+root(isDark)
overflow hidden
- background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
+ background isDark ? #282c37 : #fff
+ border solid 1px rgba(#000, 0.075)
border-radius 6px
&[data-compact]
@@ -54,14 +52,14 @@ export default define({
display block
width 100%
height 100%
- background rgba(0, 0, 0, 0.5)
+ background rgba(#000, 0.5)
> .avatar
top ((100px - 58px) / 2)
left ((100px - 58px) / 2)
border none
border-radius 100%
- box-shadow 0 0 16px rgba(0, 0, 0, 0.5)
+ box-shadow 0 0 16px rgba(#000, 0.5)
> .name
position absolute
@@ -70,7 +68,7 @@ export default define({
margin 0
line-height 100px
color #fff
- text-shadow 0 0 8px rgba(0, 0, 0, 0.5)
+ text-shadow 0 0 8px rgba(#000, 0.5)
> .username
display none
@@ -91,7 +89,7 @@ export default define({
> .banner
height 100px
- background-color #f5f5f5
+ background-color isDark ? #303e4a : #f5f5f5
background-size cover
background-position center
cursor pointer
@@ -103,10 +101,8 @@ export default define({
left 16px
width 58px
height 58px
- margin 0
- border solid 3px #fff
+ border solid 3px isDark ? #282c37 : #fff
border-radius 8px
- vertical-align bottom
cursor pointer
> .name
@@ -114,13 +110,19 @@ export default define({
margin 10px 0 0 84px
line-height 16px
font-weight bold
- color #555
+ color isDark ? #fff : #555
> .username
display block
margin 4px 0 8px 84px
line-height 16px
font-size 0.9em
- color #999
+ color isDark ? #606984 : #999
+
+.mkw-profile[data-darkmode]
+ root(true)
+
+.mkw-profile:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/desktop/views/widgets/timemachine.vue b/src/client/app/desktop/views/widgets/timemachine.vue
index 6db3b14c62..22a4120403 100644
--- a/src/client/app/desktop/views/widgets/timemachine.vue
+++ b/src/client/app/desktop/views/widgets/timemachine.vue
@@ -22,6 +22,7 @@ export default define({
} else {
this.props.design++;
}
+ this.save();
}
}
});
diff --git a/src/client/app/desktop/views/widgets/trends.vue b/src/client/app/desktop/views/widgets/trends.vue
index fccda3f9d0..c33bf2f2f2 100644
--- a/src/client/app/desktop/views/widgets/trends.vue
+++ b/src/client/app/desktop/views/widgets/trends.vue
@@ -1,15 +1,18 @@
<template>
<div class="mkw-trends">
- <template v-if="!props.compact">
- <p class="title">%fa:fire%%i18n:@title%</p>
- <button @click="fetch" title="%i18n:@refresh%">%fa:sync%</button>
- </template>
- <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
- <div class="note" v-else-if="note != null">
- <p class="text"><router-link :to="note | notePage">{{ note.text }}</router-link></p>
- <p class="author">―<router-link :to="note.user | userPage">@{{ note.user | acct }}</router-link></p>
- </div>
- <p class="empty" v-else>%i18n:@nothing%</p>
+ <mk-widget-container :show-header="!props.compact">
+ <template slot="header">%fa:fire%%i18n:@title%</template>
+ <button slot="func" title="%i18n:@refresh%" @click="fetch">%fa:sync%</button>
+
+ <div class="mkw-trends--body">
+ <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+ <div class="note" v-else-if="note != null">
+ <p class="text"><router-link :to="note | notePage">{{ note.text }}</router-link></p>
+ <p class="author">―<router-link :to="note.user | userPage">@{{ note.user | acct }}</router-link></p>
+ </div>
+ <p class="empty" v-else>%i18n:@nothing%</p>
+ </div>
+ </mk-widget-container>
</div>
</template>
@@ -35,6 +38,7 @@ export default define({
methods: {
func() {
this.props.compact = !this.props.compact;
+ this.save();
},
fetch() {
this.fetching = true;
@@ -63,67 +67,41 @@ export default define({
</script>
<style lang="stylus" scoped>
-.mkw-trends
- background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
- border-radius 6px
-
- > .title
- margin 0
- padding 0 16px
- line-height 42px
- font-size 0.9em
- font-weight bold
- color #888
- border-bottom solid 1px #eee
+root(isDark)
+ .mkw-trends--body
+ > .note
+ padding 16px
+ font-size 12px
+ font-style oblique
+ color #555
- > [data-fa]
- margin-right 4px
+ > p
+ margin 0
- > button
- position absolute
- z-index 2
- top 0
- right 0
- padding 0
- width 42px
- font-size 0.9em
- line-height 42px
- color #ccc
+ > .text,
+ > .author
+ > a
+ color inherit
- &:hover
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
color #aaa
- &:active
- color #999
-
- > .note
- padding 16px
- font-size 12px
- font-style oblique
- color #555
-
- > p
+ > .fetching
margin 0
+ padding 16px
+ text-align center
+ color #aaa
- > .text,
- > .author
- > a
- color inherit
-
- > .empty
- margin 0
- padding 16px
- text-align center
- color #aaa
+ > [data-fa]
+ margin-right 4px
- > .fetching
- margin 0
- padding 16px
- text-align center
- color #aaa
+.mkw-trends[data-darkmode]
+ root(true)
- > [data-fa]
- margin-right 4px
+.mkw-trends:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/desktop/views/widgets/users.vue b/src/client/app/desktop/views/widgets/users.vue
index 0955ebbd71..328fa56697 100644
--- a/src/client/app/desktop/views/widgets/users.vue
+++ b/src/client/app/desktop/views/widgets/users.vue
@@ -1,23 +1,24 @@
<template>
<div class="mkw-users">
- <template v-if="!props.compact">
- <p class="title">%fa:users%%i18n:@title%</p>
- <button @click="refresh" title="%i18n:@refresh%">%fa:sync%</button>
- </template>
- <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
- <template v-else-if="users.length != 0">
- <div class="user" v-for="_user in users">
- <router-link class="avatar-anchor" :to="_user | userPage">
- <img class="avatar" :src="`${_user.avatarUrl}?thumbnail&size=42`" alt="" v-user-preview="_user.id"/>
- </router-link>
- <div class="body">
- <router-link class="name" :to="_user | userPage" v-user-preview="_user.id">{{ _user | userName }}</router-link>
- <p class="username">@{{ _user | acct }}</p>
- </div>
- <mk-follow-button :user="_user"/>
+ <mk-widget-container :show-header="!props.compact">
+ <template slot="header">%fa:users%%i18n:@title%</template>
+ <button slot="func" title="%i18n:@refresh%" @click="refresh">%fa:sync%</button>
+
+ <div class="mkw-users--body">
+ <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+ <template v-else-if="users.length != 0">
+ <div class="user" v-for="_user in users">
+ <mk-avatar class="avatar" :user="_user"/>
+ <div class="body">
+ <router-link class="name" :to="_user | userPage" v-user-preview="_user.id">{{ _user | userName }}</router-link>
+ <p class="username">@{{ _user | acct }}</p>
+ </div>
+ <mk-follow-button :user="_user"/>
+ </div>
+ </template>
+ <p class="empty" v-else>%i18n:@no-one%</p>
</div>
- </template>
- <p class="empty" v-else>%i18n:@no-one%</p>
+ </mk-widget-container>
</div>
</template>
@@ -45,6 +46,7 @@ export default define({
methods: {
func() {
this.props.compact = !this.props.compact;
+ this.save();
},
fetch() {
this.fetching = true;
@@ -71,100 +73,69 @@ export default define({
</script>
<style lang="stylus" scoped>
-.mkw-users
- background #fff
- border solid 1px rgba(0, 0, 0, 0.075)
- border-radius 6px
-
- > .title
- margin 0
- padding 0 16px
- line-height 42px
- font-size 0.9em
- font-weight bold
- color #888
- border-bottom solid 1px #eee
-
- > [data-fa]
- 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
+root(isDark)
+ .mkw-users--body
+ > .user
+ padding 16px
+ border-bottom solid 1px isDark ? #1c2023 : #eee
- &:last-child
- border-bottom none
+ &:last-child
+ border-bottom none
- &:after
- content ""
- display block
- clear both
-
- > .avatar-anchor
- display block
- float left
- margin 0 12px 0 0
+ &:after
+ content ""
+ display block
+ clear both
> .avatar
display block
+ float left
+ margin 0 12px 0 0
width 42px
height 42px
- margin 0
border-radius 8px
- vertical-align bottom
- > .body
- float left
- width calc(100% - 54px)
+ > .body
+ float left
+ width calc(100% - 54px)
- > .name
- margin 0
- font-size 16px
- line-height 24px
- color #555
+ > .name
+ margin 0
+ font-size 16px
+ line-height 24px
+ color isDark ? #fff : #555
- > .username
- display block
- margin 0
- font-size 15px
- line-height 16px
- color #ccc
+ > .username
+ display block
+ margin 0
+ font-size 15px
+ line-height 16px
+ color isDark ? #606984 : #ccc
+
+ > .mk-follow-button
+ position absolute
+ top 16px
+ right 16px
- > .mk-follow-button
- position absolute
- top 16px
- right 16px
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > .fetching
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
- > .empty
- margin 0
- padding 16px
- text-align center
- color #aaa
+ > [data-fa]
+ margin-right 4px
- > .fetching
- margin 0
- padding 16px
- text-align center
- color #aaa
+.mkw-users[data-darkmode]
+ root(true)
- > [data-fa]
- margin-right 4px
+.mkw-users:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/init.css b/src/client/app/init.css
index 2587f63943..fa59195f71 100644
--- a/src/client/app/init.css
+++ b/src/client/app/init.css
@@ -56,6 +56,13 @@ body > noscript {
animation-delay: 0.32s;
}
+html[data-darkmode] #ini {
+ background: #191b22;
+}
+ html[data-darkmode] #ini > p {
+ color: #fff;
+ }
+
@keyframes ini {
0%, 80%, 100% {
opacity: 1;
diff --git a/src/client/app/init.ts b/src/client/app/init.ts
index 990933ec0e..4908b73b23 100644
--- a/src/client/app/init.ts
+++ b/src/client/app/init.ts
@@ -3,6 +3,7 @@
*/
import Vue from 'vue';
+import Vuex from 'vuex';
import VueRouter from 'vue-router';
import VModal from 'vue-js-modal';
import * as TreeView from 'vue-json-tree-view';
@@ -13,7 +14,7 @@ import ElementLocaleJa from 'element-ui/lib/locale/lang/ja';
import App from './app.vue';
import checkForUpdate from './common/scripts/check-for-update';
-import MiOS, { API } from './common/mios';
+import MiOS, { API } from './mios';
import { version, codename, lang } from './config';
let elementLocale;
@@ -23,6 +24,7 @@ switch (lang) {
default: elementLocale = ElementLocaleEn; break;
}
+Vue.use(Vuex);
Vue.use(VueRouter);
Vue.use(VModal);
Vue.use(TreeView);
@@ -47,6 +49,48 @@ Vue.mixin({
}
});
+// Dark/Light
+const bus = new Vue();
+Vue.mixin({
+ data() {
+ return {
+ _darkmode_: localStorage.getItem('darkmode') == 'true'
+ };
+ },
+ beforeCreate() {
+ // なぜか警告が出るので
+ this._darkmode_ = localStorage.getItem('darkmode') == 'true';
+ },
+ beforeDestroy() {
+ bus.$off('updated', this._onDarkmodeUpdated_);
+ },
+ mounted() {
+ this._onDarkmodeUpdated_(this._darkmode_);
+ bus.$on('updated', this._onDarkmodeUpdated_);
+ },
+ methods: {
+ _updateDarkmode_(v) {
+ localStorage.setItem('darkmode', v.toString());
+ if (v) {
+ document.documentElement.setAttribute('data-darkmode', 'true');
+ } else {
+ document.documentElement.removeAttribute('data-darkmode');
+ }
+ bus.$emit('updated', v);
+ },
+ _onDarkmodeUpdated_(v) {
+ if (!this.$el || !this.$el.setAttribute) return;
+ if (v) {
+ this.$el.setAttribute('data-darkmode', 'true');
+ } else {
+ this.$el.removeAttribute('data-darkmode');
+ }
+ this._darkmode_ = v;
+ this.$forceUpdate();
+ }
+ }
+});
+
/**
* APP ENTRY POINT!
*/
@@ -102,21 +146,15 @@ export default (callback: (launch: (router: VueRouter, api?: (os: MiOS) => API)
return {
os,
api: os.api,
- apis: os.apis
+ apis: os.apis,
+ clientSettings: os.store.state.settings.data
};
}
});
const app = new Vue({
+ store: os.store,
router,
- created() {
- this.$watch('os.i', i => {
- // キャッシュ更新
- localStorage.setItem('me', JSON.stringify(i));
- }, {
- deep: true
- });
- },
render: createEl => createEl(App)
});
diff --git a/src/client/app/common/mios.ts b/src/client/app/mios.ts
index 6d6d6b3e68..2373b0d8d2 100644
--- a/src/client/app/common/mios.ts
+++ b/src/client/app/mios.ts
@@ -3,18 +3,19 @@ import { EventEmitter } from 'eventemitter3';
import * as merge from 'object-assign-deep';
import * as uuid from 'uuid';
-import { hostname, apiUrl, swPublickey, version, lang, googleMapsApiKey } from '../config';
-import Progress from './scripts/loading';
-import Connection from './scripts/streaming/stream';
-import { HomeStreamManager } from './scripts/streaming/home';
-import { DriveStreamManager } from './scripts/streaming/drive';
-import { ServerStreamManager } from './scripts/streaming/server';
-import { MessagingIndexStreamManager } from './scripts/streaming/messaging-index';
-import { OthelloStreamManager } from './scripts/streaming/othello';
+import initStore from './store';
+import { hostname, apiUrl, swPublickey, version, lang, googleMapsApiKey } from './config';
+import Progress from './common/scripts/loading';
+import Connection from './common/scripts/streaming/stream';
+import { HomeStreamManager } from './common/scripts/streaming/home';
+import { DriveStreamManager } from './common/scripts/streaming/drive';
+import { ServerStreamManager } from './common/scripts/streaming/server';
+import { MessagingIndexStreamManager } from './common/scripts/streaming/messaging-index';
+import { OthelloStreamManager } from './common/scripts/streaming/othello';
-import Err from '../common/views/components/connect-failed.vue';
-import { LocalTimelineStreamManager } from './scripts/streaming/local-timeline';
-import { GlobalTimelineStreamManager } from './scripts/streaming/global-timeline';
+import Err from './common/views/components/connect-failed.vue';
+import { LocalTimelineStreamManager } from './common/scripts/streaming/local-timeline';
+import { GlobalTimelineStreamManager } from './common/scripts/streaming/global-timeline';
//#region api requests
let spinner = null;
@@ -78,6 +79,7 @@ export default class MiOS extends EventEmitter {
propsData: props
}).$mount();
document.body.appendChild(w.$el);
+ return w;
}
/**
@@ -106,6 +108,8 @@ export default class MiOS extends EventEmitter {
return localStorage.getItem('enableSounds') == 'true';
}
+ public store: ReturnType<typeof initStore>;
+
public apis: API;
/**
@@ -221,8 +225,14 @@ export default class MiOS extends EventEmitter {
console.error.apply(null, args);
}
+ public bakeMe() {
+ // ローカルストレージにキャッシュ
+ localStorage.setItem('me', JSON.stringify(this.i));
+ }
+
public signout() {
localStorage.removeItem('me');
+ localStorage.removeItem('settings');
document.cookie = `i=; domain=${hostname}; expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
location.href = '/';
}
@@ -232,6 +242,8 @@ export default class MiOS extends EventEmitter {
* @param callback A function that call when initialized
*/
public async init(callback) {
+ this.store = initStore(this);
+
//#region Init stream managers
this.streams.serverStream = new ServerStreamManager(this);
@@ -296,21 +308,11 @@ export default class MiOS extends EventEmitter {
// フェッチが完了したとき
const fetched = me => {
- if (me) {
- // デフォルトの設定をマージ
- me.clientSettings = Object.assign({
- fetchOnScroll: true,
- showMaps: true,
- showPostFormOnTopOfTl: false,
- gradientWindowHeader: false
- }, me.clientSettings);
-
- // ローカルストレージにキャッシュ
- localStorage.setItem('me', JSON.stringify(me));
- }
-
this.i = me;
+ // ローカルストレージにキャッシュ
+ this.bakeMe();
+
this.emit('signedin');
// Finish init
@@ -327,6 +329,14 @@ export default class MiOS extends EventEmitter {
// Get cached account data
const cachedMe = JSON.parse(localStorage.getItem('me'));
+ //#region キャッシュされた設定を復元
+ const cachedSettings = JSON.parse(localStorage.getItem('settings'));
+
+ if (cachedSettings) {
+ this.store.dispatch('settings/merge', cachedSettings);
+ }
+ //#endregion
+
// キャッシュがあったとき
if (cachedMe) {
if (cachedMe.token == null) {
@@ -340,12 +350,23 @@ export default class MiOS extends EventEmitter {
// 後から新鮮なデータをフェッチ
fetchme(cachedMe.token, freshData => {
merge(cachedMe, freshData);
+
+ this.store.dispatch('settings/merge', freshData.clientSettings);
});
} else {
// Get token from cookie
const i = (document.cookie.match(/i=(!\w+)/) || [null, null])[1];
- fetchme(i, fetched);
+ fetchme(i, me => {
+ if (me) {
+ this.store.dispatch('settings/merge', me.clientSettings);
+
+ fetched(me);
+ } else {
+ // Finish init
+ callback();
+ }
+ });
}
}
@@ -450,7 +471,7 @@ export default class MiOS extends EventEmitter {
};
const promise = new Promise((resolve, reject) => {
- const viaStream = this.stream.hasConnection &&
+ const viaStream = this.stream && this.stream.hasConnection &&
(localStorage.getItem('apiViaStream') ? localStorage.getItem('apiViaStream') == 'true' : true);
if (viaStream) {
diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts
index 1de4891973..2e9805e0d0 100644
--- a/src/client/app/mobile/script.ts
+++ b/src/client/app/mobile/script.ts
@@ -55,15 +55,15 @@ init((launch) => {
{ path: '/signup', name: 'signup', component: MkSignup },
{ path: '/i/settings', component: MkSettings },
{ path: '/i/settings/profile', component: MkProfileSetting },
- { path: '/i/notifications', component: MkNotifications },
- { path: '/i/messaging', component: MkMessaging },
+ { path: '/i/notifications', name: 'notifications', component: MkNotifications },
+ { path: '/i/messaging', name: 'messaging', component: MkMessaging },
{ path: '/i/messaging/:user', component: MkMessagingRoom },
- { path: '/i/drive', component: MkDrive },
+ { path: '/i/drive', name: 'drive', component: MkDrive },
{ path: '/i/drive/folder/:folder', component: MkDrive },
{ path: '/i/drive/file/:file', component: MkDrive },
{ path: '/selectdrive', component: MkSelectDrive },
{ path: '/search', component: MkSearch },
- { path: '/othello', component: MkOthello },
+ { path: '/othello', name: 'othello', component: MkOthello },
{ path: '/othello/:game', component: MkOthello },
{ path: '/@:user', component: MkUser },
{ path: '/@:user/followers', component: MkFollowers },
diff --git a/src/client/app/mobile/style.styl b/src/client/app/mobile/style.styl
index 81912a2483..847ae8eec5 100644
--- a/src/client/app/mobile/style.styl
+++ b/src/client/app/mobile/style.styl
@@ -8,6 +8,10 @@
html
height 100%
+ background #ececed
+
+ &[data-darkmode]
+ background #191B22
body
display flex
diff --git a/src/client/app/mobile/views/components/drive-file-chooser.vue b/src/client/app/mobile/views/components/drive-file-chooser.vue
index 41536afbd4..d95d5fa223 100644
--- a/src/client/app/mobile/views/components/drive-file-chooser.vue
+++ b/src/client/app/mobile/views/components/drive-file-chooser.vue
@@ -54,7 +54,7 @@ export default Vue.extend({
width 100%
height 100%
padding 8px
- background rgba(0, 0, 0, 0.2)
+ background rgba(#000, 0.2)
> .body
width 100%
diff --git a/src/client/app/mobile/views/components/drive-folder-chooser.vue b/src/client/app/mobile/views/components/drive-folder-chooser.vue
index bfd8fbda6f..7934fb7816 100644
--- a/src/client/app/mobile/views/components/drive-folder-chooser.vue
+++ b/src/client/app/mobile/views/components/drive-folder-chooser.vue
@@ -38,7 +38,7 @@ export default Vue.extend({
width 100%
height 100%
padding 8px
- background rgba(0, 0, 0, 0.2)
+ background rgba(#000, 0.2)
> .body
width 100%
diff --git a/src/client/app/mobile/views/components/drive.file-detail.vue b/src/client/app/mobile/views/components/drive.file-detail.vue
index c7be7d1879..764822e98c 100644
--- a/src/client/app/mobile/views/components/drive.file-detail.vue
+++ b/src/client/app/mobile/views/components/drive.file-detail.vue
@@ -139,7 +139,7 @@ export default Vue.extend({
max-width 100%
max-height 300px
margin 0 auto
- box-shadow 1px 1px 4px rgba(0, 0, 0, 0.2)
+ box-shadow 1px 1px 4px rgba(#000, 0.2)
> footer
padding 8px 8px 0 8px
@@ -226,7 +226,7 @@ export default Vue.extend({
background-color #767676
background-image none
border-color #444
- box-shadow 0 1px 3px rgba(0, 0, 0, 0.075), inset 0 0 5px rgba(0, 0, 0, 0.2)
+ box-shadow 0 1px 3px rgba(#000, 0.075), inset 0 0 5px rgba(#000, 0.2)
> [data-fa]
margin-right 4px
diff --git a/src/client/app/mobile/views/components/drive.vue b/src/client/app/mobile/views/components/drive.vue
index 7aa666e1bb..ef3432a3ec 100644
--- a/src/client/app/mobile/views/components/drive.vue
+++ b/src/client/app/mobile/views/components/drive.vue
@@ -474,11 +474,11 @@ export default Vue.extend({
overflow auto
white-space nowrap
font-size 0.9em
- color rgba(0, 0, 0, 0.67)
+ color rgba(#000, 0.67)
-webkit-backdrop-filter blur(12px)
backdrop-filter blur(12px)
background-color rgba(#fff, 0.75)
- border-bottom solid 1px rgba(0, 0, 0, 0.13)
+ border-bottom solid 1px rgba(#000, 0.13)
> p
> a
@@ -555,7 +555,7 @@ export default Vue.extend({
display inline-block
position absolute
top 0
- background rgba(0, 0, 0, 0.2)
+ background rgba(#000, 0.2)
border-radius 100%
animation sk-bounce 2.0s infinite ease-in-out
diff --git a/src/client/app/mobile/views/components/friends-maker.vue b/src/client/app/mobile/views/components/friends-maker.vue
index 961a5f568a..ba4abe341f 100644
--- a/src/client/app/mobile/views/components/friends-maker.vue
+++ b/src/client/app/mobile/views/components/friends-maker.vue
@@ -57,7 +57,7 @@ export default Vue.extend({
.mk-friends-maker
background #fff
border-radius 8px
- box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+ box-shadow 0 0 0 1px rgba(#000, 0.2)
> .title
margin 0
diff --git a/src/client/app/mobile/views/components/index.ts b/src/client/app/mobile/views/components/index.ts
index 9346700304..5ed8427b05 100644
--- a/src/client/app/mobile/views/components/index.ts
+++ b/src/client/app/mobile/views/components/index.ts
@@ -1,7 +1,6 @@
import Vue from 'vue';
import ui from './ui.vue';
-import timeline from './timeline.vue';
import note from './note.vue';
import notes from './notes.vue';
import mediaImage from './media-image.vue';
@@ -20,11 +19,11 @@ import notificationPreview from './notification-preview.vue';
import usersList from './users-list.vue';
import userPreview from './user-preview.vue';
import userTimeline from './user-timeline.vue';
+import userListTimeline from './user-list-timeline.vue';
import activity from './activity.vue';
import widgetContainer from './widget-container.vue';
Vue.component('mk-ui', ui);
-Vue.component('mk-timeline', timeline);
Vue.component('mk-note', note);
Vue.component('mk-notes', notes);
Vue.component('mk-media-image', mediaImage);
@@ -43,5 +42,6 @@ Vue.component('mk-notification-preview', notificationPreview);
Vue.component('mk-users-list', usersList);
Vue.component('mk-user-preview', userPreview);
Vue.component('mk-user-timeline', userTimeline);
+Vue.component('mk-user-list-timeline', userListTimeline);
Vue.component('mk-activity', activity);
Vue.component('mk-widget-container', widgetContainer);
diff --git a/src/client/app/mobile/views/components/media-image.vue b/src/client/app/mobile/views/components/media-image.vue
index cfc2134988..92d1cdc6f5 100644
--- a/src/client/app/mobile/views/components/media-image.vue
+++ b/src/client/app/mobile/views/components/media-image.vue
@@ -6,12 +6,20 @@
import Vue from 'vue';
export default Vue.extend({
- props: ['image'],
+ props: {
+ image: {
+ type: Object,
+ required: true
+ },
+ raw: {
+ default: false
+ }
+ },
computed: {
style(): any {
return {
'background-color': this.image.properties.avgColor ? `rgb(${this.image.properties.avgColor.join(',')})` : 'transparent',
- 'background-image': `url(${this.image.url}?thumbnail&size=512)`
+ 'background-image': this.raw ? `url(${this.image.url})` : `url(${this.image.url}?thumbnail&size=512)`
};
}
}
diff --git a/src/client/app/mobile/views/components/note-card.vue b/src/client/app/mobile/views/components/note-card.vue
index 393fa9b831..89700b5e82 100644
--- a/src/client/app/mobile/views/components/note-card.vue
+++ b/src/client/app/mobile/views/components/note-card.vue
@@ -27,17 +27,17 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.mk-note-card
+root(isDark)
display inline-block
width 150px
//height 120px
font-size 12px
- background #fff
+ background isDark ? #282c37 : #fff
border-radius 4px
> a
display block
- color #2c3940
+ color isDark ? #fff : #2c3940
&:hover
text-decoration none
@@ -75,11 +75,17 @@ export default Vue.extend({
left 0
width 100%
height 20px
- background linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, #fff 100%)
+ background isDark ? linear-gradient(to bottom, rgba(#282c37, 0) 0%, #282c37 100%) : linear-gradient(to bottom, rgba(#fff, 0) 0%, #fff 100%)
> .mk-time
display inline-block
padding 8px
color #aaa
+.mk-note-card[data-darkmode]
+ root(true)
+
+.mk-note-card:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/mobile/views/components/note-detail.sub.vue b/src/client/app/mobile/views/components/note-detail.sub.vue
index 06f442d308..e515fda8a6 100644
--- a/src/client/app/mobile/views/components/note-detail.sub.vue
+++ b/src/client/app/mobile/views/components/note-detail.sub.vue
@@ -1,8 +1,6 @@
<template>
<div class="root sub">
- <router-link class="avatar-anchor" :to="note.user | userPage">
- <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
- </router-link>
+ <mk-avatar class="avatar" :user="note.user"/>
<div class="main">
<header>
<router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link>
@@ -27,35 +25,29 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.root.sub
+root(isDark)
padding 8px
font-size 0.9em
- background #fdfdfd
+ background isDark ? #21242d : #fdfdfd
@media (min-width 500px)
padding 12px
+ @media (min-width 600px)
+ padding 24px 32px
+
&:after
content ""
display block
clear both
- &:hover
- > .main > footer > button
- color #888
-
- > .avatar-anchor
+ > .avatar
display block
float left
margin 0 12px 0 0
-
- > .avatar
- display block
- width 48px
- height 48px
- margin 0
- border-radius 8px
- vertical-align bottom
+ width 48px
+ height 48px
+ border-radius 8px
> .main
float left
@@ -63,6 +55,7 @@ export default Vue.extend({
> header
display flex
+ align-items baseline
margin-bottom 4px
white-space nowrap
@@ -71,7 +64,7 @@ export default Vue.extend({
margin 0 .5em 0 0
padding 0
overflow hidden
- color #607073
+ color isDark ? #fff : #607073
font-size 1em
font-weight 700
text-align left
@@ -84,11 +77,11 @@ export default Vue.extend({
> .username
text-align left
margin 0 .5em 0 0
- color #d1d8da
+ color isDark ? #606984 : #d1d8da
> .time
margin-left auto
- color #b2b8bb
+ color isDark ? #606984 : #b2b8bb
> .body
@@ -97,7 +90,12 @@ export default Vue.extend({
margin 0
padding 0
font-size 1.1em
- color #717171
+ color isDark ? #959ba7 : #717171
-</style>
+.root.sub[data-darkmode]
+ root(true)
+
+.root.sub:not([data-darkmode])
+ root(false)
+</style>
diff --git a/src/client/app/mobile/views/components/note-detail.vue b/src/client/app/mobile/views/components/note-detail.vue
index 7d2747751e..5a7226faac 100644
--- a/src/client/app/mobile/views/components/note-detail.vue
+++ b/src/client/app/mobile/views/components/note-detail.vue
@@ -17,29 +17,27 @@
</div>
<div class="renote" v-if="isRenote">
<p>
- <router-link class="avatar-anchor" :to="note.user | userPage">
- <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=32`" alt="avatar"/>
- </router-link>
- %fa:retweet%<router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link>がRenote
+ <mk-avatar class="avatar" :user="note.user"/>%fa:retweet%<router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link>がRenote
</p>
</div>
<article>
<header>
- <router-link class="avatar-anchor" :to="p.user | userPage">
- <img class="avatar" :src="`${p.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
- </router-link>
+ <mk-avatar class="avatar" :user="p.user"/>
<div>
<router-link class="name" :to="p.user | userPage">{{ p.user | userName }}</router-link>
<span class="username">@{{ p.user | acct }}</span>
</div>
</header>
<div class="body">
- <mk-note-html v-if="p.text" :text="p.text" :i="os.i" :class="$style.text"/>
+ <div class="text">
+ <span v-if="p.isHidden" style="opacity: 0.5">(この投稿は非公開です)</span>
+ <mk-note-html v-if="p.text" :text="p.text" :i="os.i"/>
+ </div>
<div class="tags" v-if="p.tags && p.tags.length > 0">
<router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link>
</div>
<div class="media" v-if="p.media.length > 0">
- <mk-media-list :media-list="p.media"/>
+ <mk-media-list :media-list="p.media" :raw="true"/>
</div>
<mk-poll v-if="p.poll" :note="p"/>
<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
@@ -55,7 +53,9 @@
<footer>
<mk-reactions-viewer :note="p"/>
<button @click="reply" title="%i18n:@reply%">
- %fa:reply%<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p>
+ <template v-if="p.reply">%fa:reply-all%</template>
+ <template v-else>%fa:reply%</template>
+ <p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p>
</button>
<button @click="renote" title="Renote">
%fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p>
@@ -147,7 +147,7 @@ export default Vue.extend({
// Draw map
if (this.p.geo) {
- const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.clientSettings.showMaps : true;
+ const shouldShowMap = (this as any).os.isSignedIn ? (this as any).clientSettings.showMaps : true;
if (shouldShowMap) {
(this as any).os.getGoogleMaps().then(maps => {
const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]);
@@ -207,15 +207,18 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-note-detail
+root(isDark)
overflow hidden
margin 0 auto
padding 0
width 100%
text-align left
- background #fff
+ background isDark ? #282C37 : #fff
border-radius 8px
- box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+ box-shadow 0 0 2px rgba(#000, 0.1)
+
+ @media (min-width 500px)
+ box-shadow 0 8px 32px rgba(#000, 0.1)
> .fetching
padding 64px 0
@@ -229,45 +232,37 @@ export default Vue.extend({
text-align center
color #999
cursor pointer
- background #fafafa
+ background isDark ? #21242d : #fafafa
outline none
border none
- border-bottom solid 1px #eef0f2
+ border-bottom solid 1px isDark ? #1c2023 : #eef0f2
border-radius 6px 6px 0 0
box-shadow none
&:hover
- background #f6f6f6
-
- &:active
- background #f0f0f0
+ background isDark ? #16181d : #f6f6f6
&:disabled
color #ccc
> .context
> *
- border-bottom 1px solid #eef0f2
+ border-bottom 1px solid isDark ? #1c2023 : #eef0f2
> .renote
color #9dbb00
- background linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+ background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%)
> p
margin 0
padding 16px 32px
- .avatar-anchor
+ .avatar
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
+ width 28px
+ height 28px
+ margin 0 8px 0 0
+ border-radius 6px
[data-fa]
margin-right 4px
@@ -279,7 +274,7 @@ export default Vue.extend({
padding-top 8px
> .reply-to
- border-bottom 1px solid #eef0f2
+ border-bottom 1px solid isDark ? #1c2023 : #eef0f2
> article
padding 14px 16px 9px 16px
@@ -292,36 +287,27 @@ export default Vue.extend({
display block
clear both
- &:hover
- > .main > footer > button
- color #888
-
> header
display flex
- line-height 1.1
+ line-height 1.1em
- > .avatar-anchor
+ > .avatar
display block
- padding 0 .5em 0 0
-
- > .avatar
- display block
- width 54px
- height 54px
- margin 0
- border-radius 8px
- vertical-align bottom
+ margin 0 12px 0 0
+ width 54px
+ height 54px
+ border-radius 8px
- @media (min-width 500px)
- width 60px
- height 60px
+ @media (min-width 500px)
+ width 60px
+ height 60px
> div
> .name
display inline-block
margin .4em 0
- color #777
+ color isDark ? #fff : #627079
font-size 16px
font-weight bold
text-align left
@@ -334,11 +320,22 @@ export default Vue.extend({
display block
text-align left
margin 0
- color #ccc
+ color isDark ? #606984 : #ccc
> .body
padding 8px 0
+ > .text
+ display block
+ margin 0
+ padding 0
+ overflow-wrap break-word
+ font-size 16px
+ color isDark ? #fff : #717171
+
+ @media (min-width 500px)
+ font-size 24px
+
> .renote
margin 8px 0
@@ -394,7 +391,7 @@ export default Vue.extend({
> .time
font-size 16px
- color #c0c0c0
+ color isDark ? #606984 : #c0c0c0
> footer
font-size 1.2em
@@ -406,14 +403,14 @@ export default Vue.extend({
border none
box-shadow none
font-size 1em
- color #ddd
+ color isDark ? #606984 : #ddd
cursor pointer
&:not(:last-child)
margin-right 28px
&:hover
- color #666
+ color isDark ? #9198af : #666
> .count
display inline
@@ -425,20 +422,12 @@ export default Vue.extend({
> .replies
> *
- border-top 1px solid #eef0f2
+ border-top 1px solid isDark ? #1c2023 : #eef0f2
-</style>
+.mk-note-detail[data-darkmode]
+ root(true)
-<style lang="stylus" module>
-.text
- display block
- margin 0
- padding 0
- overflow-wrap break-word
- font-size 16px
- color #717171
-
- @media (min-width 500px)
- font-size 24px
+.mk-note-detail:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/mobile/views/components/note-preview.vue b/src/client/app/mobile/views/components/note-preview.vue
index b9a6db315d..ec11f23315 100644
--- a/src/client/app/mobile/views/components/note-preview.vue
+++ b/src/client/app/mobile/views/components/note-preview.vue
@@ -1,8 +1,6 @@
<template>
<div class="mk-note-preview">
- <router-link class="avatar-anchor" :to="note.user | userPage">
- <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
- </router-link>
+ <mk-avatar class="avatar" :user="note.user"/>
<div class="main">
<header>
<router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link>
@@ -27,33 +25,23 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.mk-note-preview
+root(isDark)
margin 0
padding 0
font-size 0.9em
- background #fff
&:after
content ""
display block
clear both
- &:hover
- > .main > footer > button
- color #888
-
- > .avatar-anchor
+ > .avatar
display block
float left
margin 0 12px 0 0
-
- > .avatar
- display block
- width 48px
- height 48px
- margin 0
- border-radius 8px
- vertical-align bottom
+ width 48px
+ height 48px
+ border-radius 8px
> .main
float left
@@ -61,6 +49,7 @@ export default Vue.extend({
> header
display flex
+ align-items baseline
margin-bottom 4px
white-space nowrap
@@ -69,7 +58,7 @@ export default Vue.extend({
margin 0 .5em 0 0
padding 0
overflow hidden
- color #607073
+ color isDark ? #fff : #607073
font-size 1em
font-weight 700
text-align left
@@ -82,11 +71,11 @@ export default Vue.extend({
> .username
text-align left
margin 0 .5em 0 0
- color #d1d8da
+ color isDark ? #606984 : #d1d8da
> .time
margin-left auto
- color #b2b8bb
+ color isDark ? #606984 : #b2b8bb
> .body
@@ -95,6 +84,12 @@ export default Vue.extend({
margin 0
padding 0
font-size 1.1em
- color #717171
+ color isDark ? #959ba7 : #717171
+
+.mk-note-preview[data-darkmode]
+ root(true)
+
+.mk-note-preview:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/mobile/views/components/note.sub.vue b/src/client/app/mobile/views/components/note.sub.vue
index d489f3a053..82025291da 100644
--- a/src/client/app/mobile/views/components/note.sub.vue
+++ b/src/client/app/mobile/views/components/note.sub.vue
@@ -1,15 +1,22 @@
<template>
<div class="sub">
- <router-link class="avatar-anchor" :to="note.user | userPage">
- <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=96`" alt="avatar"/>
- </router-link>
+ <mk-avatar class="avatar" :user="note.user"/>
<div class="main">
<header>
<router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link>
<span class="username">@{{ note.user | acct }}</span>
- <router-link class="created-at" :to="note | notePage">
- <mk-time :time="note.createdAt"/>
- </router-link>
+ <div class="info">
+ <span class="mobile" v-if="note.viaMobile">%fa:mobile-alt%</span>
+ <router-link class="created-at" :to="note | notePage">
+ <mk-time :time="note.createdAt"/>
+ </router-link>
+ <span class="visibility" v-if="note.visibility != 'public'">
+ <template v-if="note.visibility == 'home'">%fa:home%</template>
+ <template v-if="note.visibility == 'followers'">%fa:unlock%</template>
+ <template v-if="note.visibility == 'specified'">%fa:envelope%</template>
+ <template v-if="note.visibility == 'private'">%fa:lock%</template>
+ </span>
+ </div>
</header>
<div class="body">
<mk-sub-note-content class="text" :note="note"/>
@@ -27,34 +34,31 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.sub
- font-size 0.9em
+root(isDark)
padding 16px
+ font-size 0.9em
+ background isDark ? #21242d : #fcfcfc
+
+ @media (min-width 600px)
+ padding 24px 32px
&:after
content ""
display block
clear both
- > .avatar-anchor
+ > .avatar
display block
float left
margin 0 10px 0 0
+ width 44px
+ height 44px
+ border-radius 8px
@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
+ width 52px
+ height 52px
> .main
float left
@@ -65,6 +69,7 @@ export default Vue.extend({
> header
display flex
+ align-items baseline
margin-bottom 2px
white-space nowrap
@@ -73,7 +78,7 @@ export default Vue.extend({
margin 0 0.5em 0 0
padding 0
overflow hidden
- color #607073
+ color isDark ? #fff : #607073
font-size 1em
font-weight 700
text-align left
@@ -86,24 +91,40 @@ export default Vue.extend({
> .username
text-align left
margin 0
- color #d1d8da
+ color isDark ? #606984 : #d1d8da
- > .created-at
+ > .info
margin-left auto
- color #b2b8bb
+ font-size 0.9em
+
+ > *
+ color isDark ? #606984 : #b2b8bb
+
+ > .mobile
+ margin-right 6px
+
+ > .visibility
+ margin-left 6px
> .body
+ max-height 128px
+ overflow hidden
> .text
cursor default
margin 0
padding 0
font-size 1.1em
- color #717171
+ color isDark ? #959ba7 : #717171
pre
max-height 120px
font-size 80%
-</style>
+.sub[data-darkmode]
+ root(true)
+.sub:not([data-darkmode])
+ root(false)
+
+</style>
diff --git a/src/client/app/mobile/views/components/note.vue b/src/client/app/mobile/views/components/note.vue
index cccb8875b4..d66f5a1016 100644
--- a/src/client/app/mobile/views/components/note.vue
+++ b/src/client/app/mobile/views/components/note.vue
@@ -1,24 +1,18 @@
<template>
<div class="note" :class="{ renote: isRenote }">
- <div class="reply-to" v-if="p.reply">
+ <div class="reply-to" v-if="p.reply && (!os.isSignedIn || clientSettings.showReplyTarget)">
<x-sub :note="p.reply"/>
</div>
<div class="renote" v-if="isRenote">
- <p>
- <router-link class="avatar-anchor" :to="note.user | userPage">
- <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
- </router-link>
- %fa:retweet%
- <span>{{ '%i18n:!@reposted-by%'.substr(0, '%i18n:!@reposted-by%'.indexOf('{')) }}</span>
- <router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link>
- <span>{{ '%i18n:!@reposted-by%'.substr('%i18n:!@reposted-by%'.indexOf('}') + 1) }}</span>
- </p>
+ <mk-avatar class="avatar" :user="note.user"/>
+ %fa:retweet%
+ <span>{{ '%i18n:!@reposted-by%'.substr(0, '%i18n:!@reposted-by%'.indexOf('{')) }}</span>
+ <router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link>
+ <span>{{ '%i18n:!@reposted-by%'.substr('%i18n:!@reposted-by%'.indexOf('}') + 1) }}</span>
<mk-time :time="note.createdAt"/>
</div>
<article>
- <router-link class="avatar-anchor" :to="p.user | userPage">
- <img class="avatar" :src="`${p.user.avatarUrl}?thumbnail&size=96`" alt="avatar"/>
- </router-link>
+ <mk-avatar class="avatar" :user="p.user"/>
<div class="main">
<header>
<router-link class="name" :to="p.user | userPage">{{ p.user | userName }}</router-link>
@@ -29,36 +23,49 @@
<router-link class="created-at" :to="p | notePage">
<mk-time :time="p.createdAt"/>
</router-link>
+ <span class="visibility" v-if="p.visibility != 'public'">
+ <template v-if="p.visibility == 'home'">%fa:home%</template>
+ <template v-if="p.visibility == 'followers'">%fa:unlock%</template>
+ <template v-if="p.visibility == 'specified'">%fa:envelope%</template>
+ <template v-if="p.visibility == 'private'">%fa:lock%</template>
+ </span>
</div>
</header>
<div class="body">
<p class="channel" v-if="p.channel != null"><a target="_blank">{{ p.channel.title }}</a>:</p>
- <div class="text">
- <a class="reply" v-if="p.reply">
- %fa:reply%
- </a>
- <mk-note-html v-if="p.text" :text="p.text" :i="os.i" :class="$style.text"/>
- <a class="rp" v-if="p.renote != null">RP:</a>
+ <p v-if="p.cw != null" class="cw">
+ <span class="text" v-if="p.cw != ''">{{ p.cw }}</span>
+ <span class="toggle" @click="showContent = !showContent">{{ showContent ? '隠す' : 'もっと見る' }}</span>
+ </p>
+ <div class="content" v-show="p.cw == null || showContent">
+ <div class="text">
+ <span v-if="p.isHidden" style="opacity: 0.5">(この投稿は非公開です)</span>
+ <a class="reply" v-if="p.reply">%fa:reply%</a>
+ <mk-note-html v-if="p.text" :text="p.text" :i="os.i" :class="$style.text"/>
+ <a class="rp" v-if="p.renote != null">RP:</a>
+ </div>
+ <div class="media" v-if="p.media.length > 0">
+ <mk-media-list :media-list="p.media"/>
+ </div>
+ <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
+ <div class="tags" v-if="p.tags && p.tags.length > 0">
+ <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link>
+ </div>
+ <mk-url-preview v-for="url in urls" :url="url" :key="url"/>
+ <a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a>
+ <div class="map" v-if="p.geo" ref="map"></div>
+ <div class="renote" v-if="p.renote">
+ <mk-note-preview :note="p.renote"/>
+ </div>
</div>
- <div class="media" v-if="p.media.length > 0">
- <mk-media-list :media-list="p.media"/>
- </div>
- <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
- <div class="tags" v-if="p.tags && p.tags.length > 0">
- <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link>
- </div>
- <mk-url-preview v-for="url in urls" :url="url" :key="url"/>
- <a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a>
- <div class="map" v-if="p.geo" ref="map"></div>
<span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span>
- <div class="renote" v-if="p.renote">
- <mk-note-preview :note="p.renote"/>
- </div>
</div>
<footer>
<mk-reactions-viewer :note="p" ref="reactionsViewer"/>
<button @click="reply">
- %fa:reply%<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p>
+ <template v-if="p.reply">%fa:reply-all%</template>
+ <template v-else>%fa:reply%</template>
+ <p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p>
</button>
<button @click="renote" title="Renote">
%fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p>
@@ -92,6 +99,7 @@ export default Vue.extend({
data() {
return {
+ showContent: false,
connection: null,
connectionId: null
};
@@ -142,7 +150,7 @@ export default Vue.extend({
// Draw map
if (this.p.geo) {
- const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.clientSettings.showMaps : true;
+ const shouldShowMap = (this as any).os.isSignedIn ? (this as any).clientSettings.showMaps : true;
if (shouldShowMap) {
(this as any).os.getGoogleMaps().then(maps => {
const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]);
@@ -229,15 +237,9 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.note
+root(isDark)
font-size 12px
- border-bottom solid 1px #eaeaea
-
- &:first-child
- border-radius 8px 8px 0 0
-
- > .renote
- border-radius 8px 8px 0 0
+ border-bottom solid 1px isDark ? #1c2023 : #eaeaea
&:last-of-type
border-bottom none
@@ -249,83 +251,78 @@ export default Vue.extend({
font-size 16px
> .renote
+ display flex
+ align-items center
+ padding 8px 16px
+ line-height 28px
color #9dbb00
- background linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+ background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%)
- > p
- margin 0
- padding 8px 16px
- line-height 28px
+ @media (min-width 500px)
+ padding 16px
- @media (min-width 500px)
- padding 16px
+ @media (min-width 600px)
+ padding 16px 32px
+
+ .avatar
+ display inline-block
+ width 28px
+ height 28px
+ margin 0 8px 0 0
+ border-radius 6px
- .avatar-anchor
- display inline-block
+ [data-fa]
+ margin-right 4px
- .avatar
- vertical-align bottom
- width 28px
- height 28px
- margin 0 8px 0 0
- border-radius 6px
+ > span
+ flex-shrink 0
- [data-fa]
- margin-right 4px
+ &:last-of-type
+ margin-right 8px
- .name
- font-weight bold
+ .name
+ overflow hidden
+ flex-shrink 1
+ text-overflow ellipsis
+ white-space nowrap
+ font-weight bold
> .mk-time
- position absolute
- top 8px
- right 16px
+ display block
+ margin-left auto
+ flex-shrink 0
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-note-preview
- background transparent
-
> article
- padding 14px 16px 9px 16px
+ padding 16px 16px 9px
+
+ @media (min-width 600px)
+ padding 32px 32px 22px
&:after
content ""
display block
clear both
- > .avatar-anchor
+ > .avatar
display block
float left
margin 0 10px 8px 0
- position -webkit-sticky
- position sticky
- top 62px
+ width 48px
+ height 48px
+ border-radius 6px
+ //position -webkit-sticky
+ //position sticky
+ //top 62px
@media (min-width 500px)
margin-right 16px
-
- > .avatar
- display block
- width 48px
- height 48px
- margin 0
- border-radius 6px
- vertical-align bottom
-
- @media (min-width 500px)
- width 58px
- height 58px
- border-radius 8px
+ width 58px
+ height 58px
+ border-radius 8px
> .main
float left
@@ -336,7 +333,7 @@ export default Vue.extend({
> header
display flex
- align-items center
+ align-items baseline
white-space nowrap
@media (min-width 500px)
@@ -347,7 +344,7 @@ export default Vue.extend({
margin 0 0.5em 0 0
padding 0
overflow hidden
- color #627079
+ color isDark ? #fff : #627079
font-size 1em
font-weight bold
text-decoration none
@@ -360,122 +357,165 @@ export default Vue.extend({
margin 0 0.5em 0 0
padding 1px 6px
font-size 12px
- color #aaa
- border solid 1px #ddd
+ color isDark ? #758188 : #aaa
+ border solid 1px isDark ? #57616f : #ddd
border-radius 3px
> .username
margin 0 0.5em 0 0
- color #ccc
+ overflow hidden
+ text-overflow ellipsis
+ color isDark ? #606984 : #ccc
> .info
margin-left auto
font-size 0.9em
+ > *
+ color isDark ? #606984 : #c0c0c0
+
> .mobile
margin-right 6px
- color #c0c0c0
- > .created-at
- color #c0c0c0
+ > .visibility
+ margin-left 6px
> .body
- > .text
+ > .cw
+ cursor default
display block
margin 0
padding 0
overflow-wrap break-word
font-size 1.1em
- color #717171
-
- >>> .quote
- margin 8px
- padding 6px 12px
- color #aaa
- border-left solid 3px #eee
+ color isDark ? #fff : #717171
- > .reply
+ > .text
margin-right 8px
- color #717171
- > .rp
- margin-left 4px
- font-style oblique
- color #a0bf46
+ > .toggle
+ display inline-block
+ padding 4px 8px
+ font-size 0.7em
+ color isDark ? #393f4f : #fff
+ background isDark ? #687390 : #b1b9c1
+ border-radius 2px
+ cursor pointer
+ user-select none
- [data-is-me]:after
- content "you"
- padding 0 4px
- margin-left 4px
- font-size 80%
- color $theme-color-foreground
- background $theme-color
- border-radius 4px
+ &:hover
+ background isDark ? #707b97 : #bbc4ce
- .mk-url-preview
- margin-top 8px
+ > .content
- > .channel
- margin 0
+ > .text
+ display block
+ margin 0
+ padding 0
+ overflow-wrap break-word
+ font-size 1.1em
+ color isDark ? #fff : #717171
- > .tags
- margin 4px 0 0 0
+ >>> .title
+ display block
+ margin-bottom 4px
+ padding 4px
+ font-size 90%
+ text-align center
+ background isDark ? #2f3944 : #eef1f3
+ border-radius 4px
- > *
- display inline-block
- margin 0 8px 0 0
- padding 2px 8px 2px 16px
- font-size 90%
- color #8d969e
- background #edf0f3
- border-radius 4px
+ >>> .code
+ margin 8px 0
+
+ >>> .quote
+ margin 8px
+ padding 6px 12px
+ color isDark ? #6f808e : #aaa
+ border-left solid 3px isDark ? #637182 : #eee
+
+ > .reply
+ margin-right 8px
+ color isDark ? #99abbf : #717171
+
+ > .rp
+ margin-left 4px
+ font-style oblique
+ color #a0bf46
+
+ [data-is-me]:after
+ content "you"
+ padding 0 4px
+ margin-left 4px
+ font-size 80%
+ color $theme-color-foreground
+ background $theme-color
+ border-radius 4px
+
+ .mk-url-preview
+ margin-top 8px
+
+ > .channel
+ margin 0
+
+ > .tags
+ margin 4px 0 0 0
+
+ > *
+ display inline-block
+ margin 0 8px 0 0
+ padding 2px 8px 2px 16px
+ font-size 90%
+ color #8d969e
+ background #edf0f3
+ border-radius 4px
+
+ &:before
+ content ""
+ display block
+ position absolute
+ top 0
+ bottom 0
+ left 4px
+ width 8px
+ height 8px
+ margin auto 0
+ background #fff
+ border-radius 100%
- &:before
- content ""
+ > .media
+ > img
display block
- position absolute
- top 0
- bottom 0
- left 4px
- width 8px
- height 8px
- margin auto 0
- background #fff
- border-radius 100%
+ max-width 100%
- > .media
- > img
- display block
- max-width 100%
+ > .location
+ margin 4px 0
+ font-size 12px
+ color #ccc
- > .location
- margin 4px 0
- font-size 12px
- color #ccc
+ > .map
+ width 100%
+ height 200px
+
+ &:empty
+ display none
+
+ > .mk-poll
+ font-size 80%
- > .map
- width 100%
- height 200px
+ > .renote
+ margin 8px 0
- &:empty
- display none
+ > .mk-note-preview
+ padding 16px
+ border dashed 1px isDark ? #4e945e : #c0dac6
+ border-radius 8px
> .app
font-size 12px
color #ccc
- > .mk-poll
- font-size 80%
-
- > .renote
- margin 8px 0
-
- > .mk-note-preview
- padding 16px
- border dashed 1px #c0dac6
- border-radius 8px
-
> footer
> button
margin 0
@@ -484,14 +524,14 @@ export default Vue.extend({
border none
box-shadow none
font-size 1em
- color #ddd
+ color isDark ? #606984 : #ddd
cursor pointer
&:not(:last-child)
margin-right 28px
&:hover
- color #666
+ color isDark ? #9198af : #666
> .count
display inline
@@ -505,6 +545,12 @@ export default Vue.extend({
@media (max-width 350px)
display none
+.note[data-darkmode]
+ root(true)
+
+.note:not([data-darkmode])
+ root(false)
+
</style>
<style lang="stylus" module>
diff --git a/src/client/app/mobile/views/components/notes.vue b/src/client/app/mobile/views/components/notes.vue
index 573026d53e..53e232e521 100644
--- a/src/client/app/mobile/views/components/notes.vue
+++ b/src/client/app/mobile/views/components/notes.vue
@@ -1,30 +1,64 @@
<template>
<div class="mk-notes">
+ <div class="newer-indicator" :style="{ top: $store.state.uiHeaderHeight + 'px' }" v-show="queue.length > 0"></div>
+
<slot name="head"></slot>
- <slot></slot>
- <template v-for="(note, i) in _notes">
- <mk-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/>
- <p class="date" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date">
- <span>%fa:angle-up%{{ note._datetext }}</span>
- <span>%fa:angle-down%{{ _notes[i + 1]._datetext }}</span>
- </p>
- </template>
- <footer>
- <slot name="tail"></slot>
+
+ <slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot>
+
+ <div class="init" v-if="fetching">
+ %fa:spinner .pulse%%i18n:common.loading%
+ </div>
+
+ <div v-if="!fetching && requestInitPromise != null">
+ <p>読み込みに失敗しました。</p>
+ <button @click="resolveInitPromise">リトライ</button>
+ </div>
+
+ <transition-group name="mk-notes" class="transition">
+ <template v-for="(note, i) in _notes">
+ <mk-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/>
+ <p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date">
+ <span>%fa:angle-up%{{ note._datetext }}</span>
+ <span>%fa:angle-down%{{ _notes[i + 1]._datetext }}</span>
+ </p>
+ </template>
+ </transition-group>
+
+ <footer v-if="more">
+ <button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+ <template v-if="!moreFetching">%i18n:@load-more%</template>
+ <template v-if="moreFetching">%fa:spinner .pulse .fw%</template>
+ </button>
</footer>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
+import getNoteSummary from '../../../../../renderers/get-note-summary';
+
+const displayLimit = 30;
export default Vue.extend({
props: {
- notes: {
- type: Array,
- default: () => []
+ more: {
+ type: Function,
+ required: false
}
},
+
+ data() {
+ return {
+ requestInitPromise: null as () => Promise<any[]>,
+ notes: [],
+ queue: [],
+ unreadCount: 0,
+ fetching: true,
+ moreFetching: false
+ };
+ },
+
computed: {
_notes(): any[] {
return (this.notes as any).map(note => {
@@ -36,9 +70,132 @@ export default Vue.extend({
});
}
},
+
+ mounted() {
+ document.addEventListener('visibilitychange', this.onVisibilitychange, false);
+ window.addEventListener('scroll', this.onScroll);
+ },
+
+ beforeDestroy() {
+ document.removeEventListener('visibilitychange', this.onVisibilitychange);
+ window.removeEventListener('scroll', this.onScroll);
+ },
+
methods: {
+ isScrollTop() {
+ return window.scrollY <= 8;
+ },
+
onNoteUpdated(i, note) {
Vue.set((this as any).notes, i, note);
+ },
+
+ init(promiseGenerator: () => Promise<any[]>) {
+ this.requestInitPromise = promiseGenerator;
+ this.resolveInitPromise();
+ },
+
+ resolveInitPromise() {
+ this.queue = [];
+ this.notes = [];
+ this.fetching = true;
+
+ const promise = this.requestInitPromise();
+
+ promise.then(notes => {
+ this.notes = notes;
+ this.requestInitPromise = null;
+ this.fetching = false;
+ }, e => {
+ this.fetching = false;
+ });
+ },
+
+ prepend(note, silent = false) {
+ //#region 弾く
+ const isMyNote = note.userId == (this as any).os.i.id;
+ const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null;
+
+ if ((this as any).clientSettings.showMyRenotes === false) {
+ if (isMyNote && isPureRenote) {
+ return;
+ }
+ }
+
+ if ((this as any).clientSettings.showRenotedMyNotes === false) {
+ if (isPureRenote && (note.renote.userId == (this as any).os.i.id)) {
+ return;
+ }
+ }
+ //#endregion
+
+ // 投稿が自分のものではないかつ、タブが非表示またはスクロール位置が最上部ではないならタイトルで通知
+ if ((document.hidden || !this.isScrollTop()) && note.userId !== (this as any).os.i.id) {
+ this.unreadCount++;
+ document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`;
+ }
+
+ if (this.isScrollTop()) {
+ // Prepend the note
+ this.notes.unshift(note);
+
+ // オーバーフローしたら古い投稿は捨てる
+ if (this.notes.length >= displayLimit) {
+ this.notes = this.notes.slice(0, displayLimit);
+ }
+ } else {
+ this.queue.push(note);
+ }
+ },
+
+ append(note) {
+ this.notes.push(note);
+ },
+
+ tail() {
+ return this.notes[this.notes.length - 1];
+ },
+
+ releaseQueue() {
+ this.queue.forEach(n => this.prepend(n, true));
+ this.queue = [];
+ },
+
+ async loadMore() {
+ if (this.more == null) return;
+ if (this.moreFetching) return;
+
+ this.moreFetching = true;
+ await this.more();
+ this.moreFetching = false;
+ },
+
+ clearNotification() {
+ this.unreadCount = 0;
+ document.title = 'Misskey';
+ },
+
+ onVisibilitychange() {
+ if (!document.hidden) {
+ this.clearNotification();
+ }
+ },
+
+ onScroll() {
+ if (this.isScrollTop()) {
+ this.releaseQueue();
+ this.clearNotification();
+ }
+
+ if ((this as any).clientSettings.fetchOnScroll !== false) {
+ // 親要素が display none だったら弾く
+ // https://github.com/syuilo/misskey/issues/1569
+ // http://d.hatena.ne.jp/favril/20091105/1257403319
+ if (this.$el.offsetHeight == 0) return;
+
+ const current = window.scrollY + window.innerHeight;
+ if (current > document.body.offsetHeight - 8) this.loadMore();
+ }
}
}
});
@@ -47,10 +204,46 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-notes
- background #fff
+root(isDark)
+ overflow hidden
+ background isDark ? #282C37 : #fff
border-radius 8px
- box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+ box-shadow 0 0 2px rgba(#000, 0.1)
+
+ @media (min-width 500px)
+ box-shadow 0 8px 32px rgba(#000, 0.1)
+
+ .transition
+ .mk-notes-enter
+ .mk-notes-leave-to
+ opacity 0
+ transform translateY(-30px)
+
+ > *
+ transition transform .3s ease, opacity .3s ease
+
+ > .date
+ display block
+ margin 0
+ line-height 32px
+ text-align center
+ font-size 0.9em
+ color isDark ? #666b79 : #aaa
+ background isDark ? #242731 : #fdfdfd
+ border-bottom solid 1px isDark ? #1c2023 : #eaeaea
+
+ span
+ margin 0 16px
+
+ [data-fa]
+ margin-right 8px
+
+ > .newer-indicator
+ position -webkit-sticky
+ position sticky
+ z-index 100
+ height 3px
+ background $theme-color
> .init
padding 64px 0
@@ -73,27 +266,9 @@ export default Vue.extend({
font-size 3em
color #ccc
- > .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
-
- [data-fa]
- margin-right 8px
-
> footer
text-align center
- border-top solid 1px #eaeaea
- border-bottom-left-radius 4px
- border-bottom-right-radius 4px
+ border-top solid 1px isDark ? #1c2023 : #eaeaea
&:empty
display none
@@ -102,10 +277,18 @@ export default Vue.extend({
margin 0
padding 16px
width 100%
- color $theme-color
- border-radius 0 0 8px 8px
+ color #ccc
+
+ @media (min-width 500px)
+ padding 20px
&:disabled
opacity 0.7
+.mk-notes[data-darkmode]
+ root(true)
+
+.mk-notes:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/mobile/views/components/notification.vue b/src/client/app/mobile/views/components/notification.vue
index 4f7c8968b2..c1b37563ce 100644
--- a/src/client/app/mobile/views/components/notification.vue
+++ b/src/client/app/mobile/views/components/notification.vue
@@ -1,15 +1,13 @@
<template>
<div class="mk-notification">
<div class="notification reaction" v-if="notification.type == 'reaction'">
- <mk-time :time="notification.createdAt"/>
- <router-link class="avatar-anchor" :to="notification.user | userPage">
- <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
- </router-link>
- <div class="text">
- <p>
+ <mk-avatar class="avatar" :user="notification.user"/>
+ <div>
+ <header>
<mk-reaction-icon :reaction="notification.reaction"/>
<router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link>
- </p>
+ <mk-time :time="notification.createdAt"/>
+ </header>
<router-link class="note-ref" :to="notification.note | notePage">
%fa:quote-left%{{ getNoteSummary(notification.note) }}
%fa:quote-right%
@@ -18,61 +16,55 @@
</div>
<div class="notification renote" v-if="notification.type == 'renote'">
- <mk-time :time="notification.createdAt"/>
- <router-link class="avatar-anchor" :to="notification.user | userPage">
- <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
- </router-link>
- <div class="text">
- <p>
+ <mk-avatar class="avatar" :user="notification.user"/>
+ <div>
+ <header>
%fa:retweet%
<router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link>
- </p>
+ <mk-time :time="notification.createdAt"/>
+ </header>
<router-link class="note-ref" :to="notification.note | notePage">
%fa:quote-left%{{ getNoteSummary(notification.note.renote) }}%fa:quote-right%
</router-link>
</div>
</div>
- <template v-if="notification.type == 'quote'">
- <mk-note :note="notification.note"/>
- </template>
-
<div class="notification follow" v-if="notification.type == 'follow'">
- <mk-time :time="notification.createdAt"/>
- <router-link class="avatar-anchor" :to="notification.user | userPage">
- <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
- </router-link>
- <div class="text">
- <p>
+ <mk-avatar class="avatar" :user="notification.user"/>
+ <div>
+ <header>
%fa:user-plus%
<router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link>
- </p>
+ <mk-time :time="notification.createdAt"/>
+ </header>
</div>
</div>
- <template v-if="notification.type == 'reply'">
- <mk-note :note="notification.note"/>
- </template>
-
- <template v-if="notification.type == 'mention'">
- <mk-note :note="notification.note"/>
- </template>
-
<div class="notification poll_vote" v-if="notification.type == 'poll_vote'">
- <mk-time :time="notification.createdAt"/>
- <router-link class="avatar-anchor" :to="notification.user | userPage">
- <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
- </router-link>
- <div class="text">
- <p>
+ <mk-avatar class="avatar" :user="notification.user"/>
+ <div>
+ <header>
%fa:chart-pie%
<router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link>
- </p>
+ <mk-time :time="notification.createdAt"/>
+ </header>
<router-link class="note-ref" :to="notification.note | notePage">
%fa:quote-left%{{ getNoteSummary(notification.note) }}%fa:quote-right%
</router-link>
</div>
</div>
+
+ <template v-if="notification.type == 'quote'">
+ <mk-note :note="notification.note"/>
+ </template>
+
+ <template v-if="notification.type == 'reply'">
+ <mk-note :note="notification.note"/>
+ </template>
+
+ <template v-if="notification.type == 'mention'">
+ <mk-note :note="notification.note"/>
+ </template>
</div>
</template>
@@ -91,53 +83,63 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.mk-notification
-
+root(isDark)
> .notification
padding 16px
+ font-size 12px
overflow-wrap break-word
+ @media (min-width 350px)
+ font-size 14px
+
+ @media (min-width 500px)
+ font-size 16px
+
+ @media (min-width 600px)
+ padding 24px 32px
+
&:after
content ""
display block
clear both
- > .mk-time
- display inline
- position absolute
- top 16px
- right 12px
- vertical-align top
- color rgba(0, 0, 0, 0.6)
- font-size 0.9em
-
- > .avatar-anchor
+ > .avatar
display block
float left
+ width 36px
+ height 36px
+ border-radius 6px
- img
- min-width 36px
- min-height 36px
- max-width 36px
- max-height 36px
- border-radius 6px
+ @media (min-width 500px)
+ width 42px
+ height 42px
- > .text
+ > div
float right
width calc(100% - 36px)
padding-left 8px
- p
- margin 0
+ @media (min-width 500px)
+ width calc(100% - 42px)
+
+ > header
+ display flex
+ align-items baseline
+ white-space nowrap
i, .mk-reaction-icon
margin-right 4px
+ > .mk-time
+ margin-left auto
+ color isDark ? #606984 : #c0c0c0
+ font-size 0.9em
+
> .note-preview
- color rgba(0, 0, 0, 0.7)
+ color isDark ? #fff : #717171
> .note-ref
- color rgba(0, 0, 0, 0.7)
+ color isDark ? #fff : #717171
[data-fa]
font-size 1em
@@ -147,12 +149,17 @@ export default Vue.extend({
margin-right 3px
&.renote
- .text p i
+ > div > header i
color #77B255
&.follow
- .text p i
+ > div > header i
color #53c7ce
-</style>
+.mk-notification[data-darkmode]
+ root(true)
+.mk-notification:not([data-darkmode])
+ root(false)
+
+</style>
diff --git a/src/client/app/mobile/views/components/notifications.vue b/src/client/app/mobile/views/components/notifications.vue
index ad43a27b98..8ab66940c4 100644
--- a/src/client/app/mobile/views/components/notifications.vue
+++ b/src/client/app/mobile/views/components/notifications.vue
@@ -1,18 +1,20 @@
<template>
<div class="mk-notifications">
- <div class="notifications" v-if="notifications.length != 0">
+ <transition-group name="mk-notifications" class="transition notifications">
<template v-for="(notification, i) in _notifications">
<mk-notification :notification="notification" :key="notification.id"/>
- <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date">
+ <p class="date" :key="notification.id + '_date'" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date">
<span>%fa:angle-up%{{ notification._datetext }}</span>
<span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span>
</p>
</template>
- </div>
+ </transition-group>
+
<button class="more" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications">
<template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>
{{ fetchingMoreNotifications ? '%i18n:!common.loading%' : '%i18n:!@more%' }}
</button>
+
<p class="empty" v-if="notifications.length == 0 && !fetching">%i18n:@empty%</p>
<p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
</div>
@@ -101,28 +103,29 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.mk-notifications
- margin 8px auto
- padding 0
- max-width 500px
- width calc(100% - 16px)
- background #fff
+root(isDark)
+ margin 0 auto
+ background isDark ? #282C37 :#fff
border-radius 8px
- box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+ box-shadow 0 0 2px rgba(#000, 0.1)
+ overflow hidden
@media (min-width 500px)
- margin 16px auto
- width calc(100% - 32px)
+ box-shadow 0 8px 32px rgba(#000, 0.1)
- > .notifications
+ .transition
+ .mk-notifications-enter
+ .mk-notifications-leave-to
+ opacity 0
+ transform translateY(-30px)
- > .mk-notification
- margin 0 auto
- max-width 500px
- border-bottom solid 1px rgba(0, 0, 0, 0.05)
+ > *
+ transition transform .3s ease, opacity .3s ease
- &:last-child
- border-bottom none
+ > .notifications
+
+ > .mk-notification:not(:last-child)
+ border-bottom solid 1px isDark ? #1c2023 : #eaeaea
> .date
display block
@@ -130,9 +133,9 @@ export default Vue.extend({
line-height 32px
text-align center
font-size 0.8em
- color #aaa
- background #fdfdfd
- border-bottom solid 1px rgba(0, 0, 0, 0.05)
+ color isDark ? #666b79 : #aaa
+ background isDark ? #242731 : #fdfdfd
+ border-bottom solid 1px isDark ? #1c2023 : #eaeaea
span
margin 0 16px
@@ -145,7 +148,7 @@ export default Vue.extend({
width 100%
padding 16px
color #555
- border-top solid 1px rgba(0, 0, 0, 0.05)
+ border-top solid 1px rgba(#000, 0.05)
> [data-fa]
margin-right 4px
@@ -165,4 +168,10 @@ export default Vue.extend({
> [data-fa]
margin-right 4px
+.mk-notifications[data-darkmode]
+ root(true)
+
+.mk-notifications:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/mobile/views/components/post-form.vue b/src/client/app/mobile/views/components/post-form.vue
index 861e8653ba..6d80b3046b 100644
--- a/src/client/app/mobile/views/components/post-form.vue
+++ b/src/client/app/mobile/views/components/post-form.vue
@@ -10,6 +10,11 @@
</header>
<div class="form">
<mk-note-preview v-if="reply" :note="reply"/>
+ <div v-if="visibility == 'specified'" class="visibleUsers">
+ <span v-for="u in visibleUsers">{{ u | userName }}<a @click="removeVisibleUser(u)">[x]</a></span>
+ <a @click="addVisibleUser">+ユーザーを追加</a>
+ </div>
+ <input v-show="useCw" v-model="cw" placeholder="内容への注釈 (オプション)">
<textarea v-model="text" ref="text" :disabled="posting" :placeholder="reply ? '%i18n:!@reply-placeholder%' : '%i18n:!@note-placeholder%'"></textarea>
<div class="attaches" v-show="files.length != 0">
<x-draggable class="files" :list="files" :options="{ animation: 150 }">
@@ -20,11 +25,15 @@
</div>
<mk-poll-editor v-if="poll" ref="poll" @destroyed="poll = false"/>
<mk-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/>
- <button class="upload" @click="chooseFile">%fa:upload%</button>
- <button class="drive" @click="chooseFileFromDrive">%fa:cloud%</button>
- <button class="kao" @click="kao">%fa:R smile%</button>
- <button class="poll" @click="poll = true">%fa:chart-pie%</button>
- <button class="geo" @click="geo ? removeGeo() : setGeo()">%fa:map-marker-alt%</button>
+ <footer>
+ <button class="upload" @click="chooseFile">%fa:upload%</button>
+ <button class="drive" @click="chooseFileFromDrive">%fa:cloud%</button>
+ <button class="kao" @click="kao">%fa:R smile%</button>
+ <button class="poll" @click="poll = true">%fa:chart-pie%</button>
+ <button class="poll" @click="useCw = !useCw">%fa:eye-slash%</button>
+ <button class="geo" @click="geo ? removeGeo() : setGeo()">%fa:map-marker-alt%</button>
+ <button class="visibility" @click="setVisibility" ref="visibilityButton">%fa:lock%</button>
+ </footer>
<input ref="file" class="file" type="file" accept="image/*" multiple="multiple" @change="onChangeFile"/>
</div>
</div>
@@ -33,13 +42,17 @@
<script lang="ts">
import Vue from 'vue';
import * as XDraggable from 'vuedraggable';
+import MkVisibilityChooser from '../../../common/views/components/visibility-chooser.vue';
import getKao from '../../../common/scripts/get-kao';
export default Vue.extend({
components: {
- XDraggable
+ XDraggable,
+ MkVisibilityChooser
},
+
props: ['reply'],
+
data() {
return {
posting: false,
@@ -47,21 +60,33 @@ export default Vue.extend({
uploadings: [],
files: [],
poll: false,
- geo: null
+ geo: null,
+ visibility: 'public',
+ visibleUsers: [],
+ useCw: false,
+ cw: null
};
},
+
mounted() {
+ if (this.reply && this.reply.user.host != null) {
+ this.text = `@${this.reply.user.username}@${this.reply.user.host} `;
+ }
+
this.$nextTick(() => {
this.focus();
});
},
+
methods: {
focus() {
(this.$refs.text as any).focus();
},
+
chooseFile() {
(this.$refs.file as any).click();
},
+
chooseFileFromDrive() {
(this as any).apis.chooseDriveFile({
multiple: true
@@ -69,23 +94,29 @@ export default Vue.extend({
files.forEach(this.attachMedia);
});
},
+
attachMedia(driveFile) {
this.files.push(driveFile);
this.$emit('change-attached-media', this.files);
},
+
detachMedia(file) {
this.files = this.files.filter(x => x.id != file.id);
this.$emit('change-attached-media', this.files);
},
+
onChangeFile() {
Array.from((this.$refs.file as any).files).forEach(this.upload);
},
+
upload(file) {
(this.$refs.uploader as any).upload(file);
},
+
onChangeUploadings(uploads) {
this.$emit('change-uploadings', uploads);
},
+
setGeo() {
if (navigator.geolocation == null) {
alert('お使いの端末は位置情報に対応していません');
@@ -100,23 +131,54 @@ export default Vue.extend({
enableHighAccuracy: true
});
},
+
removeGeo() {
this.geo = null;
},
+
+ setVisibility() {
+ const w = (this as any).os.new(MkVisibilityChooser, {
+ source: this.$refs.visibilityButton,
+ compact: true,
+ v: this.visibility
+ });
+ w.$once('chosen', v => {
+ this.visibility = v;
+ });
+ },
+
+ addVisibleUser() {
+ (this as any).apis.input({
+ title: 'ユーザー名を入力してください'
+ }).then(username => {
+ (this as any).api('users/show', {
+ username
+ }).then(user => {
+ this.visibleUsers.push(user);
+ });
+ });
+ },
+
+ removeVisibleUser(user) {
+ this.visibleUsers = this.visibleUsers.filter(u => u != user);
+ },
+
clear() {
this.text = '';
this.files = [];
this.poll = false;
this.$emit('change-attached-media');
},
+
post() {
this.posting = true;
- const viaMobile = (this as any).os.i.clientSettings.disableViaMobile !== true;
+ const viaMobile = (this as any).clientSettings.disableViaMobile !== true;
(this as any).api('notes/create', {
text: this.text == '' ? undefined : this.text,
mediaIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
replyId: this.reply ? this.reply.id : undefined,
poll: this.poll ? (this.$refs.poll as any).get() : undefined,
+ cw: this.useCw ? this.cw || '' : undefined,
geo: this.geo ? {
coordinates: [this.geo.longitude, this.geo.latitude],
altitude: this.geo.altitude,
@@ -125,6 +187,8 @@ export default Vue.extend({
heading: isNaN(this.geo.heading) ? null : this.geo.heading,
speed: this.geo.speed,
} : null,
+ visibility: this.visibility,
+ visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined,
viaMobile: viaMobile
}).then(data => {
this.$emit('note');
@@ -133,10 +197,12 @@ export default Vue.extend({
this.posting = false;
});
},
+
cancel() {
this.$emit('cancel');
this.$destroy();
},
+
kao() {
this.text += getKao();
}
@@ -147,29 +213,33 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.mk-post-form
+root(isDark)
max-width 500px
width calc(100% - 16px)
margin 8px auto
- background #fff
+ background isDark ? #282C37 : #fff
border-radius 8px
- box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+ box-shadow 0 0 2px rgba(#000, 0.1)
@media (min-width 500px)
margin 16px auto
width calc(100% - 32px)
+ box-shadow 0 8px 32px rgba(#000, 0.1)
+
+ @media (min-width 600px)
+ margin 32px auto
> header
- z-index 1
+ z-index 1000
height 50px
- box-shadow 0 1px 0 0 rgba(0, 0, 0, 0.1)
+ box-shadow 0 1px 0 0 isDark ? rgba(#000, 0.2) : rgba(#000, 0.1)
> .cancel
padding 0
width 50px
line-height 50px
font-size 24px
- color #555
+ color isDark ? #9baec8 : #555
> div
position absolute
@@ -203,6 +273,38 @@ export default Vue.extend({
> .mk-note-preview
padding 16px
+ > .visibleUsers
+ margin-bottom 8px
+ font-size 14px
+
+ > span
+ margin-right 16px
+ color isDark ? #fff : #666
+
+ > input
+ z-index 1
+
+ > input
+ > textarea
+ display block
+ padding 12px
+ margin 0
+ width 100%
+ font-size 16px
+ color isDark ? #fff : #333
+ background isDark ? #191d23 : #fff
+ border none
+ border-radius 0
+ box-shadow 0 1px 0 0 isDark ? rgba(#000, 0.2) : rgba(#000, 0.1)
+
+ &:disabled
+ opacity 0.5
+
+ > textarea
+ max-width 100%
+ min-width 100%
+ min-height 80px
+
> .attaches
> .files
@@ -236,40 +338,30 @@ export default Vue.extend({
> .file
display none
- > textarea
- 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
+ > footer
+ white-space nowrap
+ overflow auto
+ -webkit-overflow-scrolling touch
+ overflow-scrolling touch
- &:disabled
- opacity 0.5
+ > *
+ 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
- > .upload
- > .drive
- > .kao
- > .poll
- > .geo
- 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
+.mk-post-form[data-darkmode]
+ root(true)
-</style>
+.mk-post-form:not([data-darkmode])
+ root(false)
+</style>
diff --git a/src/client/app/mobile/views/components/sub-note-content.vue b/src/client/app/mobile/views/components/sub-note-content.vue
index 54cc74f7f5..cc50977a58 100644
--- a/src/client/app/mobile/views/components/sub-note-content.vue
+++ b/src/client/app/mobile/views/components/sub-note-content.vue
@@ -1,6 +1,7 @@
<template>
<div class="mk-sub-note-content">
<div class="body">
+ <span v-if="note.isHidden" style="opacity: 0.5">(この投稿は非公開です)</span>
<a class="reply" v-if="note.replyId">%fa:reply%</a>
<mk-note-html v-if="note.text" :text="note.text" :i="os.i"/>
<a class="rp" v-if="note.renoteId">RP: ...</a>
diff --git a/src/client/app/mobile/views/components/timeline.vue b/src/client/app/mobile/views/components/timeline.vue
deleted file mode 100644
index 11b82aa456..0000000000
--- a/src/client/app/mobile/views/components/timeline.vue
+++ /dev/null
@@ -1,113 +0,0 @@
-<template>
-<div class="mk-timeline">
- <mk-friends-maker v-if="alone"/>
- <mk-notes :notes="notes">
- <div class="init" v-if="fetching">
- %fa:spinner .pulse%%i18n:common.loading%
- </div>
- <div class="empty" v-if="!fetching && notes.length == 0">
- %fa:R comments%
- %i18n:@empty%
- </div>
- <button v-if="!fetching && existMore" @click="more" :disabled="moreFetching" slot="tail">
- <span v-if="!moreFetching">%i18n:@load-more%</span>
- <span v-if="moreFetching">%i18n:common.loading%<mk-ellipsis/></span>
- </button>
- </mk-notes>
-</div>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-
-const limit = 10;
-
-export default Vue.extend({
- props: {
- date: {
- type: Date,
- required: false,
- default: null
- }
- },
- data() {
- return {
- fetching: true,
- moreFetching: false,
- notes: [],
- existMore: false,
- connection: null,
- connectionId: null
- };
- },
- computed: {
- alone(): boolean {
- return (this as any).os.i.followingCount == 0;
- }
- },
- mounted() {
- this.connection = (this as any).os.stream.getConnection();
- this.connectionId = (this as any).os.stream.use();
-
- this.connection.on('note', this.onNote);
- this.connection.on('follow', this.onChangeFollowing);
- this.connection.on('unfollow', this.onChangeFollowing);
-
-this.fetch();
- },
- beforeDestroy() {
- this.connection.off('note', this.onNote);
- this.connection.off('follow', this.onChangeFollowing);
- this.connection.off('unfollow', this.onChangeFollowing);
- (this as any).os.stream.dispose(this.connectionId);
- },
- methods: {
- fetch(cb?) {
- this.fetching = true;
- (this as any).api('notes/timeline', {
- limit: limit + 1,
- untilDate: this.date ? (this.date as any).getTime() : undefined
- }).then(notes => {
- if (notes.length == limit + 1) {
- notes.pop();
- this.existMore = true;
- }
- this.notes = notes;
- this.fetching = false;
- this.$emit('loaded');
- if (cb) cb();
- });
- },
- more() {
- this.moreFetching = true;
- (this as any).api('notes/timeline', {
- limit: limit + 1,
- untilId: this.notes[this.notes.length - 1].id
- }).then(notes => {
- if (notes.length == limit + 1) {
- notes.pop();
- this.existMore = true;
- } else {
- this.existMore = false;
- }
- this.notes = this.notes.concat(notes);
- this.moreFetching = false;
- });
- },
- onNote(note) {
- this.notes.unshift(note);
-
- const isTop = window.scrollY > 8;
- if (isTop) this.notes.pop();
- },
- onChangeFollowing() {
- this.fetch();
- }
- }
-});
-</script>
-
-<style lang="stylus" scoped>
-.mk-friends-maker
- margin-bottom 8px
-</style>
diff --git a/src/client/app/mobile/views/components/ui.header.vue b/src/client/app/mobile/views/components/ui.header.vue
index f1b24bf2da..509463333d 100644
--- a/src/client/app/mobile/views/components/ui.header.vue
+++ b/src/client/app/mobile/views/components/ui.header.vue
@@ -32,6 +32,8 @@ export default Vue.extend({
};
},
mounted() {
+ this.$store.commit('setUiHeaderHeight', 48);
+
if ((this as any).os.isSignedIn) {
this.connection = (this as any).os.stream.getConnection();
this.connectionId = (this as any).os.stream.use();
@@ -57,9 +59,10 @@ export default Vue.extend({
}
});
- const ago = (new Date().getTime() - new Date((this as any).os.i.lastUsedAt).getTime()) / 1000
+ const ago = (new Date().getTime() - new Date((this as any).os.i.lastUsedAt).getTime()) / 1000;
const isHisasiburi = ago >= 3600;
(this as any).os.i.lastUsedAt = new Date();
+ (this as any).os.bakeMe();
if (isHisasiburi) {
(this.$refs.welcomeback as any).style.display = 'block';
(this.$refs.main as any).style.overflow = 'hidden';
@@ -141,7 +144,7 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.header
+root(isDark)
$height = 48px
position fixed
@@ -150,6 +153,9 @@ export default Vue.extend({
width 100%
box-shadow 0 1px 0 rgba(#000, 0.075)
+ &, *
+ user-select none
+
> .main
color rgba(#fff, 0.9)
@@ -162,7 +168,7 @@ export default Vue.extend({
-webkit-backdrop-filter blur(12px)
backdrop-filter blur(12px)
//background-color rgba(#1b2023, 0.75)
- background-color #1b2023
+ background-color isDark ? #313543 : #595f6f
> p
display none
@@ -239,4 +245,10 @@ export default Vue.extend({
line-height $height
border-left solid 1px rgba(#000, 0.1)
+.header[data-darkmode]
+ root(true)
+
+.header:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/mobile/views/components/ui.nav.vue b/src/client/app/mobile/views/components/ui.nav.vue
index 68cdacb3b5..5c65d52237 100644
--- a/src/client/app/mobile/views/components/ui.nav.vue
+++ b/src/client/app/mobile/views/components/ui.nav.vue
@@ -15,19 +15,20 @@
</router-link>
<div class="links">
<ul>
- <li><router-link to="/">%fa:home%%i18n:@home%%fa:angle-right%</router-link></li>
- <li><router-link to="/i/notifications">%fa:R bell%%i18n:@notifications%<template v-if="hasUnreadNotifications">%fa:circle%</template>%fa:angle-right%</router-link></li>
- <li><router-link to="/i/messaging">%fa:R comments%%i18n:@messaging%<template v-if="hasUnreadMessagingMessages">%fa:circle%</template>%fa:angle-right%</router-link></li>
- <li><router-link to="/othello">%fa:gamepad%ゲーム<template v-if="hasGameInvitations">%fa:circle%</template>%fa:angle-right%</router-link></li>
+ <li><router-link to="/" :data-active="$route.name == 'index'">%fa:home%%i18n:@home%%fa:angle-right%</router-link></li>
+ <li><router-link to="/i/notifications" :data-active="$route.name == 'notifications'">%fa:R bell%%i18n:@notifications%<template v-if="hasUnreadNotifications">%fa:circle%</template>%fa:angle-right%</router-link></li>
+ <li><router-link to="/i/messaging" :data-active="$route.name == 'messaging'">%fa:R comments%%i18n:@messaging%<template v-if="hasUnreadMessagingMessages">%fa:circle%</template>%fa:angle-right%</router-link></li>
+ <li><router-link to="/othello" :data-active="$route.name == 'othello'">%fa:gamepad%ゲーム<template v-if="hasGameInvitations">%fa:circle%</template>%fa:angle-right%</router-link></li>
</ul>
<ul>
- <li><router-link to="/i/drive">%fa:cloud%%i18n:@drive%%fa:angle-right%</router-link></li>
+ <li><router-link to="/i/drive" :data-active="$route.name == 'drive'">%fa:cloud%%i18n:@drive%%fa:angle-right%</router-link></li>
</ul>
<ul>
<li><a @click="search">%fa:search%%i18n:@search%%fa:angle-right%</a></li>
</ul>
<ul>
<li><router-link to="/i/settings">%fa:cog%%i18n:@settings%%fa:angle-right%</router-link></li>
+ <li @click="dark"><p><template v-if="_darkmode_">%fa:moon%</template><template v-else>%fa:R moon%</template><span>ダークモード</span></p></li>
</ul>
</div>
<a :href="aboutUrl"><p class="about">%i18n:@about%</p></a>
@@ -113,6 +114,9 @@ export default Vue.extend({
},
onOthelloNoInvites() {
this.hasGameInvitations = false;
+ },
+ dark() {
+ (this as any)._updateDarkmode_(!(this as any)._darkmode_);
}
}
});
@@ -121,7 +125,9 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-.nav
+root(isDark)
+ $color = isDark ? #c9d2e0 : #777
+
.backdrop
position fixed
top 0
@@ -129,7 +135,7 @@ export default Vue.extend({
z-index 1025
width 100%
height 100%
- background rgba(0, 0, 0, 0.2)
+ background isDark ? rgba(#000, 0.7) : rgba(#000, 0.2)
.body
position fixed
@@ -140,8 +146,7 @@ export default Vue.extend({
height 100%
overflow auto
-webkit-overflow-scrolling touch
- color #777
- background #fff
+ background isDark ? #16191f : #fff
.me
display block
@@ -162,7 +167,7 @@ export default Vue.extend({
left 80px
padding 0
width calc(100% - 112px)
- color #777
+ color $color
line-height 96px
overflow hidden
text-overflow ellipsis
@@ -182,14 +187,22 @@ export default Vue.extend({
font-size 1em
line-height 1em
- a
+ a, p
display block
+ margin 0
padding 0 20px
line-height 3rem
line-height calc(1rem + 30px)
- color #777
+ color $color
text-decoration none
+ &[data-active]
+ color $theme-color-foreground
+ background $theme-color
+
+ > [data-fa]:last-child
+ color $theme-color-foreground
+
> [data-fa]:first-child
margin-right 0.5em
@@ -205,18 +218,17 @@ export default Vue.extend({
padding 0 20px
font-size 1.2em
line-height calc(1rem + 30px)
- color #ccc
+ color $color
+ opacity 0.5
.about
margin 0
padding 1em 0
text-align center
font-size 0.8em
+ color $color
opacity 0.5
- a
- color #777
-
.nav-enter-active,
.nav-leave-active {
opacity: 1;
@@ -239,4 +251,10 @@ export default Vue.extend({
opacity: 0;
}
+.nav[data-darkmode]
+ root(true)
+
+.nav:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/mobile/views/components/user-list-timeline.vue b/src/client/app/mobile/views/components/user-list-timeline.vue
new file mode 100644
index 0000000000..59d6abbbc1
--- /dev/null
+++ b/src/client/app/mobile/views/components/user-list-timeline.vue
@@ -0,0 +1,93 @@
+<template>
+<div>
+ <mk-notes ref="timeline" :more="existMore ? more : null"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { UserListStream } from '../../../common/scripts/streaming/user-list';
+
+const fetchLimit = 10;
+
+export default Vue.extend({
+ props: ['list'],
+ data() {
+ return {
+ fetching: true,
+ moreFetching: false,
+ existMore: false,
+ connection: null
+ };
+ },
+ watch: {
+ $route: 'init'
+ },
+ mounted() {
+ this.init();
+ },
+ beforeDestroy() {
+ this.connection.close();
+ },
+ methods: {
+ init() {
+ if (this.connection) this.connection.close();
+ this.connection = new UserListStream((this as any).os, (this as any).os.i, this.list.id);
+ this.connection.on('note', this.onNote);
+ this.connection.on('userAdded', this.onUserAdded);
+ this.connection.on('userRemoved', this.onUserRemoved);
+
+ this.fetch();
+ },
+ fetch() {
+ this.fetching = true;
+
+ (this.$refs.timeline as any).init(() => new Promise((res, rej) => {
+ (this as any).api('notes/user-list-timeline', {
+ listId: this.list.id,
+ limit: fetchLimit + 1,
+ includeMyRenotes: (this as any).clientSettings.showMyRenotes,
+ includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes
+ }).then(notes => {
+ if (notes.length == fetchLimit + 1) {
+ notes.pop();
+ this.existMore = true;
+ }
+ res(notes);
+ this.fetching = false;
+ this.$emit('loaded');
+ }, rej);
+ }));
+ },
+ more() {
+ this.moreFetching = true;
+
+ (this as any).api('notes/user-list-timeline', {
+ listId: this.list.id,
+ limit: fetchLimit + 1,
+ untilId: (this.$refs.timeline as any).tail().id,
+ includeMyRenotes: (this as any).clientSettings.showMyRenotes,
+ includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes
+ }).then(notes => {
+ if (notes.length == fetchLimit + 1) {
+ notes.pop();
+ } else {
+ this.existMore = false;
+ }
+ notes.forEach(n => (this.$refs.timeline as any).append(n));
+ this.moreFetching = false;
+ });
+ },
+ onNote(note) {
+ // Prepend a note
+ (this.$refs.timeline as any).prepend(note);
+ },
+ onUserAdded() {
+ this.fetch();
+ },
+ onUserRemoved() {
+ this.fetch();
+ }
+ }
+});
+</script>
diff --git a/src/client/app/mobile/views/components/user-preview.vue b/src/client/app/mobile/views/components/user-preview.vue
index 23a83b5e3a..d258360911 100644
--- a/src/client/app/mobile/views/components/user-preview.vue
+++ b/src/client/app/mobile/views/components/user-preview.vue
@@ -1,8 +1,6 @@
<template>
<div class="mk-user-preview">
- <router-link class="avatar-anchor" :to="user | userPage">
- <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
- </router-link>
+ <mk-avatar class="avatar" :user="user"/>
<div class="main">
<header>
<router-link class="name" :to="user | userPage">{{ user | userName }}</router-link>
@@ -40,26 +38,19 @@ export default Vue.extend({
display block
clear both
- > .avatar-anchor
+ > .avatar
display block
float left
margin 0 10px 0 0
+ width 48px
+ height 48px
+ border-radius 6px
@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
+ width 58px
+ height 58px
+ border-radius 8px
> .main
float left
diff --git a/src/client/app/mobile/views/components/user-timeline.vue b/src/client/app/mobile/views/components/user-timeline.vue
index 40b3be035e..3ceb876596 100644
--- a/src/client/app/mobile/views/components/user-timeline.vue
+++ b/src/client/app/mobile/views/components/user-timeline.vue
@@ -1,17 +1,10 @@
<template>
<div class="mk-user-timeline">
- <mk-notes :notes="notes">
- <div class="init" v-if="fetching">
- %fa:spinner .pulse%%i18n:common.loading%
- </div>
- <div class="empty" v-if="!fetching && notes.length == 0">
+ <mk-notes ref="timeline" :more="existMore ? more : null">
+ <div slot="empty">
%fa:R comments%
{{ withMedia ? '%i18n:!@no-notes-with-media%' : '%i18n:!@no-notes%' }}
</div>
- <button v-if="!fetching && existMore" @click="more" :disabled="moreFetching" slot="tail">
- <span v-if="!moreFetching">%i18n:@load-more%</span>
- <span v-if="moreFetching">%i18n:common.loading%<mk-ellipsis/></span>
- </button>
</mk-notes>
</div>
</template>
@@ -19,58 +12,68 @@
<script lang="ts">
import Vue from 'vue';
-const limit = 10;
+const fetchLimit = 10;
export default Vue.extend({
props: ['user', 'withMedia'],
+
data() {
return {
fetching: true,
- notes: [],
existMore: false,
moreFetching: false
};
},
+
+ computed: {
+ canFetchMore(): boolean {
+ return !this.moreFetching && !this.fetching && this.existMore;
+ }
+ },
+
mounted() {
- (this as any).api('users/notes', {
- userId: this.user.id,
- withMedia: this.withMedia,
- limit: limit + 1
- }).then(notes => {
- if (notes.length == limit + 1) {
- notes.pop();
- this.existMore = true;
- }
- this.notes = notes;
- this.fetching = false;
- this.$emit('loaded');
- });
+ this.fetch();
},
+
methods: {
+ fetch() {
+ this.fetching = true;
+ (this.$refs.timeline as any).init(() => new Promise((res, rej) => {
+ (this as any).api('users/notes', {
+ userId: this.user.id,
+ withMedia: this.withMedia,
+ limit: fetchLimit + 1
+ }).then(notes => {
+ if (notes.length == fetchLimit + 1) {
+ notes.pop();
+ this.existMore = true;
+ }
+ res(notes);
+ this.fetching = false;
+ this.$emit('loaded');
+ }, rej);
+ }));
+ },
+
more() {
+ if (!this.canFetchMore) return;
+
this.moreFetching = true;
(this as any).api('users/notes', {
userId: this.user.id,
withMedia: this.withMedia,
- limit: limit + 1,
- untilId: this.notes[this.notes.length - 1].id
+ limit: fetchLimit + 1,
+ untilId: (this.$refs.timeline as any).tail().id
}).then(notes => {
- if (notes.length == limit + 1) {
+ if (notes.length == fetchLimit + 1) {
notes.pop();
- this.existMore = true;
} else {
this.existMore = false;
}
- this.notes = this.notes.concat(notes);
+ notes.forEach(n => (this.$refs.timeline as any).append(n));
this.moreFetching = false;
});
}
}
});
</script>
-
-<style lang="stylus" scoped>
-.mk-user-timeline
- max-width 600px
- margin 0 auto
-</style>
diff --git a/src/client/app/mobile/views/components/users-list.vue b/src/client/app/mobile/views/components/users-list.vue
index 8fa7a9cbe6..6175067459 100644
--- a/src/client/app/mobile/views/components/users-list.vue
+++ b/src/client/app/mobile/views/components/users-list.vue
@@ -1,8 +1,8 @@
<template>
<div class="mk-users-list">
<nav>
- <span :data-is-active="mode == 'all'" @click="mode = 'all'">%i18n:@all%<span>{{ count }}</span></span>
- <span v-if="os.isSignedIn && youKnowCount" :data-is-active="mode == 'iknow'" @click="mode = 'iknow'">%i18n:@known%<span>{{ youKnowCount }}</span></span>
+ <span :data-active="mode == 'all'" @click="mode = 'all'">%i18n:@all%<span>{{ count }}</span></span>
+ <span v-if="os.isSignedIn && youKnowCount" :data-active="mode == 'iknow'" @click="mode = 'iknow'">%i18n:@known%<span>{{ youKnowCount }}</span></span>
</nav>
<div class="users" v-if="!fetching && users.length != 0">
<mk-user-preview v-for="u in users" :user="u" :key="u.id"/>
@@ -74,7 +74,7 @@ export default Vue.extend({
justify-content center
margin 0 auto
max-width 600px
- border-bottom solid 1px rgba(0, 0, 0, 0.2)
+ border-bottom solid 1px rgba(#000, 0.2)
> span
display block
@@ -85,7 +85,7 @@ export default Vue.extend({
color #657786
border-bottom solid 2px transparent
- &[data-is-active]
+ &[data-active]
font-weight bold
color $theme-color
border-color $theme-color
@@ -97,7 +97,7 @@ export default Vue.extend({
font-size 12px
line-height 1
color #fff
- background rgba(0, 0, 0, 0.3)
+ background rgba(#000, 0.3)
border-radius 20px
> .users
@@ -106,14 +106,14 @@ export default Vue.extend({
width calc(100% - 16px)
background #fff
border-radius 8px
- box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+ box-shadow 0 0 0 1px rgba(#000, 0.2)
@media (min-width 500px)
margin 16px auto
width calc(100% - 32px)
> *
- border-bottom solid 1px rgba(0, 0, 0, 0.05)
+ border-bottom solid 1px rgba(#000, 0.05)
> .no
margin 0
diff --git a/src/client/app/mobile/views/components/widget-container.vue b/src/client/app/mobile/views/components/widget-container.vue
index 7319c90849..1bdc875763 100644
--- a/src/client/app/mobile/views/components/widget-container.vue
+++ b/src/client/app/mobile/views/components/widget-container.vue
@@ -28,7 +28,7 @@ export default Vue.extend({
.mk-widget-container
background #eee
border-radius 8px
- box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+ box-shadow 0 0 0 1px rgba(#000, 0.2)
overflow hidden
&.hideHeader
diff --git a/src/client/app/mobile/views/pages/dashboard.vue b/src/client/app/mobile/views/pages/dashboard.vue
new file mode 100644
index 0000000000..a5ca6cb4a2
--- /dev/null
+++ b/src/client/app/mobile/views/pages/dashboard.vue
@@ -0,0 +1,195 @@
+<template>
+<mk-ui>
+ <span slot="header">%fa:home%ダッシュボード</span>
+ <template slot="func">
+ <button @click="customizing = !customizing">%fa:cog%</button>
+ </template>
+ <main>
+ <template v-if="customizing">
+ <header>
+ <select v-model="widgetAdderSelected">
+ <option value="profile">プロフィール</option>
+ <option value="calendar">カレンダー</option>
+ <option value="activity">アクティビティ</option>
+ <option value="rss">RSSリーダー</option>
+ <option value="photo-stream">フォトストリーム</option>
+ <option value="slideshow">スライドショー</option>
+ <option value="version">バージョン</option>
+ <option value="access-log">アクセスログ</option>
+ <option value="server">サーバー情報</option>
+ <option value="donation">寄付のお願い</option>
+ <option value="nav">ナビゲーション</option>
+ <option value="tips">ヒント</option>
+ </select>
+ <button @click="addWidget">追加</button>
+ <p><a @click="hint">カスタマイズのヒント</a></p>
+ </header>
+ <x-draggable
+ :list="widgets"
+ :options="{ handle: '.handle', animation: 150 }"
+ @sort="onWidgetSort"
+ >
+ <div v-for="widget in widgets" class="customize-container" :key="widget.id">
+ <header>
+ <span class="handle">%fa:bars%</span>{{ widget.name }}<button class="remove" @click="removeWidget(widget)">%fa:times%</button>
+ </header>
+ <div @click="widgetFunc(widget.id)">
+ <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" :is-mobile="true"/>
+ </div>
+ </div>
+ </x-draggable>
+ </template>
+ <template v-else>
+ <component class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" :is-mobile="true" @chosen="warp"/>
+ </template>
+ </main>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as XDraggable from 'vuedraggable';
+import * as uuid from 'uuid';
+
+export default Vue.extend({
+ components: {
+ XDraggable
+ },
+ data() {
+ return {
+ showNav: false,
+ widgets: [],
+ customizing: false,
+ widgetAdderSelected: null
+ };
+ },
+ created() {
+ if ((this as any).clientSettings.mobileHome == null) {
+ Vue.set((this as any).clientSettings, 'mobileHome', [{
+ name: 'calendar',
+ id: 'a', data: {}
+ }, {
+ name: 'activity',
+ id: 'b', data: {}
+ }, {
+ name: 'rss',
+ id: 'c', data: {}
+ }, {
+ name: 'photo-stream',
+ id: 'd', data: {}
+ }, {
+ name: 'donation',
+ id: 'e', data: {}
+ }, {
+ name: 'nav',
+ id: 'f', data: {}
+ }, {
+ name: 'version',
+ id: 'g', data: {}
+ }]);
+ this.widgets = (this as any).clientSettings.mobileHome;
+ this.saveHome();
+ } else {
+ this.widgets = (this as any).clientSettings.mobileHome;
+ }
+
+ this.$watch('clientSettings', i => {
+ this.widgets = (this as any).clientSettings.mobileHome;
+ }, {
+ deep: true
+ });
+ },
+
+ mounted() {
+ document.title = 'Misskey';
+ },
+
+ methods: {
+ onHomeUpdated(data) {
+ if (data.home) {
+ (this as any).clientSettings.mobileHome = data.home;
+ this.widgets = data.home;
+ } else {
+ const w = (this as any).clientSettings.mobileHome.find(w => w.id == data.id);
+ if (w != null) {
+ w.data = data.data;
+ this.$refs[w.id][0].preventSave = true;
+ this.$refs[w.id][0].props = w.data;
+ this.widgets = (this as any).clientSettings.mobileHome;
+ }
+ }
+ },
+ hint() {
+ alert('ウィジェットを追加/削除したり並べ替えたりできます。ウィジェットを移動するには「三」をドラッグします。ウィジェットを削除するには「x」をタップします。いくつかのウィジェットはタップすることで表示を変更できます。');
+ },
+ widgetFunc(id) {
+ const w = this.$refs[id][0];
+ if (w.func) w.func();
+ },
+ onWidgetSort() {
+ this.saveHome();
+ },
+ addWidget() {
+ const widget = {
+ name: this.widgetAdderSelected,
+ id: uuid(),
+ data: {}
+ };
+
+ this.widgets.unshift(widget);
+ this.saveHome();
+ },
+ removeWidget(widget) {
+ this.widgets = this.widgets.filter(w => w.id != widget.id);
+ this.saveHome();
+ },
+ saveHome() {
+ (this as any).clientSettings.mobileHome = this.widgets;
+ (this as any).api('i/update_mobile_home', {
+ home: this.widgets
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+main
+ margin 0 auto
+ max-width 500px
+
+ @media (min-width 500px)
+ padding 8px
+
+ > header
+ padding 8px
+ background #fff
+
+ .widget
+ margin 8px
+
+ .customize-container
+ margin 8px
+ background #fff
+
+ > header
+ line-height 32px
+ background #eee
+
+ > .handle
+ padding 0 8px
+
+ > .remove
+ position absolute
+ top 0
+ right 0
+ padding 0 8px
+ line-height 32px
+
+ > div
+ padding 8px
+
+ > *
+ pointer-events none
+
+</style>
diff --git a/src/client/app/mobile/views/pages/followers.vue b/src/client/app/mobile/views/pages/followers.vue
index f3c75f71e9..33ade94e35 100644
--- a/src/client/app/mobile/views/pages/followers.vue
+++ b/src/client/app/mobile/views/pages/followers.vue
@@ -40,9 +40,6 @@ export default Vue.extend({
created() {
this.fetch();
},
- mounted() {
- document.documentElement.style.background = '#313a42';
- },
methods: {
fetch() {
Progress.start();
diff --git a/src/client/app/mobile/views/pages/following.vue b/src/client/app/mobile/views/pages/following.vue
index 88368ff778..c6d6d44281 100644
--- a/src/client/app/mobile/views/pages/following.vue
+++ b/src/client/app/mobile/views/pages/following.vue
@@ -39,9 +39,6 @@ export default Vue.extend({
created() {
this.fetch();
},
- mounted() {
- document.documentElement.style.background = '#313a42';
- },
methods: {
fetch() {
Progress.start();
diff --git a/src/client/app/mobile/views/pages/home.timeline.vue b/src/client/app/mobile/views/pages/home.timeline.vue
new file mode 100644
index 0000000000..4c1c344db1
--- /dev/null
+++ b/src/client/app/mobile/views/pages/home.timeline.vue
@@ -0,0 +1,149 @@
+<template>
+<div>
+ <mk-friends-maker v-if="src == 'home' && alone" style="margin-bottom:8px"/>
+
+ <mk-notes ref="timeline" :more="existMore ? more : null">
+ <div slot="empty">
+ %fa:R comments%
+ %i18n:@empty%
+ </div>
+ </mk-notes>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+const fetchLimit = 10;
+
+export default Vue.extend({
+ props: {
+ src: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ fetching: true,
+ moreFetching: false,
+ existMore: false,
+ connection: null,
+ connectionId: null,
+ unreadCount: 0,
+ date: null
+ };
+ },
+
+ computed: {
+ alone(): boolean {
+ return (this as any).os.i.followingCount == 0;
+ },
+
+ stream(): any {
+ return this.src == 'home'
+ ? (this as any).os.stream
+ : this.src == 'local'
+ ? (this as any).os.streams.localTimelineStream
+ : (this as any).os.streams.globalTimelineStream;
+ },
+
+ endpoint(): string {
+ return this.src == 'home'
+ ? 'notes/timeline'
+ : this.src == 'local'
+ ? 'notes/local-timeline'
+ : 'notes/global-timeline';
+ },
+
+ canFetchMore(): boolean {
+ return !this.moreFetching && !this.fetching && this.existMore;
+ }
+ },
+
+ mounted() {
+ this.connection = this.stream.getConnection();
+ this.connectionId = this.stream.use();
+
+ this.connection.on('note', this.onNote);
+ if (this.src == 'home') {
+ this.connection.on('follow', this.onChangeFollowing);
+ this.connection.on('unfollow', this.onChangeFollowing);
+ }
+
+ this.fetch();
+ },
+
+ beforeDestroy() {
+ this.connection.off('note', this.onNote);
+ if (this.src == 'home') {
+ this.connection.off('follow', this.onChangeFollowing);
+ this.connection.off('unfollow', this.onChangeFollowing);
+ }
+ this.stream.dispose(this.connectionId);
+ },
+
+ methods: {
+ fetch() {
+ this.fetching = true;
+
+ (this.$refs.timeline as any).init(() => new Promise((res, rej) => {
+ (this as any).api(this.endpoint, {
+ limit: fetchLimit + 1,
+ untilDate: this.date ? this.date.getTime() : undefined,
+ includeMyRenotes: (this as any).clientSettings.showMyRenotes,
+ includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes
+ }).then(notes => {
+ if (notes.length == fetchLimit + 1) {
+ notes.pop();
+ this.existMore = true;
+ }
+ res(notes);
+ this.fetching = false;
+ this.$emit('loaded');
+ }, rej);
+ }));
+ },
+
+ more() {
+ if (!this.canFetchMore) return;
+
+ this.moreFetching = true;
+
+ (this as any).api(this.endpoint, {
+ limit: fetchLimit + 1,
+ untilId: (this.$refs.timeline as any).tail().id,
+ includeMyRenotes: (this as any).clientSettings.showMyRenotes,
+ includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes
+ }).then(notes => {
+ if (notes.length == fetchLimit + 1) {
+ notes.pop();
+ } else {
+ this.existMore = false;
+ }
+ notes.forEach(n => (this.$refs.timeline as any).append(n));
+ this.moreFetching = false;
+ });
+ },
+
+ onNote(note) {
+ // Prepend a note
+ (this.$refs.timeline as any).prepend(note);
+ },
+
+ onChangeFollowing() {
+ this.fetch();
+ },
+
+ focus() {
+ (this.$refs.timeline as any).focus();
+ },
+
+ warp(date) {
+ this.date = date;
+ this.fetch();
+ }
+ }
+});
+</script>
diff --git a/src/client/app/mobile/views/pages/home.vue b/src/client/app/mobile/views/pages/home.vue
index 3d94dd7ce6..ad6d5ed408 100644
--- a/src/client/app/mobile/views/pages/home.vue
+++ b/src/client/app/mobile/views/pages/home.vue
@@ -1,59 +1,42 @@
<template>
<mk-ui>
- <span slot="header" @click="showTl = !showTl">
- <template v-if="showTl">%fa:home%%i18n:@timeline%</template>
- <template v-else>%fa:home%ウィジェット</template>
+ <span slot="header" @click="showNav = true">
+ <span>
+ <span v-if="src == 'home'">%fa:home%ホーム</span>
+ <span v-if="src == 'local'">%fa:R comments%ローカル</span>
+ <span v-if="src == 'global'">%fa:globe%グローバル</span>
+ <span v-if="src.startsWith('list')">%fa:list%{{ list.title }}</span>
+ </span>
<span style="margin-left:8px">
- <template v-if="showTl">%fa:angle-down%</template>
+ <template v-if="!showNav">%fa:angle-down%</template>
<template v-else>%fa:angle-up%</template>
</span>
</span>
+
<template slot="func">
- <button @click="fn" v-if="showTl">%fa:pencil-alt%</button>
- <button @click="customizing = !customizing" v-else>%fa:cog%</button>
+ <button @click="fn">%fa:pencil-alt%</button>
</template>
- <main>
- <div class="tl">
- <mk-timeline @loaded="onLoaded" v-show="showTl"/>
+
+ <main :data-darkmode="_darkmode_">
+ <div class="nav" v-if="showNav">
+ <div class="bg" @click="showNav = false"></div>
+ <div class="body">
+ <div>
+ <span :data-active="src == 'home'" @click="src = 'home'">%fa:home% ホーム</span>
+ <span :data-active="src == 'local'" @click="src = 'local'">%fa:R comments% ローカル</span>
+ <span :data-active="src == 'global'" @click="src = 'global'">%fa:globe% グローバル</span>
+ <template v-if="lists">
+ <span v-for="l in lists" :data-active="src == 'list:' + l.id" @click="src = 'list:' + l.id; list = l" :key="l.id">%fa:list% {{ l.title }}</span>
+ </template>
+ </div>
+ </div>
</div>
- <div class="widgets" v-show="!showTl">
- <template v-if="customizing">
- <header>
- <select v-model="widgetAdderSelected">
- <option value="profile">プロフィール</option>
- <option value="calendar">カレンダー</option>
- <option value="activity">アクティビティ</option>
- <option value="rss">RSSリーダー</option>
- <option value="photo-stream">フォトストリーム</option>
- <option value="slideshow">スライドショー</option>
- <option value="version">バージョン</option>
- <option value="access-log">アクセスログ</option>
- <option value="server">サーバー情報</option>
- <option value="donation">寄付のお願い</option>
- <option value="nav">ナビゲーション</option>
- <option value="tips">ヒント</option>
- </select>
- <button @click="addWidget">追加</button>
- <p><a @click="hint">カスタマイズのヒント</a></p>
- </header>
- <x-draggable
- :list="widgets"
- :options="{ handle: '.handle', animation: 150 }"
- @sort="onWidgetSort"
- >
- <div v-for="widget in widgets" class="customize-container" :key="widget.id">
- <header>
- <span class="handle">%fa:bars%</span>{{ widget.name }}<button class="remove" @click="removeWidget(widget)">%fa:times%</button>
- </header>
- <div @click="widgetFunc(widget.id)">
- <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" :is-mobile="true"/>
- </div>
- </div>
- </x-draggable>
- </template>
- <template v-else>
- <component class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" :is-mobile="true" @chosen="warp"/>
- </template>
+
+ <div class="tl">
+ <x-tl v-if="src == 'home'" ref="tl" key="home" src="home" @loaded="onLoaded"/>
+ <x-tl v-if="src == 'local'" ref="tl" key="local" src="local"/>
+ <x-tl v-if="src == 'global'" ref="tl" key="global" src="global"/>
+ <mk-user-list-timeline v-if="src.startsWith('list:')" ref="tl" :key="list.id" :list="list"/>
</div>
</main>
</mk-ui>
@@ -61,144 +44,58 @@
<script lang="ts">
import Vue from 'vue';
-import * as XDraggable from 'vuedraggable';
-import * as uuid from 'uuid';
import Progress from '../../../common/scripts/loading';
-import getNoteSummary from '../../../../../renderers/get-note-summary';
+import XTl from './home.timeline.vue';
export default Vue.extend({
components: {
- XDraggable
+ XTl
},
+
data() {
return {
- connection: null,
- connectionId: null,
- unreadCount: 0,
- showTl: true,
- widgets: [],
- customizing: false,
- widgetAdderSelected: null
+ src: 'home',
+ list: null,
+ lists: null,
+ showNav: false
};
},
- created() {
- if ((this as any).os.i.clientSettings.mobileHome == null) {
- Vue.set((this as any).os.i.clientSettings, 'mobileHome', [{
- name: 'calendar',
- id: 'a', data: {}
- }, {
- name: 'activity',
- id: 'b', data: {}
- }, {
- name: 'rss',
- id: 'c', data: {}
- }, {
- name: 'photo-stream',
- id: 'd', data: {}
- }, {
- name: 'donation',
- id: 'e', data: {}
- }, {
- name: 'nav',
- id: 'f', data: {}
- }, {
- name: 'version',
- id: 'g', data: {}
- }]);
- this.widgets = (this as any).os.i.clientSettings.mobileHome;
- this.saveHome();
- } else {
- this.widgets = (this as any).os.i.clientSettings.mobileHome;
+
+ watch: {
+ src() {
+ this.showNav = false;
+ },
+
+ showNav(v) {
+ if (v && this.lists === null) {
+ (this as any).api('users/lists/list').then(lists => {
+ this.lists = lists;
+ });
+ }
}
+ },
- this.$watch('os.i.clientSettings', i => {
- this.widgets = (this as any).os.i.clientSettings.mobileHome;
- }, {
- deep: true
- });
+ created() {
+ if ((this as any).os.i.followingCount == 0) {
+ this.src = 'local';
+ }
},
+
mounted() {
document.title = 'Misskey';
- document.documentElement.style.background = '#313a42';
-
- this.connection = (this as any).os.stream.getConnection();
- this.connectionId = (this as any).os.stream.use();
-
- this.connection.on('note', this.onStreamNote);
- this.connection.on('mobile_home_updated', this.onHomeUpdated);
- document.addEventListener('visibilitychange', this.onVisibilitychange, false);
Progress.start();
},
- beforeDestroy() {
- this.connection.off('note', this.onStreamNote);
- this.connection.off('mobile_home_updated', this.onHomeUpdated);
- (this as any).os.stream.dispose(this.connectionId);
- document.removeEventListener('visibilitychange', this.onVisibilitychange);
- },
+
methods: {
fn() {
(this as any).apis.post();
},
+
onLoaded() {
Progress.done();
},
- onStreamNote(note) {
- if (document.hidden && note.userId !== (this as any).os.i.id) {
- this.unreadCount++;
- document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`;
- }
- },
- onVisibilitychange() {
- if (!document.hidden) {
- this.unreadCount = 0;
- document.title = 'Misskey';
- }
- },
- onHomeUpdated(data) {
- if (data.home) {
- (this as any).os.i.clientSettings.mobileHome = data.home;
- this.widgets = data.home;
- } else {
- const w = (this as any).os.i.clientSettings.mobileHome.find(w => w.id == data.id);
- if (w != null) {
- w.data = data.data;
- this.$refs[w.id][0].preventSave = true;
- this.$refs[w.id][0].props = w.data;
- this.widgets = (this as any).os.i.clientSettings.mobileHome;
- }
- }
- },
- hint() {
- alert('ウィジェットを追加/削除したり並べ替えたりできます。ウィジェットを移動するには「三」をドラッグします。ウィジェットを削除するには「x」をタップします。いくつかのウィジェットはタップすることで表示を変更できます。');
- },
- widgetFunc(id) {
- const w = this.$refs[id][0];
- if (w.func) w.func();
- },
- onWidgetSort() {
- this.saveHome();
- },
- addWidget() {
- const widget = {
- name: this.widgetAdderSelected,
- id: uuid(),
- data: {}
- };
- this.widgets.unshift(widget);
- this.saveHome();
- },
- removeWidget(widget) {
- this.widgets = this.widgets.filter(w => w.id != widget.id);
- this.saveHome();
- },
- saveHome() {
- (this as any).os.i.clientSettings.mobileHome = this.widgets;
- (this as any).api('i/update_mobile_home', {
- home: this.widgets
- });
- },
warp() {
}
@@ -207,53 +104,74 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-main
+@import '~const.styl'
- > .tl
- > .mk-timeline
- max-width 600px
+root(isDark)
+ > .nav
+ > .bg
+ position fixed
+ z-index 10000
+ top 0
+ left 0
+ width 100%
+ height 100%
+ background rgba(#000, 0.5)
+
+ > .body
+ position fixed
+ z-index 10001
+ top 56px
+ left 0
+ right 0
+ width 300px
margin 0 auto
- padding 8px
+ background isDark ? #272f3a : #fff
+ border-radius 8px
+ box-shadow 0 0 16px rgba(#000, 0.1)
- @media (min-width 500px)
- padding 16px
+ $balloon-size = 16px
- > .widgets
- margin 0 auto
- max-width 500px
+ &:after
+ content ""
+ display block
+ position absolute
+ top -($balloon-size * 2) + 1.5px
+ left s('calc(50% - %s)', $balloon-size)
+ border-top solid $balloon-size transparent
+ border-left solid $balloon-size transparent
+ border-right solid $balloon-size transparent
+ border-bottom solid $balloon-size isDark ? #272f3a : #fff
- @media (min-width 500px)
- padding 8px
+ > div
+ padding 8px 0
- > header
- padding 8px
- background #fff
+ > *
+ display block
+ padding 8px 16px
+ color isDark ? #cdd0d8 : #666
- .widget
- margin 8px
+ &[data-active]
+ color $theme-color-foreground
+ background $theme-color
- .customize-container
- margin 8px
- background #fff
+ &:not([data-active]):hover
+ background isDark ? #353e4a : #eee
- > header
- line-height 32px
- background #eee
+ > .tl
+ max-width 680px
+ margin 0 auto
+ padding 8px
- > .handle
- padding 0 8px
+ @media (min-width 500px)
+ padding 16px
- > .remove
- position absolute
- top 0
- right 0
- padding 0 8px
- line-height 32px
+ @media (min-width 600px)
+ padding 32px
- > div
- padding 8px
+main[data-darkmode]
+ root(true)
- > *
- pointer-events none
+main:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/mobile/views/pages/note.vue b/src/client/app/mobile/views/pages/note.vue
index c866be8a14..146d89d22b 100644
--- a/src/client/app/mobile/views/pages/note.vue
+++ b/src/client/app/mobile/views/pages/note.vue
@@ -2,11 +2,13 @@
<mk-ui>
<span slot="header">%fa:R sticky-note%%i18n:@title%</span>
<main v-if="!fetching">
- <a v-if="note.next" :href="note.next">%fa:angle-up%%i18n:@next%</a>
<div>
<mk-note-detail :note="note"/>
</div>
- <a v-if="note.prev" :href="note.prev">%fa:angle-down%%i18n:@prev%</a>
+ <footer>
+ <router-link v-if="note.prev" :to="note.prev">%fa:angle-left% %i18n:@prev%</router-link>
+ <router-link v-if="note.next" :to="note.next">%i18n:@next% %fa:angle-right%</router-link>
+ </footer>
</main>
</mk-ui>
</template>
@@ -30,7 +32,6 @@ export default Vue.extend({
},
mounted() {
document.title = 'Misskey';
- document.documentElement.style.background = '#313a42';
},
methods: {
fetch() {
@@ -53,33 +54,24 @@ export default Vue.extend({
<style lang="stylus" scoped>
main
text-align center
+ padding 8px
- > div
- margin 8px auto
- padding 0
- max-width 500px
- width calc(100% - 16px)
-
- @media (min-width 500px)
- margin 16px auto
- width calc(100% - 32px)
-
- > a
- display inline-block
+ @media (min-width 500px)
+ padding 16px
- &:first-child
- margin-top 8px
+ @media (min-width 600px)
+ padding 32px
- @media (min-width 500px)
- margin-top 16px
-
- &:last-child
- margin-bottom 8px
+ > div
+ margin 0 auto
+ padding 0
+ max-width 600px
- @media (min-width 500px)
- margin-bottom 16px
+ > footer
+ margin-top 16px
- > [data-fa]
- margin-right 4px
+ > a
+ display inline-block
+ margin 0 16px
</style>
diff --git a/src/client/app/mobile/views/pages/notifications.vue b/src/client/app/mobile/views/pages/notifications.vue
index cd2b633676..d0c0fe9535 100644
--- a/src/client/app/mobile/views/pages/notifications.vue
+++ b/src/client/app/mobile/views/pages/notifications.vue
@@ -2,7 +2,10 @@
<mk-ui>
<span slot="header">%fa:R bell%%i18n:@notifications%</span>
<template slot="func"><button @click="fn">%fa:check%</button></template>
- <mk-notifications @fetched="onFetched"/>
+
+ <main>
+ <mk-notifications @fetched="onFetched"/>
+ </main>
</mk-ui>
</template>
@@ -13,7 +16,6 @@ import Progress from '../../../common/scripts/loading';
export default Vue.extend({
mounted() {
document.title = 'Misskey | %i18n:@notifications%';
- document.documentElement.style.background = '#313a42';
Progress.start();
},
@@ -30,3 +32,20 @@ export default Vue.extend({
}
});
</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+main
+ width 100%
+ max-width 680px
+ margin 0 auto
+ padding 8px
+
+ @media (min-width 500px)
+ padding 16px
+
+ @media (min-width 600px)
+ padding 32px
+
+</style>
diff --git a/src/client/app/mobile/views/pages/profile-setting.vue b/src/client/app/mobile/views/pages/profile-setting.vue
index 59da71c67d..7048cdef31 100644
--- a/src/client/app/mobile/views/pages/profile-setting.vue
+++ b/src/client/app/mobile/views/pages/profile-setting.vue
@@ -59,7 +59,6 @@ export default Vue.extend({
},
mounted() {
document.title = 'Misskey | %i18n:@title%';
- document.documentElement.style.background = '#313a42';
},
methods: {
setAvatar() {
@@ -137,7 +136,7 @@ export default Vue.extend({
.form
position relative
background #fff
- box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+ box-shadow 0 0 0 1px rgba(#000, 0.2)
border-radius 8px
&:before
@@ -146,7 +145,7 @@ export default Vue.extend({
position absolute
bottom -20px
left calc(50% - 10px)
- border-top solid 10px rgba(0, 0, 0, 0.2)
+ border-top solid 10px rgba(#000, 0.2)
border-right solid 10px transparent
border-bottom solid 10px transparent
border-left solid 10px transparent
diff --git a/src/client/app/mobile/views/pages/search.vue b/src/client/app/mobile/views/pages/search.vue
index 31035f666a..f038a6f81f 100644
--- a/src/client/app/mobile/views/pages/search.vue
+++ b/src/client/app/mobile/views/pages/search.vue
@@ -39,7 +39,6 @@ export default Vue.extend({
},
mounted() {
document.title = `%i18n:@search%: ${this.q} | Misskey`;
- document.documentElement.style.background = '#313a42';
this.fetch();
},
@@ -85,7 +84,7 @@ export default Vue.extend({
width calc(100% - 16px)
background #fff
border-radius 8px
- box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+ box-shadow 0 0 0 1px rgba(#000, 0.2)
@media (min-width 500px)
margin 16px auto
diff --git a/src/client/app/mobile/views/pages/selectdrive.vue b/src/client/app/mobile/views/pages/selectdrive.vue
index 741559ed0b..d730e4fcff 100644
--- a/src/client/app/mobile/views/pages/selectdrive.vue
+++ b/src/client/app/mobile/views/pages/selectdrive.vue
@@ -62,7 +62,7 @@ export default Vue.extend({
width 100%
z-index 1000
background #fff
- box-shadow 0 1px rgba(0, 0, 0, 0.1)
+ box-shadow 0 1px rgba(#000, 0.1)
> h1
margin 0
diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue
index 8ae087749f..0e9c5ea962 100644
--- a/src/client/app/mobile/views/pages/settings.vue
+++ b/src/client/app/mobile/views/pages/settings.vue
@@ -34,7 +34,6 @@ export default Vue.extend({
},
mounted() {
document.title = 'Misskey | %i18n:@settings%';
- document.documentElement.style.background = '#313a42';
},
methods: {
signout() {
@@ -63,7 +62,7 @@ export default Vue.extend({
width calc(100% - 32px)
list-style none
background #fff
- border solid 1px rgba(0, 0, 0, 0.2)
+ border solid 1px rgba(#000, 0.2)
border-radius $radius
> li
@@ -71,7 +70,7 @@ export default Vue.extend({
border-bottom solid 1px #ddd
&:hover
- background rgba(0, 0, 0, 0.1)
+ background rgba(#000, 0.1)
&:first-child
border-top-left-radius $radius
diff --git a/src/client/app/mobile/views/pages/signup.vue b/src/client/app/mobile/views/pages/signup.vue
index 9dc07a4b86..b8245beb00 100644
--- a/src/client/app/mobile/views/pages/signup.vue
+++ b/src/client/app/mobile/views/pages/signup.vue
@@ -40,7 +40,7 @@ export default Vue.extend({
.form
background #fff
- border solid 1px rgba(0, 0, 0, 0.2)
+ border solid 1px rgba(#000, 0.2)
border-radius 8px
overflow hidden
diff --git a/src/client/app/mobile/views/pages/user.vue b/src/client/app/mobile/views/pages/user.vue
index 04db482df2..27482dc215 100644
--- a/src/client/app/mobile/views/pages/user.vue
+++ b/src/client/app/mobile/views/pages/user.vue
@@ -1,14 +1,15 @@
<template>
<mk-ui>
- <span slot="header" v-if="!fetching">%fa:user% {{ user | userName }}</span>
- <main v-if="!fetching">
- <div class="is-remote" v-if="user.host != null"><p>%fa:exclamation-triangle% %i18n:@is-remote% <a :href="user.url || user.uri" target="_blank">%i18n:@view-remote%</a></p></div>
+ <template slot="header" v-if="!fetching"><img :src="`${user.avatarUrl}?thumbnail&size=64`" alt="">{{ user | userName }}</template>
+ <main v-if="!fetching" :data-darkmode="_darkmode_">
+ <div class="is-suspended" v-if="user.isSuspended"><p>%fa:exclamation-triangle% %i18n:@is-suspended%</p></div>
+ <div class="is-remote" v-if="user.host != null"><p>%fa:exclamation-triangle% %i18n:@is-remote%<a :href="user.url || user.uri" target="_blank">%i18n:@view-remote%</a></p></div>
<header>
- <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl}?thumbnail&size=1024)` : ''"></div>
+ <div class="banner" :style="style"></div>
<div class="body">
<div class="top">
<a class="avatar">
- <img :src="`${user.avatarUrl}?thumbnail&size=200`" alt="avatar"/>
+ <img :src="user.avatarUrl" alt="avatar"/>
</a>
<mk-follow-button v-if="os.isSignedIn && os.i.id != user.id" :user="user"/>
</div>
@@ -44,9 +45,9 @@
</header>
<nav>
<div class="nav-container">
- <a :data-is-active="page == 'home'" @click="page = 'home'">%i18n:@overview%</a>
- <a :data-is-active="page == 'notes'" @click="page = 'notes'">%i18n:@timeline%</a>
- <a :data-is-active="page == 'media'" @click="page = 'media'">%i18n:@media%</a>
+ <a :data-active="page == 'home'" @click="page = 'home'">%fa:home% %i18n:@overview%</a>
+ <a :data-active="page == 'notes'" @click="page = 'notes'">%fa:R comment-alt% %i18n:@timeline%</a>
+ <a :data-active="page == 'media'" @click="page = 'media'">%fa:image% %i18n:@media%</a>
</div>
</nav>
<div class="body">
@@ -79,6 +80,13 @@ export default Vue.extend({
computed: {
age(): number {
return age(this.user.profile.birthday);
+ },
+ style(): any {
+ if (this.user.bannerUrl == null) return {};
+ return {
+ backgroundColor: this.user.bannerColor ? `rgb(${ this.user.bannerColor.join(',') })` : null,
+ backgroundImage: `url(${ this.user.bannerUrl })`
+ };
}
},
watch: {
@@ -87,9 +95,6 @@ export default Vue.extend({
created() {
this.fetch();
},
- mounted() {
- document.documentElement.style.background = '#313a42';
- },
methods: {
fetch() {
Progress.start();
@@ -109,27 +114,38 @@ export default Vue.extend({
<style lang="stylus" scoped>
@import '~const.styl'
-main
+root(isDark)
+ $bg = isDark ? #22252f : #f7f7f7
+
+ > .is-suspended
> .is-remote
- padding 16px
- color #573c08
- background #fff0db
+ &.is-suspended
+ color #570808
+ background #ffdbdb
+
+ &.is-remote
+ color #573c08
+ background #fff0db
> p
margin 0 auto
- max-width 1024px
+ padding 14px
+ max-width 600px
+ font-size 14px
> a
font-weight bold
@media (max-width 500px)
+ padding 12px
font-size 12px
> header
+ background $bg
> .banner
padding-bottom 33.3%
- background-color #1b1b1b
+ background-color isDark ? #5f7273 : #cacaca
background-size cover
background-position center
@@ -156,13 +172,14 @@ main
left -2px
bottom -2px
width 100%
- border 3px solid #313a42
+ background $bg
+ border 3px solid $bg
border-radius 6px
@media (min-width 500px)
left -4px
bottom -4px
- border 4px solid #313a42
+ border 4px solid $bg
border-radius 12px
> .mk-follow-button
@@ -176,26 +193,26 @@ main
margin 0
line-height 22px
font-size 20px
- color #fff
+ color isDark ? #fff : #757c82
> .username
display inline-block
line-height 20px
font-size 16px
font-weight bold
- color #657786
+ color isDark ? #657786 : #969ea5
> .followed
margin-left 8px
padding 2px 4px
font-size 12px
- color #657786
- background #f8f8f8
+ color isDark ? #657786 : #fff
+ background isDark ? #f8f8f8 : #a7bec7
border-radius 4px
> .description
margin 8px 0
- color #fff
+ color isDark ? #fff : #757c82
> .info
margin 8px 0
@@ -203,14 +220,14 @@ main
> p
display inline
margin 0 16px 0 0
- color #a9b9c1
+ color isDark ? #a9b9c1 : #90989c
> i
margin-right 4px
> .status
> a
- color #657786
+ color isDark ? #657786 : #818a92
&:not(:last-child)
margin-right 16px
@@ -218,7 +235,7 @@ main
> b
margin-right 4px
font-size 16px
- color #fff
+ color isDark ? #fff : #787e86
> i
font-size 14px
@@ -226,9 +243,9 @@ main
> nav
position -webkit-sticky
position sticky
- top 48px
- box-shadow 0 4px 4px rgba(0, 0, 0, 0.3)
- background-color #313a42
+ top 47px
+ box-shadow 0 4px 4px isDark ? rgba(#000, 0.3) : rgba(#000, 0.07)
+ background-color $bg
z-index 1
> .nav-container
@@ -241,21 +258,36 @@ main
display block
flex 1 1
text-align center
- line-height 52px
- font-size 14px
+ line-height 48px
+ font-size 12px
text-decoration none
- color #657786
+ color isDark ? #657786 : #9ca1a5
border-bottom solid 2px transparent
- &[data-is-active]
+ @media (min-width 400px)
+ line-height 52px
+ font-size 14px
+
+ &[data-active]
font-weight bold
color $theme-color
border-color $theme-color
> .body
+ max-width 680px
+ margin 0 auto
padding 8px
@media (min-width 500px)
padding 16px
+ @media (min-width 600px)
+ padding 32px
+
+main[data-darkmode]
+ root(true)
+
+main:not([data-darkmode])
+ root(false)
+
</style>
diff --git a/src/client/app/mobile/views/pages/user/home.vue b/src/client/app/mobile/views/pages/user/home.vue
index 4ba2ffd1df..d02daf5027 100644
--- a/src/client/app/mobile/views/pages/user/home.vue
+++ b/src/client/app/mobile/views/pages/user/home.vue
@@ -54,30 +54,39 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
-.root.home
+root(isDark)
max-width 600px
margin 0 auto
> .mk-note-detail
margin 0 0 8px 0
+ @media (min-width 500px)
+ margin 0 0 16px 0
+
> section
- background #eee
+ background isDark ? #21242f : #eee
border-radius 8px
- box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+ box-shadow 0 4px 16px rgba(#000, 0.1)
&:not(:last-child)
margin-bottom 8px
+ @media (min-width 500px)
+ margin-bottom 16px
+
> h2
margin 0
padding 8px 10px
font-size 15px
font-weight normal
- color #465258
- background #fff
+ color isDark ? #b8c5cc : #465258
+ background isDark ? #282c37 : #fff
border-radius 8px 8px 0 0
+ @media (min-width 500px)
+ padding 10px 16px
+
> i
margin-right 6px
@@ -89,6 +98,12 @@ export default Vue.extend({
display block
margin 16px
text-align center
- color #cad2da
+ color isDark ? #cad2da : #929aa0
+
+.root.home[data-darkmode]
+ root(true)
+
+.root.home:not([data-darkmode])
+ root(false)
</style>
diff --git a/src/client/app/mobile/views/pages/welcome.vue b/src/client/app/mobile/views/pages/welcome.vue
index 27baf8bee4..64cfa5a46c 100644
--- a/src/client/app/mobile/views/pages/welcome.vue
+++ b/src/client/app/mobile/views/pages/welcome.vue
@@ -1,33 +1,33 @@
<template>
<div class="welcome">
- <h1><b>Misskey</b>へようこそ</h1>
- <p>Twitter風ミニブログSNS、Misskeyへようこそ。共有したいことを投稿したり、タイムラインでみんなの投稿を読むこともできます。<br><a href="/signup">アカウントを作成する</a></p>
- <div class="form">
- <p>%fa:lock% ログイン</p>
- <div>
- <form @submit.prevent="onSubmit">
- <input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" placeholder="ユーザー名" autofocus required @change="onUsernameChange"/>
- <input v-model="password" type="password" placeholder="パスワード" required/>
- <input v-if="user && user.twoFactorEnabled" v-model="token" type="number" placeholder="トークン" required/>
- <button type="submit" :disabled="signing">{{ signing ? 'ログインしています' : 'ログイン' }}</button>
- </form>
+ <div>
+ <h1><b>Misskey</b>へようこそ</h1>
+ <p>Twitter風ミニブログSNS、Misskeyへようこそ。共有したいことを投稿したり、タイムラインでみんなの投稿を読むこともできます。<br><a href="/signup">アカウントを作成する</a></p>
+ <div class="form">
+ <p>%fa:lock% ログイン</p>
<div>
- <a :href="`${apiUrl}/signin/twitter`">Twitterでログイン</a>
+ <form @submit.prevent="onSubmit">
+ <input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" placeholder="ユーザー名" autofocus required @change="onUsernameChange"/>
+ <input v-model="password" type="password" placeholder="パスワード" required/>
+ <input v-if="user && user.twoFactorEnabled" v-model="token" type="number" placeholder="トークン" required/>
+ <button type="submit" :disabled="signing">{{ signing ? 'ログインしています' : 'ログイン' }}</button>
+ </form>
+ <div>
+ <a :href="`${apiUrl}/signin/twitter`">Twitterでログイン</a>
+ </div>
</div>
</div>
+ <div class="tl">
+ <p>%fa:comments R% タイムラインを見てみる</p>
+ <mk-welcome-timeline/>
+ </div>
+ <div class="users">
+ <mk-avatar class="avatar" v-for="user in users" :key="user.id" :user="user"/>
+ </div>
+ <footer>
+ <small>{{ copyright }}</small>
+ </footer>
</div>
- <div class="tl">
- <p>%fa:comments R% タイムラインを見てみる</p>
- <mk-welcome-timeline/>
- </div>
- <div class="users">
- <router-link v-for="user in users" :key="user.id" class="avatar-anchor" :to="`/@${user.username}`">
- <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
- </router-link>
- </div>
- <footer>
- <small>{{ copyright }}</small>
- </footer>
</div>
</template>
@@ -84,123 +84,116 @@ export default Vue.extend({
<style lang="stylus" scoped>
.welcome
- padding 16px
- margin 0 auto
- max-width 500px
-
- h1
- margin 0
- padding 8px
- font-size 1.5em
- font-weight normal
- color #cacac3
-
- & + p
- margin 0 0 16px 0
- padding 0 8px 0 8px
- color #949fa9
+ background linear-gradient(to bottom, #1e1d65, #bd6659)
- .form
- margin-bottom 16px
- background #fff
- border solid 1px rgba(0, 0, 0, 0.2)
- border-radius 8px
- overflow hidden
+ > div
+ padding 16px
+ margin 0 auto
+ max-width 500px
- > p
+ h1
margin 0
- padding 12px 20px
- color #555
- background #f5f5f5
- border-bottom solid 1px #ddd
+ padding 8px
+ font-size 1.5em
+ font-weight normal
+ color #cacac3
- > div
+ & + p
+ margin 0 0 16px 0
+ padding 0 8px 0 8px
+ color #949fa9
- > form
- padding 16px
+ .form
+ margin-bottom 16px
+ background #fff
+ border solid 1px rgba(#000, 0.2)
+ border-radius 8px
+ overflow hidden
+
+ > p
+ margin 0
+ padding 12px 20px
+ color #555
+ background #f5f5f5
border-bottom solid 1px #ddd
- input
- display block
- padding 12px
- margin 0 0 16px 0
- width 100%
- font-size 1em
- color rgba(0, 0, 0, 0.7)
- background #fff
- outline none
- border solid 1px #ddd
- border-radius 4px
+ > div
+
+ > form
+ padding 16px
+ border-bottom solid 1px #ddd
- button
- display block
- width 100%
- padding 10px
- margin 0
- color #333
- font-size 1em
- text-align center
- text-decoration none
- text-shadow 0 1px 0 rgba(255, 255, 255, 0.9)
- background-image linear-gradient(#fafafa, #eaeaea)
- border 1px solid #ddd
- border-bottom-color #cecece
- border-radius 4px
+ input
+ display block
+ padding 12px
+ margin 0 0 16px 0
+ width 100%
+ font-size 1em
+ color rgba(#000, 0.7)
+ background #fff
+ outline none
+ border solid 1px #ddd
+ border-radius 4px
- &:active
- background-color #767676
- background-image none
- border-color #444
- box-shadow 0 1px 3px rgba(0, 0, 0, 0.075), inset 0 0 5px rgba(0, 0, 0, 0.2)
+ button
+ display block
+ width 100%
+ padding 10px
+ margin 0
+ color #333
+ font-size 1em
+ text-align center
+ text-decoration none
+ text-shadow 0 1px 0 rgba(255, 255, 255, 0.9)
+ background-image linear-gradient(#fafafa, #eaeaea)
+ border 1px solid #ddd
+ border-bottom-color #cecece
+ border-radius 4px
- > div
- padding 16px
- text-align center
+ &:active
+ background-color #767676
+ background-image none
+ border-color #444
+ box-shadow 0 1px 3px rgba(#000, 0.075), inset 0 0 5px rgba(#000, 0.2)
- > .tl
- background #fff
- border solid 1px rgba(0, 0, 0, 0.2)
- border-radius 8px
- overflow hidden
+ > div
+ padding 16px
+ text-align center
- > p
- margin 0
- padding 12px 20px
- color #555
- background #f5f5f5
- border-bottom solid 1px #ddd
+ > .tl
+ background #fff
+ border solid 1px rgba(#000, 0.2)
+ border-radius 8px
+ overflow hidden
- > .mk-welcome-timeline
- max-height 300px
- overflow auto
+ > p
+ margin 0
+ padding 12px 20px
+ color #555
+ background #f5f5f5
+ border-bottom solid 1px #ddd
- > .users
- margin 12px 0 0 0
+ > .mk-welcome-timeline
+ max-height 300px
+ overflow auto
- > *
- display inline-block
- margin 4px
+ > .users
+ margin 12px 0 0 0
> *
display inline-block
+ margin 4px
width 38px
height 38px
- vertical-align top
border-radius 6px
- > footer
- text-align center
- color #fff
-
- > small
- display block
- margin 16px 0 0 0
- opacity 0.7
+ > footer
+ text-align center
+ color #fff
-</style>
+ > small
+ display block
+ margin 16px 0 0 0
+ opacity 0.7
-<style lang="stylus">
-html
-body
- background linear-gradient(to bottom, #1e1d65, #bd6659)
</style>
diff --git a/src/client/app/mobile/views/widgets/activity.vue b/src/client/app/mobile/views/widgets/activity.vue
index 48dcafb3ed..7763be41f5 100644
--- a/src/client/app/mobile/views/widgets/activity.vue
+++ b/src/client/app/mobile/views/widgets/activity.vue
@@ -21,6 +21,7 @@ export default define({
methods: {
func() {
this.props.compact = !this.props.compact;
+ this.save();
}
}
});
diff --git a/src/client/app/mobile/views/widgets/profile.vue b/src/client/app/mobile/views/widgets/profile.vue
index 502f886ceb..59c1ec7c0e 100644
--- a/src/client/app/mobile/views/widgets/profile.vue
+++ b/src/client/app/mobile/views/widgets/profile.vue
@@ -34,7 +34,7 @@ export default define({
display block
width 100%
height 100%
- background rgba(0, 0, 0, 0.5)
+ background rgba(#000, 0.5)
.avatar
display block
@@ -47,7 +47,7 @@ export default define({
left ((100px - 58px) / 2)
border none
border-radius 100%
- box-shadow 0 0 16px rgba(0, 0, 0, 0.5)
+ box-shadow 0 0 16px rgba(#000, 0.5)
.name
display block
@@ -58,6 +58,6 @@ export default define({
line-height 100px
color #fff
font-weight bold
- text-shadow 0 0 8px rgba(0, 0, 0, 0.5)
+ text-shadow 0 0 8px rgba(#000, 0.5)
</style>
diff --git a/src/client/app/store.ts b/src/client/app/store.ts
new file mode 100644
index 0000000000..0bdfdef6a0
--- /dev/null
+++ b/src/client/app/store.ts
@@ -0,0 +1,92 @@
+import Vuex from 'vuex';
+import MiOS from './mios';
+
+const defaultSettings = {
+ home: [],
+ fetchOnScroll: true,
+ showMaps: true,
+ showPostFormOnTopOfTl: false,
+ circleIcons: true,
+ gradientWindowHeader: false,
+ showReplyTarget: true,
+ showMyRenotes: true,
+ showRenotedMyNotes: true
+};
+
+export default (os: MiOS) => new Vuex.Store({
+ plugins: [store => {
+ store.subscribe((mutation, state) => {
+ if (mutation.type.startsWith('settings/')) {
+ localStorage.setItem('settings', JSON.stringify(state.settings.data));
+ }
+ });
+ }],
+
+ state: {
+ uiHeaderHeight: 0
+ },
+
+ mutations: {
+ setUiHeaderHeight(state, height) {
+ state.uiHeaderHeight = height;
+ }
+ },
+
+ modules: {
+ settings: {
+ namespaced: true,
+
+ state: {
+ data: defaultSettings
+ },
+
+ mutations: {
+ set(state, x: { key: string; value: any }) {
+ state.data[x.key] = x.value;
+ },
+
+ setHome(state, data) {
+ state.data.home = data;
+ },
+
+ setHomeWidget(state, x) {
+ const w = state.data.home.find(w => w.id == x.id);
+ if (w) {
+ w.data = x.data;
+ }
+ },
+
+ addHomeWidget(state, widget) {
+ state.data.home.unshift(widget);
+ }
+ },
+
+ actions: {
+ merge(ctx, settings) {
+ Object.entries(settings).forEach(([key, value]) => {
+ ctx.commit('set', { key, value });
+ });
+ },
+
+ set(ctx, x) {
+ ctx.commit('set', x);
+
+ if (os.isSignedIn) {
+ os.api('i/update_client_setting', {
+ name: x.key,
+ value: x.value
+ });
+ }
+ },
+
+ addHomeWidget(ctx, widget) {
+ ctx.commit('addHomeWidget', widget);
+
+ os.api('i/update_home', {
+ home: ctx.state.data.home
+ });
+ }
+ }
+ }
+ }
+});
diff --git a/src/client/docs/api/endpoints/view.pug b/src/client/docs/api/endpoints/view.pug
index d271a5517a..f8795c8442 100644
--- a/src/client/docs/api/endpoints/view.pug
+++ b/src/client/docs/api/endpoints/view.pug
@@ -2,7 +2,7 @@ extends ../../layout.pug
include ../mixins
block meta
- link(rel="stylesheet" href="/assets/api/endpoints/style.css")
+ link(rel="stylesheet" href="/docs/assets/api/endpoints/style.css")
block main
h1= endpoint
diff --git a/src/client/docs/api/entities/note.yaml b/src/client/docs/api/entities/note.yaml
index 718d331d13..6fd26543bb 100644
--- a/src/client/docs/api/entities/note.yaml
+++ b/src/client/docs/api/entities/note.yaml
@@ -29,12 +29,6 @@ props:
desc:
ja: "投稿の本文 (ローカルの場合Markdown風のフォーマット)"
en: "The text of this note (in Markdown like format if local)"
- - name: "textHtml"
- type: "string"
- optional: true
- desc:
- ja: "投稿の本文 (HTML) (投稿時は無視)"
- en: "The text of this note (in HTML. Ignored when posting.)"
- name: "mediaIds"
type: "id(DriveFile)[]"
optional: true
diff --git a/src/client/docs/api/entities/post.yaml b/src/client/docs/api/entities/post.yaml
index 718d331d13..6fd26543bb 100644
--- a/src/client/docs/api/entities/post.yaml
+++ b/src/client/docs/api/entities/post.yaml
@@ -29,12 +29,6 @@ props:
desc:
ja: "投稿の本文 (ローカルの場合Markdown風のフォーマット)"
en: "The text of this note (in Markdown like format if local)"
- - name: "textHtml"
- type: "string"
- optional: true
- desc:
- ja: "投稿の本文 (HTML) (投稿時は無視)"
- en: "The text of this note (in HTML. Ignored when posting.)"
- name: "mediaIds"
type: "id(DriveFile)[]"
optional: true
diff --git a/src/client/docs/api/entities/view.pug b/src/client/docs/api/entities/view.pug
index 2156463dc7..ac938151a7 100644
--- a/src/client/docs/api/entities/view.pug
+++ b/src/client/docs/api/entities/view.pug
@@ -2,7 +2,7 @@ extends ../../layout.pug
include ../mixins
block meta
- link(rel="stylesheet" href="/assets/api/entities/style.css")
+ link(rel="stylesheet" href="/docs/assets/api/entities/style.css")
block main
h1= name
diff --git a/src/client/docs/api/mixins.pug b/src/client/docs/api/mixins.pug
index 686bf6a2b6..913135a85f 100644
--- a/src/client/docs/api/mixins.pug
+++ b/src/client/docs/api/mixins.pug
@@ -14,13 +14,13 @@ mixin propTable(props)
if prop.kind == 'id'
if prop.entity
| (
- a(href=`/${lang}/api/entities/${kebab(prop.entity)}`)= prop.entity
+ a(href=`/docs/${lang}/api/entities/${kebab(prop.entity)}`)= prop.entity
| ID)
else
| (ID)
else if prop.kind == 'entity'
| (
- a(href=`/${lang}/api/entities/${kebab(prop.entity)}`)= prop.entity
+ a(href=`/docs/${lang}/api/entities/${kebab(prop.entity)}`)= prop.entity
| )
else if prop.kind == 'object'
if prop.def
diff --git a/src/client/docs/follow.ja.pug b/src/client/docs/follow.ja.pug
new file mode 100644
index 0000000000..f0e83bc8fd
--- /dev/null
+++ b/src/client/docs/follow.ja.pug
@@ -0,0 +1,9 @@
+h1 フォロー
+p ユーザーをフォローすると、タイムラインにそのユーザーの投稿が表示されるようになります。ただし、他のユーザーに対する返信は含まれません。
+p ユーザーをフォローするには、ユーザーページの「フォロー」ボタンをクリックします。フォローを解除するには、もう一度クリックします。
+
+section
+ h2 ストーキング
+ p ユーザーをフォローしている状態では、さらに「ストーキング」モードをオンにすることができます。ストーキングを行うと、タイムラインにそのユーザーの全ての投稿が表示されるようになります。つまり、他のユーザーに対する返信も含まれることになります。
+ p ストーキングするには、ユーザーページの「ストークする」をクリックします。ストーキングをやめるには、もう一度クリックします。
+ p ストーキングしていることは相手に通知されません。
diff --git a/src/config/load.ts b/src/config/load.ts
index 9f4e2151f3..fea89b989a 100644
--- a/src/config/load.ts
+++ b/src/config/load.ts
@@ -53,5 +53,5 @@ function normalizeUrl(url: string) {
function urlError(url: string) {
console.error(`「${url}」は、正しいURLではありません。先頭に http:// または https:// をつけ忘れてないかなど確認してください。`);
- process.exit();
+ process.exit(99);
}
diff --git a/src/config/types.ts b/src/config/types.ts
index b181f2c8c1..dff3f7d37c 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -40,6 +40,12 @@ export type Source = {
site_key: string;
secret_key: string;
};
+
+ /**
+ * ゴーストアカウントのID
+ */
+ ghost?: string;
+
accesslog?: string;
twitter?: {
consumer_key: string;
diff --git a/src/const.json b/src/const.json
index 65dc734fab..b4f3b63ac7 100644
--- a/src/const.json
+++ b/src/const.json
@@ -1,5 +1,5 @@
{
"copyright": "Copyright (c) 2014-2018 syuilo",
- "themeColor": "#5cbb2d",
+ "themeColor": "#f66e4f",
"themeColorForeground": "#fff"
}
diff --git a/src/crypto_key.cc b/src/crypto_key.cc
index c8e4d8f7f0..fe67b14532 100644
--- a/src/crypto_key.cc
+++ b/src/crypto_key.cc
@@ -22,14 +22,14 @@ NAN_METHOD(extractPublic)
const auto source = BIO_new_mem_buf(sourceBuf, sourceLength);
if (source == nullptr) {
Nan::ThrowError("Memory allocation failed");
- delete sourceBuf;
+ delete[] sourceBuf;
return;
}
const auto rsa = PEM_read_bio_RSAPrivateKey(source, nullptr, nullptr, nullptr);
BIO_free(source);
- delete sourceBuf;
+ delete[] sourceBuf;
if (rsa == nullptr) {
Nan::ThrowError("Decode failed");
diff --git a/src/drive/gen-thumbnail.ts b/src/drive/gen-thumbnail.ts
new file mode 100644
index 0000000000..455705cd3a
--- /dev/null
+++ b/src/drive/gen-thumbnail.ts
@@ -0,0 +1,25 @@
+import * as stream from 'stream';
+import * as Gm from 'gm';
+import { IDriveFile, getDriveFileBucket } from '../models/drive-file';
+
+const gm = Gm.subClass({
+ imageMagick: true
+});
+
+export default async function(file: IDriveFile): Promise<stream.Readable> {
+ if (!/^image\/.*$/.test(file.contentType)) return null;
+
+ const bucket = await getDriveFileBucket();
+ const readable = bucket.openDownloadStream(file._id);
+
+ const g = gm(readable);
+
+ const stream = g
+ .resize(256, 256)
+ .compress('jpeg')
+ .quality(70)
+ .interlace('line')
+ .stream();
+
+ return stream;
+}
diff --git a/src/models/drive-file-thumbnail.ts b/src/models/drive-file-thumbnail.ts
new file mode 100644
index 0000000000..46de24379f
--- /dev/null
+++ b/src/models/drive-file-thumbnail.ts
@@ -0,0 +1,61 @@
+import * as mongo from 'mongodb';
+import monkDb, { nativeDbConn } from '../db/mongodb';
+
+const DriveFileThumbnail = monkDb.get<IDriveFileThumbnail>('driveFileThumbnails.files');
+DriveFileThumbnail.createIndex('metadata.originalId', { sparse: true, unique: true });
+export default DriveFileThumbnail;
+
+export const DriveFileThumbnailChunk = monkDb.get('driveFileThumbnails.chunks');
+
+export const getDriveFileThumbnailBucket = async (): Promise<mongo.GridFSBucket> => {
+ const db = await nativeDbConn();
+ const bucket = new mongo.GridFSBucket(db, {
+ bucketName: 'driveFileThumbnails'
+ });
+ return bucket;
+};
+
+export type IMetadata = {
+ originalId: mongo.ObjectID;
+};
+
+export type IDriveFileThumbnail = {
+ _id: mongo.ObjectID;
+ uploadDate: Date;
+ md5: string;
+ filename: string;
+ contentType: string;
+ metadata: IMetadata;
+};
+
+/**
+ * DriveFileThumbnailを物理削除します
+ */
+export async function deleteDriveFileThumbnail(driveFile: string | mongo.ObjectID | IDriveFileThumbnail) {
+ let d: IDriveFileThumbnail;
+
+ // Populate
+ if (mongo.ObjectID.prototype.isPrototypeOf(driveFile)) {
+ d = await DriveFileThumbnail.findOne({
+ _id: driveFile
+ });
+ } else if (typeof driveFile === 'string') {
+ d = await DriveFileThumbnail.findOne({
+ _id: new mongo.ObjectID(driveFile)
+ });
+ } else {
+ d = driveFile as IDriveFileThumbnail;
+ }
+
+ if (d == null) return;
+
+ // このDriveFileThumbnailのチャンクをすべて削除
+ await DriveFileThumbnailChunk.remove({
+ files_id: d._id
+ });
+
+ // このDriveFileThumbnailを削除
+ await DriveFileThumbnail.remove({
+ _id: d._id
+ });
+}
diff --git a/src/models/drive-file.ts b/src/models/drive-file.ts
index fc9c150724..f8cad36f9a 100644
--- a/src/models/drive-file.ts
+++ b/src/models/drive-file.ts
@@ -6,14 +6,16 @@ import monkDb, { nativeDbConn } from '../db/mongodb';
import Note, { deleteNote } from './note';
import MessagingMessage, { deleteMessagingMessage } from './messaging-message';
import User from './user';
+import DriveFileThumbnail, { deleteDriveFileThumbnail } from './drive-file-thumbnail';
const DriveFile = monkDb.get<IDriveFile>('driveFiles.files');
-
+DriveFile.createIndex('md5');
DriveFile.createIndex('metadata.uri', { sparse: true, unique: true });
-
export default DriveFile;
-const getGridFSBucket = async (): Promise<mongo.GridFSBucket> => {
+export const DriveFileChunk = monkDb.get('driveFiles.chunks');
+
+export const getDriveFileBucket = async (): Promise<mongo.GridFSBucket> => {
const db = await nativeDbConn();
const bucket = new mongo.GridFSBucket(db, {
bucketName: 'driveFiles'
@@ -21,14 +23,16 @@ const getGridFSBucket = async (): Promise<mongo.GridFSBucket> => {
return bucket;
};
-export { getGridFSBucket };
-
export type IMetadata = {
properties: any;
userId: mongo.ObjectID;
+ _user: any;
folderId: mongo.ObjectID;
comment: string;
- uri: string;
+ uri?: string;
+ url?: string;
+ deletedAt?: Date;
+ isExpired?: boolean;
};
export type IDriveFile = {
@@ -92,8 +96,13 @@ export async function deleteDriveFile(driveFile: string | mongo.ObjectID | IDriv
}
}
+ // このDriveFileのDriveFileThumbnailをすべて削除
+ await Promise.all((
+ await DriveFileThumbnail.find({ 'metadata.originalId': d._id })
+ ).map(x => deleteDriveFileThumbnail(x)));
+
// このDriveFileのチャンクをすべて削除
- await monkDb.get('driveFiles.chunks').remove({
+ await DriveFileChunk.remove({
files_id: d._id
});
diff --git a/src/models/favorite.ts b/src/models/favorite.ts
index b2c5828088..d24833f191 100644
--- a/src/models/favorite.ts
+++ b/src/models/favorite.ts
@@ -1,7 +1,10 @@
import * as mongo from 'mongodb';
+import deepcopy = require('deepcopy');
import db from '../db/mongodb';
+import { pack as packNote } from './note';
const Favorite = db.get<IFavorite>('favorites');
+Favorite.createIndex(['userId', 'noteId'], { unique: true });
export default Favorite;
export type IFavorite = {
@@ -37,3 +40,35 @@ export async function deleteFavorite(favorite: string | mongo.ObjectID | IFavori
_id: f._id
});
}
+
+/**
+ * Pack a favorite for API response
+ */
+export const pack = (
+ favorite: any,
+ me: any
+) => new Promise<any>(async (resolve, reject) => {
+ let _favorite: any;
+
+ // Populate the favorite if 'favorite' is ID
+ if (mongo.ObjectID.prototype.isPrototypeOf(favorite)) {
+ _favorite = await Favorite.findOne({
+ _id: favorite
+ });
+ } else if (typeof favorite === 'string') {
+ _favorite = await Favorite.findOne({
+ _id: new mongo.ObjectID(favorite)
+ });
+ } else {
+ _favorite = deepcopy(favorite);
+ }
+
+ // Rename _id to id
+ _favorite.id = _favorite._id;
+ delete _favorite._id;
+
+ // Populate note
+ _favorite.note = await packNote(_favorite.noteId, me);
+
+ resolve(_favorite);
+});
diff --git a/src/models/following.ts b/src/models/following.ts
index f10e349ee9..4712379a70 100644
--- a/src/models/following.ts
+++ b/src/models/following.ts
@@ -10,6 +10,17 @@ export type IFollowing = {
createdAt: Date;
followeeId: mongo.ObjectID;
followerId: mongo.ObjectID;
+ stalk: boolean;
+
+ // 非正規化
+ _followee: {
+ host: string;
+ inbox?: string;
+ },
+ _follower: {
+ host: string;
+ inbox?: string;
+ }
};
/**
diff --git a/src/models/messaging-message.ts b/src/models/messaging-message.ts
index 9d62fab4fa..a6a50fc8cf 100644
--- a/src/models/messaging-message.ts
+++ b/src/models/messaging-message.ts
@@ -12,7 +12,6 @@ export interface IMessagingMessage {
_id: mongo.ObjectID;
createdAt: Date;
text: string;
- textHtml: string;
userId: mongo.ObjectID;
recipientId: mongo.ObjectID;
isRead: boolean;
diff --git a/src/models/note-reaction.ts b/src/models/note-reaction.ts
index 9bf467f222..706ae54c16 100644
--- a/src/models/note-reaction.ts
+++ b/src/models/note-reaction.ts
@@ -1,4 +1,5 @@
import * as mongo from 'mongodb';
+import $ from 'cafy';
import deepcopy = require('deepcopy');
import db from '../db/mongodb';
import Reaction from './note-reaction';
@@ -16,6 +17,18 @@ export interface INoteReaction {
reaction: string;
}
+export const validateReaction = $.str.or([
+ 'like',
+ 'love',
+ 'laugh',
+ 'hmm',
+ 'surprise',
+ 'congrats',
+ 'angry',
+ 'confused',
+ 'pudding'
+]);
+
/**
* NoteReactionを物理削除します
*/
diff --git a/src/models/note.ts b/src/models/note.ts
index 3059593540..f42bb2a49d 100644
--- a/src/models/note.ts
+++ b/src/models/note.ts
@@ -12,11 +12,11 @@ import NoteWatching, { deleteNoteWatching } from './note-watching';
import NoteReaction from './note-reaction';
import Favorite, { deleteFavorite } from './favorite';
import Notification, { deleteNotification } from './notification';
+import Following from './following';
const Note = db.get<INote>('notes');
-
Note.createIndex('uri', { sparse: true, unique: true });
-
+Note.createIndex('userId');
export default Note;
export function isValidText(text: string): boolean {
@@ -24,7 +24,7 @@ export function isValidText(text: string): boolean {
}
export function isValidCw(text: string): boolean {
- return text.length <= 100 && text.trim() != '';
+ return text.length <= 100;
}
export type INote = {
@@ -38,7 +38,6 @@ export type INote = {
poll: any; // todo
text: string;
tags: string[];
- textHtml: string;
cw: string;
userId: mongo.ObjectID;
appId: mongo.ObjectID;
@@ -47,7 +46,18 @@ export type INote = {
repliesCount: number;
reactionCounts: any;
mentions: mongo.ObjectID[];
- visibility: 'public' | 'unlisted' | 'private' | 'direct';
+
+ /**
+ * public ... 公開
+ * home ... ホームタイムライン(ユーザーページのタイムライン含む)のみに流す
+ * followers ... フォロワーのみ
+ * specified ... visibleUserIds で指定したユーザーのみ
+ * private ... 自分のみ
+ */
+ visibility: 'public' | 'home' | 'followers' | 'specified' | 'private';
+
+ visibleUserIds: mongo.ObjectID[];
+
geo: {
coordinates: number[];
altitude: number;
@@ -58,6 +68,7 @@ export type INote = {
};
uri: string;
+ // 非正規化
_reply?: {
userId: mongo.ObjectID;
};
@@ -66,9 +77,7 @@ export type INote = {
};
_user: {
host: string;
- account: {
- inbox?: string;
- };
+ inbox?: string;
};
};
@@ -153,9 +162,9 @@ export const pack = async (
detail: boolean
}
) => {
- const opts = options || {
- detail: true,
- };
+ const opts = Object.assign({
+ detail: true
+ }, options);
// Me
const meId: mongo.ObjectID = me
@@ -183,12 +192,61 @@ export const pack = async (
if (!_note) throw `invalid note arg ${note}`;
+ let hide = false;
+
+ // visibility が private かつ投稿者のIDが自分のIDではなかったら非表示
+ if (_note.visibility == 'private' && (meId == null || !meId.equals(_note.userId))) {
+ hide = true;
+ }
+
+ // visibility が specified かつ自分が指定されていなかったら非表示
+ if (_note.visibility == 'specified') {
+ if (meId == null) {
+ hide = true;
+ } else if (meId.equals(_note.userId)) {
+ hide = false;
+ } else {
+ // 指定されているかどうか
+ const specified = _note.visibleUserIds.some(id => id.equals(meId));
+
+ if (specified) {
+ hide = false;
+ } else {
+ hide = true;
+ }
+ }
+ }
+
+ // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
+ if (_note.visibility == 'followers') {
+ if (meId == null) {
+ hide = true;
+ } else if (meId.equals(_note.userId)) {
+ hide = false;
+ } else {
+ // フォロワーかどうか
+ const following = await Following.findOne({
+ followeeId: _note.userId,
+ followerId: meId
+ });
+
+ if (following == null) {
+ hide = true;
+ } else {
+ hide = false;
+ }
+ }
+ }
+
const id = _note._id;
// Rename _id to id
_note.id = _note._id;
delete _note._id;
+ delete _note._user;
+ delete _note._reply;
+ delete _note.repost;
delete _note.mentions;
if (_note.geo) delete _note.geo.type;
@@ -206,49 +264,16 @@ export const pack = async (
}
// Populate media
- if (_note.mediaIds) {
- _note.media = Promise.all(_note.mediaIds.map(fileId =>
- packFile(fileId)
- ));
- }
+ _note.media = hide ? [] : Promise.all(_note.mediaIds.map(fileId =>
+ packFile(fileId)
+ ));
// When requested a detailed note data
if (opts.detail) {
- // Get previous note info
- _note.prev = (async () => {
- const prev = await Note.findOne({
- userId: _note.userId,
- _id: {
- $lt: id
- }
- }, {
- fields: {
- _id: true
- },
- sort: {
- _id: -1
- }
- });
- return prev ? prev._id : null;
- })();
-
- // Get next note info
- _note.next = (async () => {
- const next = await Note.findOne({
- userId: _note.userId,
- _id: {
- $gt: id
- }
- }, {
- fields: {
- _id: true
- },
- sort: {
- _id: 1
- }
- });
- return next ? next._id : null;
- })();
+ //#region 重いので廃止
+ _note.prev = null;
+ _note.next = null;
+ //#endregion
if (_note.replyId) {
// Populate reply to note
@@ -265,7 +290,7 @@ export const pack = async (
}
// Poll
- if (meId && _note.poll) {
+ if (meId && _note.poll && !hide) {
_note.poll = (async (poll) => {
const vote = await PollVote
.findOne({
@@ -306,5 +331,12 @@ export const pack = async (
// resolve promises in _note object
_note = await rap(_note);
+ if (hide) {
+ _note.mediaIds = [];
+ _note.text = null;
+ _note.poll = null;
+ _note.isHidden = true;
+ }
+
return _note;
};
diff --git a/src/models/sw-subscription.ts b/src/models/sw-subscription.ts
index 621ac8a9b6..a38edd3a50 100644
--- a/src/models/sw-subscription.ts
+++ b/src/models/sw-subscription.ts
@@ -38,4 +38,3 @@ export async function deleteSwSubscription(swSubscription: string | mongo.Object
_id: s._id
});
}
-
diff --git a/src/models/user-list.ts b/src/models/user-list.ts
new file mode 100644
index 0000000000..7100fced7e
--- /dev/null
+++ b/src/models/user-list.ts
@@ -0,0 +1,67 @@
+import * as mongo from 'mongodb';
+import deepcopy = require('deepcopy');
+import db from '../db/mongodb';
+
+const UserList = db.get<IUserList>('userList');
+export default UserList;
+
+export interface IUserList {
+ _id: mongo.ObjectID;
+ createdAt: Date;
+ title: string;
+ userId: mongo.ObjectID;
+ userIds: mongo.ObjectID[];
+}
+
+/**
+ * UserListを物理削除します
+ */
+export async function deleteUserList(userList: string | mongo.ObjectID | IUserList) {
+ let u: IUserList;
+
+ // Populate
+ if (mongo.ObjectID.prototype.isPrototypeOf(userList)) {
+ u = await UserList.findOne({
+ _id: userList
+ });
+ } else if (typeof userList === 'string') {
+ u = await UserList.findOne({
+ _id: new mongo.ObjectID(userList)
+ });
+ } else {
+ u = userList as IUserList;
+ }
+
+ if (u == null) return;
+
+ // このUserListを削除
+ await UserList.remove({
+ _id: u._id
+ });
+}
+
+export const pack = (
+ userList: string | mongo.ObjectID | IUserList
+) => new Promise<any>(async (resolve, reject) => {
+ let _userList: any;
+
+ if (mongo.ObjectID.prototype.isPrototypeOf(userList)) {
+ _userList = await UserList.findOne({
+ _id: userList
+ });
+ } else if (typeof userList === 'string') {
+ _userList = await UserList.findOne({
+ _id: new mongo.ObjectID(userList)
+ });
+ } else {
+ _userList = deepcopy(userList);
+ }
+
+ if (!_userList) throw `invalid userList arg ${userList}`;
+
+ // Rename _id to id
+ _userList.id = _userList._id;
+ delete _userList._id;
+
+ resolve(_userList);
+});
diff --git a/src/models/user.ts b/src/models/user.ts
index 741306fd27..108111ceca 100644
--- a/src/models/user.ts
+++ b/src/models/user.ts
@@ -1,11 +1,12 @@
import * as mongo from 'mongodb';
import deepcopy = require('deepcopy');
+import sequential = require('promise-sequential');
import rap from '@prezzemolo/rap';
import db from '../db/mongodb';
-import Note, { INote, pack as packNote, deleteNote } from './note';
+import Note, { pack as packNote, deleteNote } from './note';
import Following, { deleteFollowing } from './following';
import Mute, { deleteMute } from './mute';
-import getFriends from '../server/api/common/get-friends';
+import { getFriendIds } from '../server/api/common/get-friends';
import config from '../config';
import AccessToken, { deleteAccessToken } from './access-token';
import NoteWatching, { deleteNoteWatching } from './note-watching';
@@ -20,6 +21,7 @@ import FollowingLog, { deleteFollowingLog } from './following-log';
import FollowedLog, { deleteFollowedLog } from './followed-log';
import SwSubscription, { deleteSwSubscription } from './sw-subscription';
import Notification, { deleteNotification } from './notification';
+import UserList, { deleteUserList } from './user-list';
const User = db.get<IUser>('users');
@@ -35,7 +37,7 @@ export default User;
type IUserBase = {
_id: mongo.ObjectID;
createdAt: Date;
- deletedAt: Date;
+ deletedAt?: Date;
followersCount: number;
followingCount: number;
name?: string;
@@ -47,10 +49,8 @@ type IUserBase = {
bannerId: mongo.ObjectID;
data: any;
description: string;
- latestNote: INote;
pinnedNoteId: mongo.ObjectID;
isSuspended: boolean;
- keywords: string[];
host: string;
};
@@ -80,13 +80,14 @@ export interface ILocalUser extends IUserBase {
isPro: boolean;
twoFactorSecret: string;
twoFactorEnabled: boolean;
- twoFactorTempSecret: string;
+ twoFactorTempSecret?: string;
clientSettings: any;
settings: any;
}
export interface IRemoteUser extends IUserBase {
inbox: string;
+ endpoints: string[];
uri: string;
url?: string;
publicKey: {
@@ -114,7 +115,7 @@ export function validatePassword(password: string): boolean {
}
export function isValidName(name?: string): boolean {
- return name === null || (typeof name == 'string' && name.length < 30 && name.trim() != '');
+ return name === null || (typeof name == 'string' && name.length < 50 && name.trim() != '');
}
export function isValidDescription(description: string): boolean {
@@ -167,9 +168,9 @@ export async function deleteUser(user: string | mongo.ObjectID | IUser) {
).map(x => deleteAccessToken(x)));
// このユーザーのNoteをすべて削除
- await Promise.all((
- await Note.find({ userId: u._id })
- ).map(x => deleteNote(x)));
+ //await sequential((
+ // await Note.find({ userId: u._id })
+ //).map(x => () => deleteNote(x)));
// このユーザーのNoteReactionをすべて削除
await Promise.all((
@@ -261,6 +262,20 @@ export async function deleteUser(user: string | mongo.ObjectID | IUser) {
await Notification.find({ notifierId: u._id })
).map(x => deleteNotification(x)));
+ // このユーザーのUserListをすべて削除
+ await Promise.all((
+ await UserList.find({ userId: u._id })
+ ).map(x => deleteUserList(x)));
+
+ // このユーザーが入っているすべてのUserListからこのユーザーを削除
+ await Promise.all((
+ await UserList.find({ userIds: u._id })
+ ).map(x =>
+ UserList.update({ _id: x._id }, {
+ $pull: { userIds: u._id }
+ })
+ ));
+
// このユーザーを削除
await User.remove({
_id: u._id
@@ -332,9 +347,6 @@ export const pack = (
_user.id = _user._id;
delete _user._id;
- // Remove needless properties
- delete _user.latestNote;
-
if (_user.host == null) {
// Remove private properties
delete _user.keypair;
@@ -359,6 +371,8 @@ export const pack = (
if (!opts.detail) {
delete _user.twoFactorEnabled;
}
+ } else {
+ delete _user.publicKey;
}
_user.avatarUrl = _user.avatarId != null
@@ -377,33 +391,30 @@ export const pack = (
}
if (meId && !meId.equals(_user.id)) {
- // Whether the user is following
- _user.isFollowing = (async () => {
- const follow = await Following.findOne({
+ const [following1, following2, mute] = await Promise.all([
+ Following.findOne({
followerId: meId,
followeeId: _user.id
- });
- return follow !== null;
- })();
-
- // Whether the user is followed
- _user.isFollowed = (async () => {
- const follow2 = await Following.findOne({
+ }),
+ Following.findOne({
followerId: _user.id,
followeeId: meId
- });
- return follow2 !== null;
- })();
+ }),
+ Mute.findOne({
+ muterId: meId,
+ muteeId: _user.id
+ })
+ ]);
+
+ // Whether the user is following
+ _user.isFollowing = following1 !== null;
+ _user.isStalking = following1 && following1.stalk;
+
+ // Whether the user is followed
+ _user.isFollowed = following2 !== null;
// Whether the user is muted
- _user.isMuted = (async () => {
- const mute = await Mute.findOne({
- muterId: meId,
- muteeId: _user.id,
- deletedAt: { $exists: false }
- });
- return mute !== null;
- })();
+ _user.isMuted = mute !== null;
}
if (opts.detail) {
@@ -415,7 +426,7 @@ export const pack = (
}
if (meId && !meId.equals(_user.id)) {
- const myFollowingIds = await getFriends(meId);
+ const myFollowingIds = await getFriendIds(meId);
// Get following you know count
_user.followingYouKnowCount = Following.count({
@@ -448,3 +459,7 @@ function img(url) {
};
}
*/
+
+export function getGhost(): Promise<ILocalUser> {
+ return User.findOne({ _id: new mongo.ObjectId(config.ghost) });
+}
diff --git a/src/publishers/stream.ts b/src/publishers/stream.ts
index 2ecbfa0dd8..dcc03e39f1 100644
--- a/src/publishers/stream.ts
+++ b/src/publishers/stream.ts
@@ -25,6 +25,10 @@ class MisskeyEvent {
this.publish(`note-stream:${noteId}`, type, typeof value === 'undefined' ? null : value);
}
+ public publishUserListStream(listId: ID, type: string, value?: any): void {
+ this.publish(`user-list-stream:${listId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
public publishMessagingStream(userId: ID, otherpartyId: ID, type: string, value?: any): void {
this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value);
}
@@ -69,6 +73,7 @@ export default ev.publishUserStream.bind(ev);
export const publishLocalTimelineStream = ev.publishLocalTimelineStream.bind(ev);
export const publishGlobalTimelineStream = ev.publishGlobalTimelineStream.bind(ev);
export const publishDriveStream = ev.publishDriveStream.bind(ev);
+export const publishUserListStream = ev.publishUserListStream.bind(ev);
export const publishNoteStream = ev.publishNoteStream.bind(ev);
export const publishMessagingStream = ev.publishMessagingStream.bind(ev);
export const publishMessagingIndexStream = ev.publishMessagingIndexStream.bind(ev);
diff --git a/src/queue/index.ts b/src/queue/index.ts
index 88e475dd5d..3f82b30b35 100644
--- a/src/queue/index.ts
+++ b/src/queue/index.ts
@@ -2,6 +2,7 @@ import { createQueue } from 'kue';
import config from '../config';
import http from './processors/http';
+import { ILocalUser } from '../models/user';
const queue = createQueue({
redis: {
@@ -14,17 +15,20 @@ const queue = createQueue({
export function createHttp(data) {
return queue
.create('http', data)
+ .removeOnComplete(true)
+ .events(false)
.attempts(8)
.backoff({ delay: 16384, type: 'exponential' });
}
-export function deliver(user, content, to) {
+export function deliver(user: ILocalUser, content, to) {
createHttp({
+ title: 'deliver',
type: 'deliver',
user,
content,
to
- }).removeOnComplete(true).save();
+ }).save();
}
export default function() {
diff --git a/src/queue/processors/http/deliver.ts b/src/queue/processors/http/deliver.ts
index cf843fad07..2c4bbe9bf0 100644
--- a/src/queue/processors/http/deliver.ts
+++ b/src/queue/processors/http/deliver.ts
@@ -7,13 +7,14 @@ export default async (job: kue.Job, done): Promise<void> => {
await request(job.data.user, job.data.to, job.data.content);
done();
} catch (res) {
+ if (res.statusCode == null) return done();
if (res.statusCode >= 400 && res.statusCode < 500) {
// HTTPステータスコード4xxはクライアントエラーであり、それはつまり
// 何回再送しても成功することはないということなのでエラーにはしないでおく
done();
} else {
console.warn(`deliver failed: ${res.statusMessage}`);
- done(new Error(res.statusMessage));
+ done(res.statusMessage);
}
}
};
diff --git a/src/queue/processors/http/process-inbox.ts b/src/queue/processors/http/process-inbox.ts
index 75ff5918f6..dfafe64a78 100644
--- a/src/queue/processors/http/process-inbox.ts
+++ b/src/queue/processors/http/process-inbox.ts
@@ -33,6 +33,11 @@ export default async (job: kue.Job, done): Promise<void> => {
}
user = await User.findOne({ usernameLower: username, host: host.toLowerCase() }) as IRemoteUser;
+
+ // アクティビティを送信してきたユーザーがまだMisskeyサーバーに登録されていなかったら登録する
+ if (user === null) {
+ user = await resolvePerson(activity.actor);
+ }
} else {
user = await User.findOne({
host: { $ne: null },
diff --git a/src/remote/activitypub/kernel/announce/note.ts b/src/remote/activitypub/kernel/announce/note.ts
index a288dd499a..fe645b07b5 100644
--- a/src/remote/activitypub/kernel/announce/note.ts
+++ b/src/remote/activitypub/kernel/announce/note.ts
@@ -5,6 +5,7 @@ import post from '../../../../services/note/create';
import { IRemoteUser } from '../../../../models/user';
import { IAnnounce, INote } from '../../type';
import { fetchNote, resolveNote } from '../../models/note';
+import { resolvePerson } from '../../models/person';
const log = debug('misskey:activitypub');
@@ -30,16 +31,22 @@ export default async function(resolver: Resolver, actor: IRemoteUser, activity:
//#region Visibility
let visibility = 'public';
- if (!activity.to.includes('https://www.w3.org/ns/activitystreams#Public')) visibility = 'unlisted';
- if (activity.cc.length == 0) visibility = 'private';
- // TODO
- if (visibility != 'public') throw new Error('unspported visibility');
+ let visibleUsers = [];
+ if (!note.to.includes('https://www.w3.org/ns/activitystreams#Public')) {
+ if (note.cc.includes('https://www.w3.org/ns/activitystreams#Public')) {
+ visibility = 'home';
+ } else {
+ visibility = 'specified';
+ visibleUsers = await Promise.all(note.to.map(uri => resolvePerson(uri)));
+ }
+ } if (activity.cc.length == 0) visibility = 'followers';
//#endergion
await post(actor, {
createdAt: new Date(activity.published),
renote,
visibility,
+ visibleUsers,
uri
});
}
diff --git a/src/remote/activitypub/kernel/delete/note.ts b/src/remote/activitypub/kernel/delete/note.ts
index 64c342d39b..b2868f69a3 100644
--- a/src/remote/activitypub/kernel/delete/note.ts
+++ b/src/remote/activitypub/kernel/delete/note.ts
@@ -22,7 +22,6 @@ export default async function(actor: IRemoteUser, uri: string): Promise<void> {
$set: {
deletedAt: new Date(),
text: null,
- textHtml: null,
mediaIds: [],
poll: null
}
diff --git a/src/remote/activitypub/kernel/follow.ts b/src/remote/activitypub/kernel/follow.ts
index 6a8b5a1bec..7e31eb32ea 100644
--- a/src/remote/activitypub/kernel/follow.ts
+++ b/src/remote/activitypub/kernel/follow.ts
@@ -1,3 +1,4 @@
+import * as mongo from 'mongodb';
import User, { IRemoteUser } from '../../../models/user';
import config from '../../../config';
import follow from '../../../services/following/create';
@@ -10,7 +11,9 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise<void> => {
return null;
}
- const followee = await User.findOne({ _id: id.split('/').pop() });
+ const followee = await User.findOne({
+ _id: new mongo.ObjectID(id.split('/').pop())
+ });
if (followee === null) {
throw new Error('followee not found');
diff --git a/src/remote/activitypub/kernel/like.ts b/src/remote/activitypub/kernel/like.ts
index 4941608588..17ec73f12b 100644
--- a/src/remote/activitypub/kernel/like.ts
+++ b/src/remote/activitypub/kernel/like.ts
@@ -1,7 +1,9 @@
+import * as mongo from 'mongodb';
import Note from '../../../models/note';
import { IRemoteUser } from '../../../models/user';
import { ILike } from '../type';
import create from '../../../services/note/reaction/create';
+import { validateReaction } from '../../../models/note-reaction';
export default async (actor: IRemoteUser, activity: ILike) => {
const id = typeof activity.object == 'string' ? activity.object : activity.object.id;
@@ -9,12 +11,21 @@ export default async (actor: IRemoteUser, activity: ILike) => {
// Transform:
// https://misskey.ex/notes/xxxx to
// xxxx
- const noteId = id.split('/').pop();
+ const noteId = new mongo.ObjectID(id.split('/').pop());
const note = await Note.findOne({ _id: noteId });
if (note === null) {
throw new Error();
}
- await create(actor, note, 'pudding');
+ let reaction = 'pudding';
+
+ // 他のMisskeyインスタンスからのリアクション
+ if (activity._misskey_reaction) {
+ if (validateReaction.ok(activity._misskey_reaction)) {
+ reaction = activity._misskey_reaction;
+ }
+ }
+
+ await create(actor, note, reaction);
};
diff --git a/src/remote/activitypub/kernel/undo/follow.ts b/src/remote/activitypub/kernel/undo/follow.ts
index a85cb0305d..c0b10c1898 100644
--- a/src/remote/activitypub/kernel/undo/follow.ts
+++ b/src/remote/activitypub/kernel/undo/follow.ts
@@ -1,3 +1,4 @@
+import * as mongo from 'mongodb';
import User, { IRemoteUser } from '../../../../models/user';
import config from '../../../../config';
import unfollow from '../../../../services/following/delete';
@@ -10,7 +11,9 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise<void> => {
return null;
}
- const followee = await User.findOne({ _id: id.split('/').pop() });
+ const followee = await User.findOne({
+ _id: new mongo.ObjectID(id.split('/').pop())
+ });
if (followee === null) {
throw new Error('followee not found');
diff --git a/src/remote/activitypub/misc/get-note-html.ts b/src/remote/activitypub/misc/get-note-html.ts
new file mode 100644
index 0000000000..5bca4eed62
--- /dev/null
+++ b/src/remote/activitypub/misc/get-note-html.ts
@@ -0,0 +1,23 @@
+import { INote } from "../../../models/note";
+import toHtml from '../../../text/html';
+import parse from '../../../text/parse';
+import config from '../../../config';
+
+export default function(note: INote) {
+ if (note.text == null) return null;
+
+ let html = toHtml(parse(note.text));
+
+ if (note.poll != null) {
+ const url = `${config.url}/notes/${note._id}`;
+ // TODO: i18n
+ html += `<p><a href="${url}">【Misskeyで投票を見る】</a></p>`;
+ }
+
+ if (note.renoteId != null) {
+ const url = `${config.url}/notes/${note.renoteId}`;
+ html += `<p>RE: <a href="${url}">${url}</a></p>`;
+ }
+
+ return html;
+}
diff --git a/src/remote/activitypub/models/image.ts b/src/remote/activitypub/models/image.ts
index d7bc5aff2f..0d5a690c6c 100644
--- a/src/remote/activitypub/models/image.ts
+++ b/src/remote/activitypub/models/image.ts
@@ -11,6 +11,11 @@ const log = debug('misskey:activitypub');
* Imageを作成します。
*/
export async function createImage(actor: IRemoteUser, value): Promise<IDriveFile> {
+ // 投稿者が凍結されていたらスキップ
+ if (actor.isSuspended) {
+ return null;
+ }
+
const image = await new Resolver().resolve(value);
if (image.url == null) {
@@ -19,7 +24,7 @@ export async function createImage(actor: IRemoteUser, value): Promise<IDriveFile
log(`Creating the Image: ${image.url}`);
- return await uploadFromUrl(image.url, actor);
+ return await uploadFromUrl(image.url, actor, null, image.url);
}
/**
diff --git a/src/remote/activitypub/models/note.ts b/src/remote/activitypub/models/note.ts
index ab6dd99a77..91e700ef6f 100644
--- a/src/remote/activitypub/models/note.ts
+++ b/src/remote/activitypub/models/note.ts
@@ -1,4 +1,5 @@
-import { JSDOM } from 'jsdom';
+import * as mongo from 'mongodb';
+import * as parse5 from 'parse5';
import * as debug from 'debug';
import config from '../../../config';
@@ -12,6 +13,76 @@ import { IRemoteUser } from '../../../models/user';
const log = debug('misskey:activitypub');
+function parse(html: string): string {
+ const dom = parse5.parseFragment(html) as parse5.AST.Default.Document;
+
+ let text = '';
+
+ dom.childNodes.forEach(n => analyze(n));
+
+ return text.trim();
+
+ function getText(node) {
+ if (node.nodeName == '#text') return node.value;
+
+ if (node.childNodes) {
+ return node.childNodes.map(n => getText(n)).join('');
+ }
+
+ return '';
+ }
+
+ function analyze(node) {
+ switch (node.nodeName) {
+ case '#text':
+ text += node.value;
+ break;
+
+ case 'br':
+ text += '\n';
+ break;
+
+ case 'a':
+ const txt = getText(node);
+
+ // メンション
+ if (txt.startsWith('@')) {
+ const part = txt.split('@');
+
+ if (part.length == 2) {
+ //#region ホスト名部分が省略されているので復元する
+ const href = new URL(node.attrs.find(x => x.name == 'href').value);
+ const acct = txt + '@' + href.hostname;
+ text += acct;
+ break;
+ //#endregion
+ } else if (part.length == 3) {
+ text += txt;
+ break;
+ }
+ }
+
+ if (node.childNodes) {
+ node.childNodes.forEach(n => analyze(n));
+ }
+ break;
+
+ case 'p':
+ text += '\n\n';
+ if (node.childNodes) {
+ node.childNodes.forEach(n => analyze(n));
+ }
+ break;
+
+ default:
+ if (node.childNodes) {
+ node.childNodes.forEach(n => analyze(n));
+ }
+ break;
+ }
+ }
+}
+
/**
* Noteをフェッチします。
*
@@ -22,7 +93,8 @@ export async function fetchNote(value: string | IObject, resolver?: Resolver): P
// URIがこのサーバーを指しているならデータベースからフェッチ
if (uri.startsWith(config.url + '/')) {
- return await Note.findOne({ _id: uri.split('/').pop() });
+ const id = new mongo.ObjectID(uri.split('/').pop());
+ return await Note.findOne({ _id: id });
}
//#region このサーバーに既に登録されていたらそれを返す
@@ -45,7 +117,8 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
const object = await resolver.resolve(value) as any;
if (object == null || object.type !== 'Note') {
- throw new Error('invalid note');
+ log(`invalid note: ${object}`);
+ return null;
}
const note: INoteActivityStreamsObject = object;
@@ -55,12 +128,23 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
// 投稿者をフェッチ
const actor = await resolvePerson(note.attributedTo) as IRemoteUser;
+ // 投稿者が凍結されていたらスキップ
+ if (actor.isSuspended) {
+ return null;
+ }
+
//#region Visibility
let visibility = 'public';
- if (!note.to.includes('https://www.w3.org/ns/activitystreams#Public')) visibility = 'unlisted';
- if (note.cc.length == 0) visibility = 'private';
- // TODO
- if (visibility != 'public') throw new Error('unspported visibility');
+ let visibleUsers = [];
+ if (!note.to.includes('https://www.w3.org/ns/activitystreams#Public')) {
+ if (note.cc.includes('https://www.w3.org/ns/activitystreams#Public')) {
+ visibility = 'home';
+ } else {
+ visibility = 'specified';
+ visibleUsers = await Promise.all(note.to.map(uri => resolvePerson(uri)));
+ }
+ }
+ if (note.cc.length == 0) visibility = 'followers';
//#endergion
// 添付メディア
@@ -73,7 +157,8 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
// リプライ
const reply = note.inReplyTo ? await resolveNote(note.inReplyTo, resolver) : null;
- const { window } = new JSDOM(note.content);
+ // テキストのパース
+ const text = parse(note.content);
// ユーザーの情報が古かったらついでに更新しておく
if (actor.updatedAt == null || Date.now() - actor.updatedAt.getTime() > 1000 * 60 * 60 * 24) {
@@ -85,10 +170,11 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
media,
reply,
renote: undefined,
- text: window.document.body.textContent,
+ text: text,
viaMobile: false,
geo: undefined,
visibility,
+ visibleUsers,
uri: note.id
}, silent);
}
diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts
index b755b2603a..33280f3d89 100644
--- a/src/remote/activitypub/models/person.ts
+++ b/src/remote/activitypub/models/person.ts
@@ -1,3 +1,4 @@
+import * as mongo from 'mongodb';
import { JSDOM } from 'jsdom';
import { toUnicode } from 'punycode';
import * as debug from 'debug';
@@ -21,7 +22,8 @@ export async function fetchPerson(value: string | IObject, resolver?: Resolver):
// URIがこのサーバーを指しているならデータベースからフェッチ
if (uri.startsWith(config.url + '/')) {
- return await User.findOne({ _id: uri.split('/').pop() });
+ const id = new mongo.ObjectID(uri.split('/').pop());
+ return await User.findOne({ _id: id });
}
//#region このサーバーに既に登録されていたらそれを返す
@@ -47,6 +49,7 @@ export async function createPerson(value: any, resolver?: Resolver): Promise<IUs
object == null ||
object.type !== 'Person' ||
typeof object.preferredUsername !== 'string' ||
+ typeof object.inbox !== 'string' ||
!validateUsername(object.preferredUsername) ||
!isValidName(object.name == '' ? null : object.name)
) {
@@ -78,27 +81,39 @@ export async function createPerson(value: any, resolver?: Resolver): Promise<IUs
const summaryDOM = JSDOM.fragment(person.summary);
// Create user
- const user = await User.insert({
- avatarId: null,
- bannerId: null,
- createdAt: Date.parse(person.published) || null,
- description: summaryDOM.textContent,
- followersCount,
- followingCount,
- notesCount,
- name: person.name,
- driveCapacity: 1024 * 1024 * 8, // 8MiB
- username: person.preferredUsername,
- usernameLower: person.preferredUsername.toLowerCase(),
- host,
- publicKey: {
- id: person.publicKey.id,
- publicKeyPem: person.publicKey.publicKeyPem
- },
- inbox: person.inbox,
- uri: person.id,
- url: person.url
- }) as IRemoteUser;
+ let user: IRemoteUser;
+ try {
+ user = await User.insert({
+ avatarId: null,
+ bannerId: null,
+ createdAt: Date.parse(person.published) || null,
+ description: summaryDOM.textContent,
+ followersCount,
+ followingCount,
+ notesCount,
+ name: person.name,
+ driveCapacity: 1024 * 1024 * 8, // 8MiB
+ username: person.preferredUsername,
+ usernameLower: person.preferredUsername.toLowerCase(),
+ host,
+ publicKey: {
+ id: person.publicKey.id,
+ publicKeyPem: person.publicKey.publicKeyPem
+ },
+ inbox: person.inbox,
+ endpoints: person.endpoints,
+ uri: person.id,
+ url: person.url
+ }) as IRemoteUser;
+ } catch (e) {
+ // duplicate key error
+ if (e.code === 11000) {
+ throw new Error('already registered');
+ }
+
+ console.error(e);
+ throw e;
+ }
//#region アイコンとヘッダー画像をフェッチ
const [avatarId, bannerId] = (await Promise.all([
@@ -194,7 +209,8 @@ export async function updatePerson(value: string | IObject, resolver?: Resolver)
followingCount,
notesCount,
name: person.name,
- url: person.url
+ url: person.url,
+ endpoints: person.endpoints
}
});
}
diff --git a/src/remote/activitypub/renderer/like.ts b/src/remote/activitypub/renderer/like.ts
index 061a10ba84..33e1341a20 100644
--- a/src/remote/activitypub/renderer/like.ts
+++ b/src/remote/activitypub/renderer/like.ts
@@ -1,8 +1,9 @@
import config from '../../../config';
import { ILocalUser } from '../../../models/user';
-export default (user: ILocalUser, note) => ({
+export default (user: ILocalUser, note, reaction: string) => ({
type: 'Like',
actor: `${config.url}/users/${user._id}`,
- object: note.uri ? note.uri : `${config.url}/notes/${note._id}`
+ object: note.uri ? note.uri : `${config.url}/notes/${note._id}`,
+ _misskey_reaction: reaction
});
diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts
index c364b13249..a05c12b388 100644
--- a/src/remote/activitypub/renderer/note.ts
+++ b/src/remote/activitypub/renderer/note.ts
@@ -4,6 +4,7 @@ import config from '../../../config';
import DriveFile from '../../../models/drive-file';
import Note, { INote } from '../../../models/note';
import User from '../../../models/user';
+import toHtml from '../misc/get-note-html';
export default async function renderNote(note: INote, dive = true) {
const promisedFiles = note.mediaIds
@@ -48,7 +49,7 @@ export default async function renderNote(note: INote, dive = true) {
id: `${config.url}/notes/${note._id}`,
type: 'Note',
attributedTo,
- content: note.textHtml,
+ content: toHtml(note),
published: note.createdAt.toISOString(),
to: 'https://www.w3.org/ns/activitystreams#Public',
cc: `${attributedTo}/followers`,
diff --git a/src/remote/activitypub/renderer/person.ts b/src/remote/activitypub/renderer/person.ts
index f1c8056a75..424305f8d3 100644
--- a/src/remote/activitypub/renderer/person.ts
+++ b/src/remote/activitypub/renderer/person.ts
@@ -10,6 +10,7 @@ export default user => {
id,
inbox: `${id}/inbox`,
outbox: `${id}/outbox`,
+ sharedInbox: `${config.url}/inbox`,
url: `${config.url}/@${user.username}`,
preferredUsername: user.username,
name: user.name,
diff --git a/src/remote/activitypub/request.ts b/src/remote/activitypub/request.ts
index 85f43eb91d..e6861fdb3e 100644
--- a/src/remote/activitypub/request.ts
+++ b/src/remote/activitypub/request.ts
@@ -40,5 +40,10 @@ export default (user: ILocalUser, url: string, object) => new Promise((resolve,
keyId: `acct:${user.username}@${config.host}`
});
+ // Signature: Signature ... => Signature: ...
+ let sig = req.getHeader('Signature').toString();
+ sig = sig.replace(/^Signature /, '');
+ req.setHeader('Signature', sig);
+
req.end(JSON.stringify(object));
});
diff --git a/src/remote/activitypub/type.ts b/src/remote/activitypub/type.ts
index 08e5493dd4..ca38ec2227 100644
--- a/src/remote/activitypub/type.ts
+++ b/src/remote/activitypub/type.ts
@@ -15,6 +15,7 @@ export interface IObject {
icon?: any;
image?: any;
url?: string;
+ tag?: any[];
}
export interface IActivity extends IObject {
@@ -49,6 +50,7 @@ export interface IPerson extends IObject {
followers: any;
following: any;
outbox: any;
+ endpoints: string[];
}
export const isCollection = (object: IObject): object is ICollection =>
@@ -82,6 +84,7 @@ export interface IAccept extends IActivity {
export interface ILike extends IActivity {
type: 'Like';
+ _misskey_reaction: string;
}
export interface IAnnounce extends IActivity {
diff --git a/src/remote/resolve-user.ts b/src/remote/resolve-user.ts
index b6048842bf..c612a8c949 100644
--- a/src/remote/resolve-user.ts
+++ b/src/remote/resolve-user.ts
@@ -1,11 +1,16 @@
import { toUnicode, toASCII } from 'punycode';
-import User from '../models/user';
+import User, { IUser } from '../models/user';
import webFinger from './webfinger';
import config from '../config';
import { createPerson } from './activitypub/models/person';
-export default async (username, _host, option) => {
+export default async (username, _host, option?): Promise<IUser> => {
const usernameLower = username.toLowerCase();
+
+ if (_host == null) {
+ return await User.findOne({ usernameLower });
+ }
+
const hostAscii = toASCII(_host).toLowerCase();
const host = toUnicode(hostAscii);
diff --git a/src/renderers/get-note-summary.ts b/src/renderers/get-note-summary.ts
index fc7482ca16..0844c0b184 100644
--- a/src/renderers/get-note-summary.ts
+++ b/src/renderers/get-note-summary.ts
@@ -1,8 +1,12 @@
/**
* 投稿を表す文字列を取得します。
- * @param {*} note 投稿
+ * @param {*} note (packされた)投稿
*/
const summarize = (note: any): string => {
+ if (note.isHidden) {
+ return '(非公開の投稿)';
+ }
+
let summary = '';
// チャンネル
diff --git a/src/server/activitypub.ts b/src/server/activitypub.ts
index e27e2552f3..3c07a3e2f2 100644
--- a/src/server/activitypub.ts
+++ b/src/server/activitypub.ts
@@ -1,3 +1,5 @@
+import * as mongo from 'mongodb';
+import * as Koa from 'koa';
import * as Router from 'koa-router';
const json = require('koa-json-body');
const httpSignature = require('http-signature');
@@ -18,8 +20,7 @@ const router = new Router();
//#region Routing
-// inbox
-router.post('/users/:user/inbox', json(), ctx => {
+function inbox(ctx: Koa.Context) {
let signature;
ctx.req.headers.authorization = 'Signature ' + ctx.req.headers.signature;
@@ -38,7 +39,11 @@ router.post('/users/:user/inbox', json(), ctx => {
}).save();
ctx.status = 202;
-});
+}
+
+// inbox
+router.post('/inbox', json(), inbox);
+router.post('/users/:user/inbox', json(), inbox);
// note
router.get('/notes/:note', async (ctx, next) => {
@@ -49,7 +54,7 @@ router.get('/notes/:note', async (ctx, next) => {
}
const note = await Note.findOne({
- _id: ctx.params.note
+ _id: new mongo.ObjectID(ctx.params.note)
});
if (note === null) {
@@ -62,7 +67,7 @@ router.get('/notes/:note', async (ctx, next) => {
// outbot
router.get('/users/:user/outbox', async ctx => {
- const userId = ctx.params.user;
+ const userId = new mongo.ObjectID(ctx.params.user);
const user = await User.findOne({ _id: userId });
@@ -84,7 +89,7 @@ router.get('/users/:user/outbox', async ctx => {
// publickey
router.get('/users/:user/publickey', async ctx => {
- const userId = ctx.params.user;
+ const userId = new mongo.ObjectID(ctx.params.user);
const user = await User.findOne({ _id: userId });
@@ -102,7 +107,7 @@ router.get('/users/:user/publickey', async ctx => {
// user
router.get('/users/:user', async ctx => {
- const userId = ctx.params.user;
+ const userId = new mongo.ObjectID(ctx.params.user);
const user = await User.findOne({ _id: userId });
diff --git a/src/server/api/common/get-friends.ts b/src/server/api/common/get-friends.ts
index c1cc3957d8..50ba71ea96 100644
--- a/src/server/api/common/get-friends.ts
+++ b/src/server/api/common/get-friends.ts
@@ -1,10 +1,10 @@
import * as mongodb from 'mongodb';
import Following from '../../../models/following';
-export default async (me: mongodb.ObjectID, includeMe: boolean = true) => {
+export const getFriendIds = async (me: mongodb.ObjectID, includeMe = true) => {
// Fetch relation to other users who the I follows
// SELECT followee
- const myfollowing = await Following
+ const followings = await Following
.find({
followerId: me
}, {
@@ -14,7 +14,7 @@ export default async (me: mongodb.ObjectID, includeMe: boolean = true) => {
});
// ID list of other users who the I follows
- const myfollowingIds = myfollowing.map(follow => follow.followeeId);
+ const myfollowingIds = followings.map(following => following.followeeId);
if (includeMe) {
myfollowingIds.push(me);
@@ -22,3 +22,26 @@ export default async (me: mongodb.ObjectID, includeMe: boolean = true) => {
return myfollowingIds;
};
+
+export const getFriends = async (me: mongodb.ObjectID, includeMe = true) => {
+ // Fetch relation to other users who the I follows
+ const followings = await Following
+ .find({
+ followerId: me
+ });
+
+ // ID list of other users who the I follows
+ const myfollowings = followings.map(following => ({
+ id: following.followeeId,
+ stalk: following.stalk
+ }));
+
+ if (includeMe) {
+ myfollowings.push({
+ id: me,
+ stalk: true
+ });
+ }
+
+ return myfollowings;
+};
diff --git a/src/server/api/common/read-messaging-message.ts b/src/server/api/common/read-messaging-message.ts
index c52f9363b5..28854e186e 100644
--- a/src/server/api/common/read-messaging-message.ts
+++ b/src/server/api/common/read-messaging-message.ts
@@ -57,6 +57,8 @@ export default (
.count({
recipientId: userId,
isRead: false
+ }, {
+ limit: 1
});
if (count == 0) {
diff --git a/src/server/api/common/read-notification.ts b/src/server/api/common/read-notification.ts
index 9bd41519fb..7b9faf4cf4 100644
--- a/src/server/api/common/read-notification.ts
+++ b/src/server/api/common/read-notification.ts
@@ -43,6 +43,8 @@ export default (
.count({
notifieeId: userId,
isRead: false
+ }, {
+ limit: 1
});
if (count == 0) {
diff --git a/src/server/api/endpoints.ts b/src/server/api/endpoints.ts
index e0223c23e0..734b8273f1 100644
--- a/src/server/api/endpoints.ts
+++ b/src/server/api/endpoints.ts
@@ -234,6 +234,12 @@ const endpoints: Endpoint[] = [
},
{
+ name: 'i/favorites',
+ withCredential: true,
+ kind: 'favorites-read'
+ },
+
+ {
name: 'othello/match',
withCredential: true
},
@@ -409,6 +415,27 @@ const endpoints: Endpoint[] = [
},
{
+ name: 'users/lists/show',
+ withCredential: true,
+ kind: 'account-read'
+ },
+ {
+ name: 'users/lists/create',
+ withCredential: true,
+ kind: 'account-write'
+ },
+ {
+ name: 'users/lists/push',
+ withCredential: true,
+ kind: 'account-write'
+ },
+ {
+ name: 'users/lists/list',
+ withCredential: true,
+ kind: 'account-read'
+ },
+
+ {
name: 'following/create',
withCredential: true,
limit: {
@@ -426,6 +453,24 @@ const endpoints: Endpoint[] = [
},
kind: 'following-write'
},
+ {
+ name: 'following/stalk',
+ withCredential: true,
+ limit: {
+ duration: ms('1hour'),
+ max: 100
+ },
+ kind: 'following-write'
+ },
+ {
+ name: 'following/unstalk',
+ withCredential: true,
+ limit: {
+ duration: ms('1hour'),
+ max: 100
+ },
+ kind: 'following-write'
+ },
{
name: 'notes'
@@ -480,6 +525,14 @@ const endpoints: Endpoint[] = [
}
},
{
+ name: 'notes/user-list-timeline',
+ withCredential: true,
+ limit: {
+ duration: ms('10minutes'),
+ max: 100
+ }
+ },
+ {
name: 'notes/mentions',
withCredential: true,
limit: {
diff --git a/src/server/api/endpoints/aggregation/posts.ts b/src/server/api/endpoints/aggregation/posts.ts
index cc2a48b53d..d348cadae9 100644
--- a/src/server/api/endpoints/aggregation/posts.ts
+++ b/src/server/api/endpoints/aggregation/posts.ts
@@ -6,13 +6,10 @@ import Note from '../../../../models/note';
/**
* Aggregate notes
- *
- * @param {any} params
- * @return {Promise<any>}
*/
module.exports = params => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 365, limitErr] = $(params.limit).optional.number().range(1, 365).$;
+ const [limit = 365, limitErr] = $.num.optional().range(1, 365).get(params.limit);
if (limitErr) return rej('invalid limit param');
const datas = await Note
diff --git a/src/server/api/endpoints/aggregation/users.ts b/src/server/api/endpoints/aggregation/users.ts
index 19776ed297..b116c1454b 100644
--- a/src/server/api/endpoints/aggregation/users.ts
+++ b/src/server/api/endpoints/aggregation/users.ts
@@ -6,13 +6,10 @@ import User from '../../../../models/user';
/**
* Aggregate users
- *
- * @param {any} params
- * @return {Promise<any>}
*/
module.exports = params => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 365, limitErr] = $(params.limit).optional.number().range(1, 365).$;
+ const [limit = 365, limitErr] = $.num.optional().range(1, 365).get(params.limit);
if (limitErr) return rej('invalid limit param');
const users = await User
diff --git a/src/server/api/endpoints/aggregation/users/activity.ts b/src/server/api/endpoints/aggregation/users/activity.ts
index 318cce77a5..9109487ac6 100644
--- a/src/server/api/endpoints/aggregation/users/activity.ts
+++ b/src/server/api/endpoints/aggregation/users/activity.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import User from '../../../../../models/user';
import Note from '../../../../../models/note';
@@ -9,17 +9,14 @@ import Note from '../../../../../models/note';
/**
* Aggregate activity of a user
- *
- * @param {any} params
- * @return {Promise<any>}
*/
module.exports = (params) => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 365, limitErr] = $(params.limit).optional.number().range(1, 365).$;
+ const [limit = 365, limitErr] = $.num.optional().range(1, 365).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'userId' parameter
- const [userId, userIdErr] = $(params.userId).id().$;
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
if (userIdErr) return rej('invalid userId param');
// Lookup user
diff --git a/src/server/api/endpoints/aggregation/users/followers.ts b/src/server/api/endpoints/aggregation/users/followers.ts
index 7ccb2a3066..dfcaf8462f 100644
--- a/src/server/api/endpoints/aggregation/users/followers.ts
+++ b/src/server/api/endpoints/aggregation/users/followers.ts
@@ -1,19 +1,16 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import User from '../../../../../models/user';
import FollowedLog from '../../../../../models/followed-log';
/**
* Aggregate followers of a user
- *
- * @param {any} params
- * @return {Promise<any>}
*/
module.exports = (params) => new Promise(async (res, rej) => {
// Get 'userId' parameter
- const [userId, userIdErr] = $(params.userId).id().$;
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
if (userIdErr) return rej('invalid userId param');
// Lookup user
diff --git a/src/server/api/endpoints/aggregation/users/following.ts b/src/server/api/endpoints/aggregation/users/following.ts
index 45e246495b..5f826fd71c 100644
--- a/src/server/api/endpoints/aggregation/users/following.ts
+++ b/src/server/api/endpoints/aggregation/users/following.ts
@@ -1,19 +1,16 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import User from '../../../../../models/user';
import FollowingLog from '../../../../../models/following-log';
/**
* Aggregate following of a user
- *
- * @param {any} params
- * @return {Promise<any>}
*/
module.exports = (params) => new Promise(async (res, rej) => {
// Get 'userId' parameter
- const [userId, userIdErr] = $(params.userId).id().$;
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
if (userIdErr) return rej('invalid userId param');
// Lookup user
diff --git a/src/server/api/endpoints/aggregation/users/post.ts b/src/server/api/endpoints/aggregation/users/post.ts
index e6170d83e2..11f9ef14cd 100644
--- a/src/server/api/endpoints/aggregation/users/post.ts
+++ b/src/server/api/endpoints/aggregation/users/post.ts
@@ -1,19 +1,16 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import User from '../../../../../models/user';
import Note from '../../../../../models/note';
/**
* Aggregate note of a user
- *
- * @param {any} params
- * @return {Promise<any>}
*/
module.exports = (params) => new Promise(async (res, rej) => {
// Get 'userId' parameter
- const [userId, userIdErr] = $(params.userId).id().$;
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
if (userIdErr) return rej('invalid userId param');
// Lookup user
diff --git a/src/server/api/endpoints/aggregation/users/reaction.ts b/src/server/api/endpoints/aggregation/users/reaction.ts
index 881c7ea693..2de2840258 100644
--- a/src/server/api/endpoints/aggregation/users/reaction.ts
+++ b/src/server/api/endpoints/aggregation/users/reaction.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import User from '../../../../../models/user';
import Reaction from '../../../../../models/note-reaction';
@@ -13,7 +13,7 @@ import Reaction from '../../../../../models/note-reaction';
*/
module.exports = (params) => new Promise(async (res, rej) => {
// Get 'userId' parameter
- const [userId, userIdErr] = $(params.userId).id().$;
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
if (userIdErr) return rej('invalid userId param');
// Lookup user
diff --git a/src/server/api/endpoints/app/create.ts b/src/server/api/endpoints/app/create.ts
index 4a55d33f2d..553bd2381a 100644
--- a/src/server/api/endpoints/app/create.ts
+++ b/src/server/api/endpoints/app/create.ts
@@ -67,24 +67,24 @@ import App, { isValidNameId, pack } from '../../../../models/app';
*/
module.exports = async (params, user) => new Promise(async (res, rej) => {
// Get 'nameId' parameter
- const [nameId, nameIdErr] = $(params.nameId).string().pipe(isValidNameId).$;
+ const [nameId, nameIdErr] = $.str.pipe(isValidNameId).get(params.nameId);
if (nameIdErr) return rej('invalid nameId param');
// Get 'name' parameter
- const [name, nameErr] = $(params.name).string().$;
+ const [name, nameErr] = $.str.get(params.name);
if (nameErr) return rej('invalid name param');
// Get 'description' parameter
- const [description, descriptionErr] = $(params.description).string().$;
+ const [description, descriptionErr] = $.str.get(params.description);
if (descriptionErr) return rej('invalid description param');
// Get 'permission' parameter
- const [permission, permissionErr] = $(params.permission).array('string').unique().$;
+ const [permission, permissionErr] = $.arr($.str).unique().get(params.permission);
if (permissionErr) return rej('invalid permission param');
// Get 'callbackUrl' parameter
// TODO: Check it is valid url
- const [callbackUrl = null, callbackUrlErr] = $(params.callbackUrl).optional.nullable.string().$;
+ const [callbackUrl = null, callbackUrlErr] = $.str.optional().nullable().get(params.callbackUrl);
if (callbackUrlErr) return rej('invalid callbackUrl param');
// Generate secret
diff --git a/src/server/api/endpoints/app/name_id/available.ts b/src/server/api/endpoints/app/name_id/available.ts
index ec2d692412..135bb7d2b4 100644
--- a/src/server/api/endpoints/app/name_id/available.ts
+++ b/src/server/api/endpoints/app/name_id/available.ts
@@ -42,7 +42,7 @@ import { isValidNameId } from '../../../../../models/app';
*/
module.exports = async (params) => new Promise(async (res, rej) => {
// Get 'nameId' parameter
- const [nameId, nameIdErr] = $(params.nameId).string().pipe(isValidNameId).$;
+ const [nameId, nameIdErr] = $.str.pipe(isValidNameId).get(params.nameId);
if (nameIdErr) return rej('invalid nameId param');
// Get exist
diff --git a/src/server/api/endpoints/app/show.ts b/src/server/api/endpoints/app/show.ts
index 99a2093b68..8d742ab182 100644
--- a/src/server/api/endpoints/app/show.ts
+++ b/src/server/api/endpoints/app/show.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import App, { pack } from '../../../../models/app';
/**
@@ -41,11 +41,11 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
const isSecure = user != null && app == null;
// Get 'appId' parameter
- const [appId, appIdErr] = $(params.appId).optional.id().$;
+ const [appId, appIdErr] = $.type(ID).optional().get(params.appId);
if (appIdErr) return rej('invalid appId param');
// Get 'nameId' parameter
- const [nameId, nameIdErr] = $(params.nameId).optional.string().$;
+ const [nameId, nameIdErr] = $.str.optional().get(params.nameId);
if (nameIdErr) return rej('invalid nameId param');
if (appId === undefined && nameId === undefined) {
diff --git a/src/server/api/endpoints/auth/accept.ts b/src/server/api/endpoints/auth/accept.ts
index b6297d663d..695fbb0803 100644
--- a/src/server/api/endpoints/auth/accept.ts
+++ b/src/server/api/endpoints/auth/accept.ts
@@ -40,7 +40,7 @@ import AccessToken from '../../../../models/access-token';
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'token' parameter
- const [token, tokenErr] = $(params.token).string().$;
+ const [token, tokenErr] = $.str.get(params.token);
if (tokenErr) return rej('invalid token param');
// Fetch token
diff --git a/src/server/api/endpoints/auth/session/generate.ts b/src/server/api/endpoints/auth/session/generate.ts
index 7c475dbe26..d649a8d902 100644
--- a/src/server/api/endpoints/auth/session/generate.ts
+++ b/src/server/api/endpoints/auth/session/generate.ts
@@ -46,7 +46,7 @@ import config from '../../../../../config';
*/
module.exports = (params) => new Promise(async (res, rej) => {
// Get 'appSecret' parameter
- const [appSecret, appSecretErr] = $(params.appSecret).string().$;
+ const [appSecret, appSecretErr] = $.str.get(params.appSecret);
if (appSecretErr) return rej('invalid appSecret param');
// Lookup app
diff --git a/src/server/api/endpoints/auth/session/show.ts b/src/server/api/endpoints/auth/session/show.ts
index f7f0b087b7..434cc264a0 100644
--- a/src/server/api/endpoints/auth/session/show.ts
+++ b/src/server/api/endpoints/auth/session/show.ts
@@ -53,7 +53,7 @@ import AuthSess, { pack } from '../../../../../models/auth-session';
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'token' parameter
- const [token, tokenErr] = $(params.token).string().$;
+ const [token, tokenErr] = $.str.get(params.token);
if (tokenErr) return rej('invalid token param');
// Lookup session
diff --git a/src/server/api/endpoints/auth/session/userkey.ts b/src/server/api/endpoints/auth/session/userkey.ts
index ddb67cb451..3026b477f1 100644
--- a/src/server/api/endpoints/auth/session/userkey.ts
+++ b/src/server/api/endpoints/auth/session/userkey.ts
@@ -51,7 +51,7 @@ import { pack } from '../../../../../models/user';
*/
module.exports = (params) => new Promise(async (res, rej) => {
// Get 'appSecret' parameter
- const [appSecret, appSecretErr] = $(params.appSecret).string().$;
+ const [appSecret, appSecretErr] = $.str.get(params.appSecret);
if (appSecretErr) return rej('invalid appSecret param');
// Lookup app
@@ -64,7 +64,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
}
// Get 'token' parameter
- const [token, tokenErr] = $(params.token).string().$;
+ const [token, tokenErr] = $.str.get(params.token);
if (tokenErr) return rej('invalid token param');
// Fetch token
diff --git a/src/server/api/endpoints/channels.ts b/src/server/api/endpoints/channels.ts
index 582e6ba43b..ceef4b9cb9 100644
--- a/src/server/api/endpoints/channels.ts
+++ b/src/server/api/endpoints/channels.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../cafy-id';
import Channel, { pack } from '../../../models/channel';
/**
@@ -13,15 +13,15 @@ import Channel, { pack } from '../../../models/channel';
*/
module.exports = (params, me) => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) return rej('invalid sinceId param');
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) return rej('invalid untilId param');
// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/channels/create.ts b/src/server/api/endpoints/channels/create.ts
index 0f0f558c8a..0e3c9dc5ac 100644
--- a/src/server/api/endpoints/channels/create.ts
+++ b/src/server/api/endpoints/channels/create.ts
@@ -8,14 +8,10 @@ import { pack } from '../../../../models/channel';
/**
* Create a channel
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = async (params, user) => new Promise(async (res, rej) => {
// Get 'title' parameter
- const [title, titleErr] = $(params.title).string().range(1, 100).$;
+ const [title, titleErr] = $.str.range(1, 100).get(params.title);
if (titleErr) return rej('invalid title param');
// Create a channel
diff --git a/src/server/api/endpoints/channels/notes.ts b/src/server/api/endpoints/channels/notes.ts
index d636aa0d10..463152e74a 100644
--- a/src/server/api/endpoints/channels/notes.ts
+++ b/src/server/api/endpoints/channels/notes.ts
@@ -1,28 +1,24 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import { default as Channel, IChannel } from '../../../../models/channel';
import Note, { pack } from '../../../../models/note';
/**
* Show a notes of a channel
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 1000, limitErr] = $(params.limit).optional.number().range(1, 1000).$;
+ const [limit = 1000, limitErr] = $.num.optional().range(1, 1000).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) return rej('invalid sinceId param');
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) return rej('invalid untilId param');
// Check if both of sinceId and untilId is specified
@@ -31,7 +27,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
}
// Get 'channelId' parameter
- const [channelId, channelIdErr] = $(params.channelId).id().$;
+ const [channelId, channelIdErr] = $.type(ID).get(params.channelId);
if (channelIdErr) return rej('invalid channelId param');
// Fetch channel
diff --git a/src/server/api/endpoints/channels/show.ts b/src/server/api/endpoints/channels/show.ts
index 3ce9ce4745..1bba63d490 100644
--- a/src/server/api/endpoints/channels/show.ts
+++ b/src/server/api/endpoints/channels/show.ts
@@ -1,19 +1,15 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Channel, { IChannel, pack } from '../../../../models/channel';
/**
* Show a channel
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'channelId' parameter
- const [channelId, channelIdErr] = $(params.channelId).id().$;
+ const [channelId, channelIdErr] = $.type(ID).get(params.channelId);
if (channelIdErr) return rej('invalid channelId param');
// Fetch channel
diff --git a/src/server/api/endpoints/channels/unwatch.ts b/src/server/api/endpoints/channels/unwatch.ts
index 8220b90b68..f7dddff461 100644
--- a/src/server/api/endpoints/channels/unwatch.ts
+++ b/src/server/api/endpoints/channels/unwatch.ts
@@ -1,20 +1,16 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Channel from '../../../../models/channel';
import Watching from '../../../../models/channel-watching';
/**
* Unwatch a channel
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'channelId' parameter
- const [channelId, channelIdErr] = $(params.channelId).id().$;
+ const [channelId, channelIdErr] = $.type(ID).get(params.channelId);
if (channelIdErr) return rej('invalid channelId param');
//#region Fetch channel
diff --git a/src/server/api/endpoints/channels/watch.ts b/src/server/api/endpoints/channels/watch.ts
index 6906282a54..34243ff68b 100644
--- a/src/server/api/endpoints/channels/watch.ts
+++ b/src/server/api/endpoints/channels/watch.ts
@@ -1,20 +1,16 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Channel from '../../../../models/channel';
import Watching from '../../../../models/channel-watching';
/**
* Watch a channel
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'channelId' parameter
- const [channelId, channelIdErr] = $(params.channelId).id().$;
+ const [channelId, channelIdErr] = $.type(ID).get(params.channelId);
if (channelIdErr) return rej('invalid channelId param');
//#region Fetch channel
diff --git a/src/server/api/endpoints/drive/files.ts b/src/server/api/endpoints/drive/files.ts
index 63d69d145a..ab4b18cef4 100644
--- a/src/server/api/endpoints/drive/files.ts
+++ b/src/server/api/endpoints/drive/files.ts
@@ -1,28 +1,23 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import DriveFile, { pack } from '../../../../models/drive-file';
/**
* Get drive files
- *
- * @param {any} params
- * @param {any} user
- * @param {any} app
- * @return {Promise<any>}
*/
module.exports = async (params, user, app) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) throw 'invalid limit param';
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) throw 'invalid sinceId param';
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) throw 'invalid untilId param';
// Check if both of sinceId and untilId is specified
@@ -31,11 +26,11 @@ module.exports = async (params, user, app) => {
}
// Get 'folderId' parameter
- const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$;
+ const [folderId = null, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId);
if (folderIdErr) throw 'invalid folderId param';
// Get 'type' parameter
- const [type, typeErr] = $(params.type).optional.string().match(/^[a-zA-Z\/\-\*]+$/).$;
+ const [type, typeErr] = $.str.optional().match(/^[a-zA-Z\/\-\*]+$/).get(params.type);
if (typeErr) throw 'invalid type param';
// Construct query
diff --git a/src/server/api/endpoints/drive/files/create.ts b/src/server/api/endpoints/drive/files/create.ts
index df0bd0a0d3..e9348e4e2f 100644
--- a/src/server/api/endpoints/drive/files/create.ts
+++ b/src/server/api/endpoints/drive/files/create.ts
@@ -1,17 +1,12 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import { validateFileName, pack } from '../../../../../models/drive-file';
import create from '../../../../../services/drive/add-file';
/**
* Create a file
- *
- * @param {any} file
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = async (file, params, user): Promise<any> => {
if (file == null) {
@@ -34,7 +29,7 @@ module.exports = async (file, params, user): Promise<any> => {
}
// Get 'folderId' parameter
- const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$;
+ const [folderId = null, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId);
if (folderIdErr) throw 'invalid folderId param';
try {
diff --git a/src/server/api/endpoints/drive/files/find.ts b/src/server/api/endpoints/drive/files/find.ts
index 0ab6e5d3e3..98165990fe 100644
--- a/src/server/api/endpoints/drive/files/find.ts
+++ b/src/server/api/endpoints/drive/files/find.ts
@@ -1,23 +1,19 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import DriveFile, { pack } from '../../../../../models/drive-file';
/**
* Find a file(s)
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'name' parameter
- const [name, nameErr] = $(params.name).string().$;
+ const [name, nameErr] = $.str.get(params.name);
if (nameErr) return rej('invalid name param');
// Get 'folderId' parameter
- const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$;
+ const [folderId = null, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId);
if (folderIdErr) return rej('invalid folderId param');
// Issue query
diff --git a/src/server/api/endpoints/drive/files/show.ts b/src/server/api/endpoints/drive/files/show.ts
index 3398f24541..c7efda7ab0 100644
--- a/src/server/api/endpoints/drive/files/show.ts
+++ b/src/server/api/endpoints/drive/files/show.ts
@@ -1,19 +1,15 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import DriveFile, { pack } from '../../../../../models/drive-file';
/**
* Show a file
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = async (params, user) => {
// Get 'fileId' parameter
- const [fileId, fileIdErr] = $(params.fileId).id().$;
+ const [fileId, fileIdErr] = $.type(ID).get(params.fileId);
if (fileIdErr) throw 'invalid fileId param';
// Fetch file
diff --git a/src/server/api/endpoints/drive/files/update.ts b/src/server/api/endpoints/drive/files/update.ts
index c783ad8b3b..12fa8e025d 100644
--- a/src/server/api/endpoints/drive/files/update.ts
+++ b/src/server/api/endpoints/drive/files/update.ts
@@ -1,21 +1,17 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import DriveFolder from '../../../../../models/drive-folder';
import DriveFile, { validateFileName, pack } from '../../../../../models/drive-file';
import { publishDriveStream } from '../../../../../publishers/stream';
/**
* Update a file
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'fileId' parameter
- const [fileId, fileIdErr] = $(params.fileId).id().$;
+ const [fileId, fileIdErr] = $.type(ID).get(params.fileId);
if (fileIdErr) return rej('invalid fileId param');
// Fetch file
@@ -30,12 +26,12 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
}
// Get 'name' parameter
- const [name, nameErr] = $(params.name).optional.string().pipe(validateFileName).$;
+ const [name, nameErr] = $.str.optional().pipe(validateFileName).get(params.name);
if (nameErr) return rej('invalid name param');
if (name) file.filename = name;
// Get 'folderId' parameter
- const [folderId, folderIdErr] = $(params.folderId).optional.nullable.id().$;
+ const [folderId, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId);
if (folderIdErr) return rej('invalid folderId param');
if (folderId !== undefined) {
diff --git a/src/server/api/endpoints/drive/files/upload_from_url.ts b/src/server/api/endpoints/drive/files/upload_from_url.ts
index 8a426c0efc..c012f0d3c9 100644
--- a/src/server/api/endpoints/drive/files/upload_from_url.ts
+++ b/src/server/api/endpoints/drive/files/upload_from_url.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import { pack } from '../../../../../models/drive-file';
import uploadFromUrl from '../../../../../services/drive/upload-from-url';
@@ -11,11 +11,11 @@ import uploadFromUrl from '../../../../../services/drive/upload-from-url';
module.exports = async (params, user): Promise<any> => {
// Get 'url' parameter
// TODO: Validate this url
- const [url, urlErr] = $(params.url).string().$;
+ const [url, urlErr] = $.str.get(params.url);
if (urlErr) throw 'invalid url param';
// Get 'folderId' parameter
- const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$;
+ const [folderId = null, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId);
if (folderIdErr) throw 'invalid folderId param';
return pack(await uploadFromUrl(url, user, folderId));
diff --git a/src/server/api/endpoints/drive/folders.ts b/src/server/api/endpoints/drive/folders.ts
index 489e47912e..bc6c50eb99 100644
--- a/src/server/api/endpoints/drive/folders.ts
+++ b/src/server/api/endpoints/drive/folders.ts
@@ -1,28 +1,23 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import DriveFolder, { pack } from '../../../../models/drive-folder';
/**
* Get drive folders
- *
- * @param {any} params
- * @param {any} user
- * @param {any} app
- * @return {Promise<any>}
*/
module.exports = (params, user, app) => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) return rej('invalid sinceId param');
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) return rej('invalid untilId param');
// Check if both of sinceId and untilId is specified
@@ -31,7 +26,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
}
// Get 'folderId' parameter
- const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$;
+ const [folderId = null, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId);
if (folderIdErr) return rej('invalid folderId param');
// Construct query
diff --git a/src/server/api/endpoints/drive/folders/create.ts b/src/server/api/endpoints/drive/folders/create.ts
index f34d0019d7..62e3b6f6e8 100644
--- a/src/server/api/endpoints/drive/folders/create.ts
+++ b/src/server/api/endpoints/drive/folders/create.ts
@@ -1,24 +1,20 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import DriveFolder, { isValidFolderName, pack } from '../../../../../models/drive-folder';
import { publishDriveStream } from '../../../../../publishers/stream';
/**
* Create drive folder
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'name' parameter
- const [name = '無題のフォルダー', nameErr] = $(params.name).optional.string().pipe(isValidFolderName).$;
+ const [name = '無題のフォルダー', nameErr] = $.str.optional().pipe(isValidFolderName).get(params.name);
if (nameErr) return rej('invalid name param');
// Get 'parentId' parameter
- const [parentId = null, parentIdErr] = $(params.parentId).optional.nullable.id().$;
+ const [parentId = null, parentIdErr] = $.type(ID).optional().nullable().get(params.parentId);
if (parentIdErr) return rej('invalid parentId param');
// If the parent folder is specified
diff --git a/src/server/api/endpoints/drive/folders/find.ts b/src/server/api/endpoints/drive/folders/find.ts
index 04dc38f87f..9703d9e99d 100644
--- a/src/server/api/endpoints/drive/folders/find.ts
+++ b/src/server/api/endpoints/drive/folders/find.ts
@@ -1,23 +1,19 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import DriveFolder, { pack } from '../../../../../models/drive-folder';
/**
* Find a folder(s)
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'name' parameter
- const [name, nameErr] = $(params.name).string().$;
+ const [name, nameErr] = $.str.get(params.name);
if (nameErr) return rej('invalid name param');
// Get 'parentId' parameter
- const [parentId = null, parentIdErr] = $(params.parentId).optional.nullable.id().$;
+ const [parentId = null, parentIdErr] = $.type(ID).optional().nullable().get(params.parentId);
if (parentIdErr) return rej('invalid parentId param');
// Issue query
diff --git a/src/server/api/endpoints/drive/folders/show.ts b/src/server/api/endpoints/drive/folders/show.ts
index b432f5a50a..44f1889001 100644
--- a/src/server/api/endpoints/drive/folders/show.ts
+++ b/src/server/api/endpoints/drive/folders/show.ts
@@ -1,19 +1,15 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import DriveFolder, { pack } from '../../../../../models/drive-folder';
/**
* Show a folder
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'folderId' parameter
- const [folderId, folderIdErr] = $(params.folderId).id().$;
+ const [folderId, folderIdErr] = $.type(ID).get(params.folderId);
if (folderIdErr) return rej('invalid folderId param');
// Get folder
diff --git a/src/server/api/endpoints/drive/folders/update.ts b/src/server/api/endpoints/drive/folders/update.ts
index dd7e8f5c86..e24d8a14cd 100644
--- a/src/server/api/endpoints/drive/folders/update.ts
+++ b/src/server/api/endpoints/drive/folders/update.ts
@@ -1,20 +1,16 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import DriveFolder, { isValidFolderName, pack } from '../../../../../models/drive-folder';
import { publishDriveStream } from '../../../../../publishers/stream';
/**
* Update a folder
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'folderId' parameter
- const [folderId, folderIdErr] = $(params.folderId).id().$;
+ const [folderId, folderIdErr] = $.type(ID).get(params.folderId);
if (folderIdErr) return rej('invalid folderId param');
// Fetch folder
@@ -29,12 +25,12 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
}
// Get 'name' parameter
- const [name, nameErr] = $(params.name).optional.string().pipe(isValidFolderName).$;
+ const [name, nameErr] = $.str.optional().pipe(isValidFolderName).get(params.name);
if (nameErr) return rej('invalid name param');
if (name) folder.name = name;
// Get 'parentId' parameter
- const [parentId, parentIdErr] = $(params.parentId).optional.nullable.id().$;
+ const [parentId, parentIdErr] = $.type(ID).optional().nullable().get(params.parentId);
if (parentIdErr) return rej('invalid parentId param');
if (parentId !== undefined) {
if (parentId === null) {
diff --git a/src/server/api/endpoints/drive/stream.ts b/src/server/api/endpoints/drive/stream.ts
index 02313aa37b..8cb3a99b42 100644
--- a/src/server/api/endpoints/drive/stream.ts
+++ b/src/server/api/endpoints/drive/stream.ts
@@ -1,27 +1,23 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import DriveFile, { pack } from '../../../../models/drive-file';
/**
* Get drive stream
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) return rej('invalid sinceId param');
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) return rej('invalid untilId param');
// Check if both of sinceId and untilId is specified
@@ -30,7 +26,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
}
// Get 'type' parameter
- const [type, typeErr] = $(params.type).optional.string().match(/^[a-zA-Z\/\-\*]+$/).$;
+ const [type, typeErr] = $.str.optional().match(/^[a-zA-Z\/\-\*]+$/).get(params.type);
if (typeErr) return rej('invalid type param');
// Construct query
diff --git a/src/server/api/endpoints/following/create.ts b/src/server/api/endpoints/following/create.ts
index 27e5eb31db..766a8c03d0 100644
--- a/src/server/api/endpoints/following/create.ts
+++ b/src/server/api/endpoints/following/create.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import User from '../../../../models/user';
import Following from '../../../../models/following';
import create from '../../../../services/following/create';
@@ -13,7 +13,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
const follower = user;
// Get 'userId' parameter
- const [userId, userIdErr] = $(params.userId).id().$;
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
if (userIdErr) return rej('invalid userId param');
// 自分自身
diff --git a/src/server/api/endpoints/following/delete.ts b/src/server/api/endpoints/following/delete.ts
index ca0703ca22..396b19a6f6 100644
--- a/src/server/api/endpoints/following/delete.ts
+++ b/src/server/api/endpoints/following/delete.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import User from '../../../../models/user';
import Following from '../../../../models/following';
import deleteFollowing from '../../../../services/following/delete';
@@ -13,7 +13,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
const follower = user;
// Get 'userId' parameter
- const [userId, userIdErr] = $(params.userId).id().$;
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
if (userIdErr) return rej('invalid userId param');
// Check if the followee is yourself
diff --git a/src/server/api/endpoints/following/stalk.ts b/src/server/api/endpoints/following/stalk.ts
new file mode 100644
index 0000000000..f0bc8cbdfc
--- /dev/null
+++ b/src/server/api/endpoints/following/stalk.ts
@@ -0,0 +1,35 @@
+import $ from 'cafy'; import ID from '../../../../cafy-id';
+import Following from '../../../../models/following';
+
+/**
+ * Stalk a user
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ const follower = user;
+
+ // Get 'userId' parameter
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
+ if (userIdErr) return rej('invalid userId param');
+
+ // Fetch following
+ const following = await Following.findOne({
+ followerId: follower._id,
+ followeeId: userId
+ });
+
+ if (following === null) {
+ return rej('following not found');
+ }
+
+ // Stalk
+ await Following.update({ _id: following._id }, {
+ $set: {
+ stalk: true
+ }
+ });
+
+ // Send response
+ res();
+
+ // TODO: イベント
+});
diff --git a/src/server/api/endpoints/following/unstalk.ts b/src/server/api/endpoints/following/unstalk.ts
new file mode 100644
index 0000000000..0d0a018c34
--- /dev/null
+++ b/src/server/api/endpoints/following/unstalk.ts
@@ -0,0 +1,35 @@
+import $ from 'cafy'; import ID from '../../../../cafy-id';
+import Following from '../../../../models/following';
+
+/**
+ * Unstalk a user
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ const follower = user;
+
+ // Get 'userId' parameter
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
+ if (userIdErr) return rej('invalid userId param');
+
+ // Fetch following
+ const following = await Following.findOne({
+ followerId: follower._id,
+ followeeId: userId
+ });
+
+ if (following === null) {
+ return rej('following not found');
+ }
+
+ // Stalk
+ await Following.update({ _id: following._id }, {
+ $set: {
+ stalk: false
+ }
+ });
+
+ // Send response
+ res();
+
+ // TODO: イベント
+});
diff --git a/src/server/api/endpoints/i/2fa/done.ts b/src/server/api/endpoints/i/2fa/done.ts
index 3e824feffd..1a2706aa84 100644
--- a/src/server/api/endpoints/i/2fa/done.ts
+++ b/src/server/api/endpoints/i/2fa/done.ts
@@ -7,7 +7,7 @@ import User from '../../../../../models/user';
module.exports = async (params, user) => new Promise(async (res, rej) => {
// Get 'token' parameter
- const [token, tokenErr] = $(params.token).string().$;
+ const [token, tokenErr] = $.str.get(params.token);
if (tokenErr) return rej('invalid token param');
const _token = token.replace(/\s/g, '');
diff --git a/src/server/api/endpoints/i/2fa/register.ts b/src/server/api/endpoints/i/2fa/register.ts
index bed64a2545..d314e1a280 100644
--- a/src/server/api/endpoints/i/2fa/register.ts
+++ b/src/server/api/endpoints/i/2fa/register.ts
@@ -10,7 +10,7 @@ import config from '../../../../../config';
module.exports = async (params, user) => new Promise(async (res, rej) => {
// Get 'password' parameter
- const [password, passwordErr] = $(params.password).string().$;
+ const [password, passwordErr] = $.str.get(params.password);
if (passwordErr) return rej('invalid password param');
// Compare password
diff --git a/src/server/api/endpoints/i/2fa/unregister.ts b/src/server/api/endpoints/i/2fa/unregister.ts
index f9d7a25f53..336a3564ab 100644
--- a/src/server/api/endpoints/i/2fa/unregister.ts
+++ b/src/server/api/endpoints/i/2fa/unregister.ts
@@ -7,7 +7,7 @@ import User from '../../../../../models/user';
module.exports = async (params, user) => new Promise(async (res, rej) => {
// Get 'password' parameter
- const [password, passwordErr] = $(params.password).string().$;
+ const [password, passwordErr] = $.str.get(params.password);
if (passwordErr) return rej('invalid password param');
// Compare password
diff --git a/src/server/api/endpoints/i/authorized_apps.ts b/src/server/api/endpoints/i/authorized_apps.ts
index 82fd2d2516..d15bd67bf2 100644
--- a/src/server/api/endpoints/i/authorized_apps.ts
+++ b/src/server/api/endpoints/i/authorized_apps.ts
@@ -7,22 +7,18 @@ import { pack } from '../../../../models/app';
/**
* Get authorized apps of my account
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'offset' parameter
- const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset);
if (offsetErr) return rej('invalid offset param');
// Get 'sort' parameter
- const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$;
+ const [sort = 'desc', sortError] = $.str.optional().or('desc asc').get(params.sort);
if (sortError) return rej('invalid sort param');
// Get tokens
diff --git a/src/server/api/endpoints/i/change_password.ts b/src/server/api/endpoints/i/change_password.ts
index 57415083f1..a1a1a43406 100644
--- a/src/server/api/endpoints/i/change_password.ts
+++ b/src/server/api/endpoints/i/change_password.ts
@@ -7,18 +7,14 @@ import User from '../../../../models/user';
/**
* Change password
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = async (params, user) => new Promise(async (res, rej) => {
// Get 'currentPasword' parameter
- const [currentPassword, currentPasswordErr] = $(params.currentPasword).string().$;
+ const [currentPassword, currentPasswordErr] = $.str.get(params.currentPasword);
if (currentPasswordErr) return rej('invalid currentPasword param');
// Get 'newPassword' parameter
- const [newPassword, newPasswordErr] = $(params.newPassword).string().$;
+ const [newPassword, newPasswordErr] = $.str.get(params.newPassword);
if (newPasswordErr) return rej('invalid newPassword param');
// Compare password
diff --git a/src/server/api/endpoints/i/favorites.ts b/src/server/api/endpoints/i/favorites.ts
index b40f2b3887..23517baaff 100644
--- a/src/server/api/endpoints/i/favorites.ts
+++ b/src/server/api/endpoints/i/favorites.ts
@@ -1,44 +1,53 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
-import Favorite from '../../../../models/favorite';
-import { pack } from '../../../../models/note';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
+import Favorite, { pack } from '../../../../models/favorite';
/**
- * Get followers of a user
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
+ * Get favorited notes
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
- // Get 'offset' parameter
- const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
- if (offsetErr) return rej('invalid offset param');
+ // Get 'sinceId' parameter
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
+ if (sinceIdErr) return rej('invalid sinceId param');
- // Get 'sort' parameter
- const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$;
- if (sortError) return rej('invalid sort param');
+ // Get 'untilId' parameter
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
+ if (untilIdErr) return rej('invalid untilId param');
+
+ // Check if both of sinceId and untilId is specified
+ if (sinceId && untilId) {
+ return rej('cannot set sinceId and untilId');
+ }
+
+ const query = {
+ userId: user._id
+ } as any;
+
+ const sort = {
+ _id: -1
+ };
+
+ if (sinceId) {
+ sort._id = 1;
+ query._id = {
+ $gt: sinceId
+ };
+ } else if (untilId) {
+ query._id = {
+ $lt: untilId
+ };
+ }
// Get favorites
const favorites = await Favorite
- .find({
- userId: user._id
- }, {
- limit: limit,
- skip: offset,
- sort: {
- _id: sort == 'asc' ? 1 : -1
- }
- });
+ .find(query, { limit, sort });
// Serialize
- res(await Promise.all(favorites.map(async favorite =>
- await pack(favorite.noteId)
- )));
+ res(await Promise.all(favorites.map(favorite => pack(favorite, user))));
});
diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts
index 3b4899682d..50ed9b27e8 100644
--- a/src/server/api/endpoints/i/notifications.ts
+++ b/src/server/api/endpoints/i/notifications.ts
@@ -1,11 +1,11 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Notification from '../../../../models/notification';
import Mute from '../../../../models/mute';
import { pack } from '../../../../models/notification';
-import getFriends from '../../common/get-friends';
+import { getFriendIds } from '../../common/get-friends';
import read from '../../common/read-notification';
/**
@@ -14,27 +14,27 @@ import read from '../../common/read-notification';
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'following' parameter
const [following = false, followingError] =
- $(params.following).optional.boolean().$;
+ $.bool.optional().get(params.following);
if (followingError) return rej('invalid following param');
// Get 'markAsRead' parameter
- const [markAsRead = true, markAsReadErr] = $(params.markAsRead).optional.boolean().$;
+ const [markAsRead = true, markAsReadErr] = $.bool.optional().get(params.markAsRead);
if (markAsReadErr) return rej('invalid markAsRead param');
// Get 'type' parameter
- const [type, typeErr] = $(params.type).optional.array('string').unique().$;
+ const [type, typeErr] = $.arr($.str).optional().unique().get(params.type);
if (typeErr) return rej('invalid type param');
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) return rej('invalid sinceId param');
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) return rej('invalid untilId param');
// Check if both of sinceId and untilId is specified
@@ -62,7 +62,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
if (following) {
// ID list of the user itself and other users who the user follows
- const followingIds = await getFriends(user._id);
+ const followingIds = await getFriendIds(user._id);
query.$and.push({
notifierId: {
diff --git a/src/server/api/endpoints/i/pin.ts b/src/server/api/endpoints/i/pin.ts
index 909a6fdbde..423f0ac4ae 100644
--- a/src/server/api/endpoints/i/pin.ts
+++ b/src/server/api/endpoints/i/pin.ts
@@ -1,21 +1,17 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import User from '../../../../models/user';
import Note from '../../../../models/note';
import { pack } from '../../../../models/user';
/**
* Pin note
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = async (params, user) => new Promise(async (res, rej) => {
// Get 'noteId' parameter
- const [noteId, noteIdErr] = $(params.noteId).id().$;
+ const [noteId, noteIdErr] = $.type(ID).get(params.noteId);
if (noteIdErr) return rej('invalid noteId param');
// Fetch pinee
diff --git a/src/server/api/endpoints/i/regenerate_token.ts b/src/server/api/endpoints/i/regenerate_token.ts
index f9e92c1797..6e1e571297 100644
--- a/src/server/api/endpoints/i/regenerate_token.ts
+++ b/src/server/api/endpoints/i/regenerate_token.ts
@@ -9,14 +9,10 @@ import generateUserToken from '../../common/generate-native-user-token';
/**
* Regenerate native token
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = async (params, user) => new Promise(async (res, rej) => {
// Get 'password' parameter
- const [password, passwordErr] = $(params.password).string().$;
+ const [password, passwordErr] = $.str.get(params.password);
if (passwordErr) return rej('invalid password param');
// Compare password
diff --git a/src/server/api/endpoints/i/signin_history.ts b/src/server/api/endpoints/i/signin_history.ts
index 931b9e2252..63a74b41b1 100644
--- a/src/server/api/endpoints/i/signin_history.ts
+++ b/src/server/api/endpoints/i/signin_history.ts
@@ -1,27 +1,23 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Signin, { pack } from '../../../../models/signin';
/**
* Get signin history of my account
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) return rej('invalid sinceId param');
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) return rej('invalid untilId param');
// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts
index f3c9d777b5..b7b25d0f65 100644
--- a/src/server/api/endpoints/i/update.ts
+++ b/src/server/api/endpoints/i/update.ts
@@ -1,9 +1,10 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import User, { isValidName, isValidDescription, isValidLocation, isValidBirthday, pack } from '../../../../models/user';
import event from '../../../../publishers/stream';
+import DriveFile from '../../../../models/drive-file';
/**
* Update myself
@@ -12,51 +13,73 @@ module.exports = async (params, user, app) => new Promise(async (res, rej) => {
const isSecure = user != null && app == null;
// Get 'name' parameter
- const [name, nameErr] = $(params.name).optional.nullable.string().pipe(isValidName).$;
+ const [name, nameErr] = $.str.optional().nullable().pipe(isValidName).get(params.name);
if (nameErr) return rej('invalid name param');
if (name) user.name = name;
// Get 'description' parameter
- const [description, descriptionErr] = $(params.description).optional.nullable.string().pipe(isValidDescription).$;
+ const [description, descriptionErr] = $.str.optional().nullable().pipe(isValidDescription).get(params.description);
if (descriptionErr) return rej('invalid description param');
if (description !== undefined) user.description = description;
// Get 'location' parameter
- const [location, locationErr] = $(params.location).optional.nullable.string().pipe(isValidLocation).$;
+ const [location, locationErr] = $.str.optional().nullable().pipe(isValidLocation).get(params.location);
if (locationErr) return rej('invalid location param');
if (location !== undefined) user.profile.location = location;
// Get 'birthday' parameter
- const [birthday, birthdayErr] = $(params.birthday).optional.nullable.string().pipe(isValidBirthday).$;
+ const [birthday, birthdayErr] = $.str.optional().nullable().pipe(isValidBirthday).get(params.birthday);
if (birthdayErr) return rej('invalid birthday param');
if (birthday !== undefined) user.profile.birthday = birthday;
// Get 'avatarId' parameter
- const [avatarId, avatarIdErr] = $(params.avatarId).optional.id().$;
+ const [avatarId, avatarIdErr] = $.type(ID).optional().get(params.avatarId);
if (avatarIdErr) return rej('invalid avatarId param');
if (avatarId) user.avatarId = avatarId;
// Get 'bannerId' parameter
- const [bannerId, bannerIdErr] = $(params.bannerId).optional.id().$;
+ const [bannerId, bannerIdErr] = $.type(ID).optional().get(params.bannerId);
if (bannerIdErr) return rej('invalid bannerId param');
if (bannerId) user.bannerId = bannerId;
// Get 'isBot' parameter
- const [isBot, isBotErr] = $(params.isBot).optional.boolean().$;
+ const [isBot, isBotErr] = $.bool.optional().get(params.isBot);
if (isBotErr) return rej('invalid isBot param');
if (isBot != null) user.isBot = isBot;
// Get 'autoWatch' parameter
- const [autoWatch, autoWatchErr] = $(params.autoWatch).optional.boolean().$;
+ const [autoWatch, autoWatchErr] = $.bool.optional().get(params.autoWatch);
if (autoWatchErr) return rej('invalid autoWatch param');
if (autoWatch != null) user.settings.autoWatch = autoWatch;
+ if (avatarId) {
+ const avatar = await DriveFile.findOne({
+ _id: avatarId
+ });
+
+ if (avatar != null && avatar.metadata.properties.avgColor) {
+ user.avatarColor = avatar.metadata.properties.avgColor;
+ }
+ }
+
+ if (bannerId) {
+ const banner = await DriveFile.findOne({
+ _id: bannerId
+ });
+
+ if (banner != null && banner.metadata.properties.avgColor) {
+ user.bannerColor = banner.metadata.properties.avgColor;
+ }
+ }
+
await User.update(user._id, {
$set: {
name: user.name,
description: user.description,
avatarId: user.avatarId,
+ avatarColor: user.avatarColor,
bannerId: user.bannerId,
+ bannerColor: user.bannerColor,
profile: user.profile,
isBot: user.isBot,
settings: user.settings
diff --git a/src/server/api/endpoints/i/update_client_setting.ts b/src/server/api/endpoints/i/update_client_setting.ts
index b0d5db5ec2..e91d7565fd 100644
--- a/src/server/api/endpoints/i/update_client_setting.ts
+++ b/src/server/api/endpoints/i/update_client_setting.ts
@@ -2,23 +2,19 @@
* Module dependencies
*/
import $ from 'cafy';
-import User, { pack } from '../../../../models/user';
+import User from '../../../../models/user';
import event from '../../../../publishers/stream';
/**
* Update myself
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = async (params, user) => new Promise(async (res, rej) => {
// Get 'name' parameter
- const [name, nameErr] = $(params.name).string().$;
+ const [name, nameErr] = $.str.get(params.name);
if (nameErr) return rej('invalid name param');
// Get 'value' parameter
- const [value, valueErr] = $(params.value).nullable.any().$;
+ const [value, valueErr] = $.any.nullable().get(params.value);
if (valueErr) return rej('invalid value param');
const x = {};
@@ -28,16 +24,11 @@ module.exports = async (params, user) => new Promise(async (res, rej) => {
$set: x
});
- // Serialize
- user.clientSettings[name] = value;
- const iObj = await pack(user, user, {
- detail: true,
- includeSecrets: true
- });
-
- // Send response
- res(iObj);
+ res();
- // Publish i updated event
- event(user._id, 'i_updated', iObj);
+ // Publish event
+ event(user._id, 'clientSettingUpdated', {
+ key: name,
+ value
+ });
});
diff --git a/src/server/api/endpoints/i/update_home.ts b/src/server/api/endpoints/i/update_home.ts
index ce7661ede0..8ce551957e 100644
--- a/src/server/api/endpoints/i/update_home.ts
+++ b/src/server/api/endpoints/i/update_home.ts
@@ -7,20 +7,22 @@ import event from '../../../../publishers/stream';
module.exports = async (params, user) => new Promise(async (res, rej) => {
// Get 'home' parameter
- const [home, homeErr] = $(params.home).optional.array().each(
- $().strict.object()
- .have('name', $().string())
- .have('id', $().string())
- .have('place', $().string())
- .have('data', $().object())).$;
+ const [home, homeErr] = $.arr(
+ $.obj.strict()
+ .have('name', $.str)
+ .have('id', $.str)
+ .have('place', $.str)
+ .have('data', $.obj))
+ .optional()
+ .get(params.home);
if (homeErr) return rej('invalid home param');
// Get 'id' parameter
- const [id, idErr] = $(params.id).optional.string().$;
+ const [id, idErr] = $.str.optional().get(params.id);
if (idErr) return rej('invalid id param');
// Get 'data' parameter
- const [data, dataErr] = $(params.data).optional.object().$;
+ const [data, dataErr] = $.obj.optional().get(params.data);
if (dataErr) return rej('invalid data param');
if (home) {
diff --git a/src/server/api/endpoints/i/update_mobile_home.ts b/src/server/api/endpoints/i/update_mobile_home.ts
index b710e2f330..d79a77072b 100644
--- a/src/server/api/endpoints/i/update_mobile_home.ts
+++ b/src/server/api/endpoints/i/update_mobile_home.ts
@@ -7,19 +7,20 @@ import event from '../../../../publishers/stream';
module.exports = async (params, user) => new Promise(async (res, rej) => {
// Get 'home' parameter
- const [home, homeErr] = $(params.home).optional.array().each(
- $().strict.object()
- .have('name', $().string())
- .have('id', $().string())
- .have('data', $().object())).$;
+ const [home, homeErr] = $.arr(
+ $.obj.strict()
+ .have('name', $.str)
+ .have('id', $.str)
+ .have('data', $.obj))
+ .optional().get(params.home);
if (homeErr) return rej('invalid home param');
// Get 'id' parameter
- const [id, idErr] = $(params.id).optional.string().$;
+ const [id, idErr] = $.str.optional().get(params.id);
if (idErr) return rej('invalid id param');
// Get 'data' parameter
- const [data, dataErr] = $(params.data).optional.object().$;
+ const [data, dataErr] = $.obj.optional().get(params.data);
if (dataErr) return rej('invalid data param');
if (home) {
diff --git a/src/server/api/endpoints/messaging/history.ts b/src/server/api/endpoints/messaging/history.ts
index e42d34f21a..ec97642f17 100644
--- a/src/server/api/endpoints/messaging/history.ts
+++ b/src/server/api/endpoints/messaging/history.ts
@@ -8,14 +8,10 @@ import { pack } from '../../../../models/messaging-message';
/**
* Show messaging history
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
const mute = await Mute.find({
diff --git a/src/server/api/endpoints/messaging/messages.ts b/src/server/api/endpoints/messaging/messages.ts
index 092eab0562..0338aba68a 100644
--- a/src/server/api/endpoints/messaging/messages.ts
+++ b/src/server/api/endpoints/messaging/messages.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Message from '../../../../models/messaging-message';
import User from '../../../../models/user';
import { pack } from '../../../../models/messaging-message';
@@ -16,7 +16,7 @@ import read from '../../common/read-messaging-message';
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'userId' parameter
- const [recipientId, recipientIdErr] = $(params.userId).id().$;
+ const [recipientId, recipientIdErr] = $.type(ID).get(params.userId);
if (recipientIdErr) return rej('invalid userId param');
// Fetch recipient
@@ -33,19 +33,19 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
}
// Get 'markAsRead' parameter
- const [markAsRead = true, markAsReadErr] = $(params.markAsRead).optional.boolean().$;
+ const [markAsRead = true, markAsReadErr] = $.bool.optional().get(params.markAsRead);
if (markAsReadErr) return rej('invalid markAsRead param');
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) return rej('invalid sinceId param');
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) return rej('invalid untilId param');
// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/messaging/messages/create.ts b/src/server/api/endpoints/messaging/messages/create.ts
index 085e75e6cf..db471839e7 100644
--- a/src/server/api/endpoints/messaging/messages/create.ts
+++ b/src/server/api/endpoints/messaging/messages/create.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import Message from '../../../../../models/messaging-message';
import { isValidText } from '../../../../../models/messaging-message';
import History from '../../../../../models/messaging-history';
@@ -12,20 +12,14 @@ import { pack } from '../../../../../models/messaging-message';
import publishUserStream from '../../../../../publishers/stream';
import { publishMessagingStream, publishMessagingIndexStream } from '../../../../../publishers/stream';
import pushSw from '../../../../../publishers/push-sw';
-import html from '../../../../../text/html';
-import parse from '../../../../../text/parse';
import config from '../../../../../config';
/**
* Create a message
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'userId' parameter
- const [recipientId, recipientIdErr] = $(params.userId).id().$;
+ const [recipientId, recipientIdErr] = $.type(ID).get(params.userId);
if (recipientIdErr) return rej('invalid userId param');
// Myself
@@ -47,11 +41,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
}
// Get 'text' parameter
- const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$;
+ const [text, textErr] = $.str.optional().pipe(isValidText).get(params.text);
if (textErr) return rej('invalid text');
// Get 'fileId' parameter
- const [fileId, fileIdErr] = $(params.fileId).optional.id().$;
+ const [fileId, fileIdErr] = $.type(ID).optional().get(params.fileId);
if (fileIdErr) return rej('invalid fileId param');
let file = null;
@@ -77,7 +71,6 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
fileId: file ? file._id : undefined,
recipientId: recipient._id,
text: text ? text : undefined,
- textHtml: text ? html(parse(text)) : undefined,
userId: user._id,
isRead: false
});
diff --git a/src/server/api/endpoints/messaging/unread.ts b/src/server/api/endpoints/messaging/unread.ts
index 30d59dd8bd..1d83af501d 100644
--- a/src/server/api/endpoints/messaging/unread.ts
+++ b/src/server/api/endpoints/messaging/unread.ts
@@ -6,10 +6,6 @@ import Mute from '../../../../models/mute';
/**
* Get count of unread messages
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
const mute = await Mute.find({
diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts
index f6a276a2b7..0e9ecf47df 100644
--- a/src/server/api/endpoints/meta.ts
+++ b/src/server/api/endpoints/meta.ts
@@ -2,10 +2,12 @@
* Module dependencies
*/
import * as os from 'os';
-import version from '../../../version';
import config from '../../../config';
import Meta from '../../../models/meta';
+const pkg = require('../../../../package.json');
+const client = require('../../../../built/client/meta.json');
+
/**
* @swagger
* /meta:
@@ -41,7 +43,10 @@ module.exports = (params) => new Promise(async (res, rej) => {
res({
maintainer: config.maintainer,
- version: version,
+
+ version: pkg.version,
+ clientVersion: client.version,
+
secure: config.https != null,
machine: os.hostname(),
os: os.platform(),
diff --git a/src/server/api/endpoints/mute/create.ts b/src/server/api/endpoints/mute/create.ts
index 26ae612cab..534020c671 100644
--- a/src/server/api/endpoints/mute/create.ts
+++ b/src/server/api/endpoints/mute/create.ts
@@ -1,22 +1,18 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import User from '../../../../models/user';
import Mute from '../../../../models/mute';
/**
* Mute a user
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
const muter = user;
// Get 'userId' parameter
- const [userId, userIdErr] = $(params.userId).id().$;
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
if (userIdErr) return rej('invalid userId param');
// 自分自身
diff --git a/src/server/api/endpoints/mute/delete.ts b/src/server/api/endpoints/mute/delete.ts
index 6f617416c8..949aff64ba 100644
--- a/src/server/api/endpoints/mute/delete.ts
+++ b/src/server/api/endpoints/mute/delete.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import User from '../../../../models/user';
import Mute from '../../../../models/mute';
@@ -12,7 +12,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
const muter = user;
// Get 'userId' parameter
- const [userId, userIdErr] = $(params.userId).id().$;
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
if (userIdErr) return rej('invalid userId param');
// Check if the mutee is yourself
diff --git a/src/server/api/endpoints/mute/list.ts b/src/server/api/endpoints/mute/list.ts
index bd80401445..cf89f7e959 100644
--- a/src/server/api/endpoints/mute/list.ts
+++ b/src/server/api/endpoints/mute/list.ts
@@ -1,29 +1,25 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Mute from '../../../../models/mute';
import { pack } from '../../../../models/user';
-import getFriends from '../../common/get-friends';
+import { getFriendIds } from '../../common/get-friends';
/**
* Get muted users of a user
- *
- * @param {any} params
- * @param {any} me
- * @return {Promise<any>}
*/
module.exports = (params, me) => new Promise(async (res, rej) => {
// Get 'iknow' parameter
- const [iknow = false, iknowErr] = $(params.iknow).optional.boolean().$;
+ const [iknow = false, iknowErr] = $.bool.optional().get(params.iknow);
if (iknowErr) return rej('invalid iknow param');
// Get 'limit' parameter
- const [limit = 30, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 30, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'cursor' parameter
- const [cursor = null, cursorErr] = $(params.cursor).optional.id().$;
+ const [cursor = null, cursorErr] = $.type(ID).optional().get(params.cursor);
if (cursorErr) return rej('invalid cursor param');
// Construct query
@@ -34,7 +30,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
if (iknow) {
// Get my friends
- const myFriends = await getFriends(me._id);
+ const myFriends = await getFriendIds(me._id);
query.muteeId = {
$in: myFriends
diff --git a/src/server/api/endpoints/my/apps.ts b/src/server/api/endpoints/my/apps.ts
index 2a3f8bcd7a..086e0b8965 100644
--- a/src/server/api/endpoints/my/apps.ts
+++ b/src/server/api/endpoints/my/apps.ts
@@ -6,18 +6,14 @@ import App, { pack } from '../../../../models/app';
/**
* Get my apps
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'offset' parameter
- const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset);
if (offsetErr) return rej('invalid offset param');
const query = {
diff --git a/src/server/api/endpoints/notes.ts b/src/server/api/endpoints/notes.ts
index a70ac0588f..4ce7613d70 100644
--- a/src/server/api/endpoints/notes.ts
+++ b/src/server/api/endpoints/notes.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../cafy-id';
import Note, { pack } from '../../../models/note';
/**
@@ -9,35 +9,35 @@ import Note, { pack } from '../../../models/note';
*/
module.exports = (params) => new Promise(async (res, rej) => {
// Get 'reply' parameter
- const [reply, replyErr] = $(params.reply).optional.boolean().$;
+ const [reply, replyErr] = $.bool.optional().get(params.reply);
if (replyErr) return rej('invalid reply param');
// Get 'renote' parameter
- const [renote, renoteErr] = $(params.renote).optional.boolean().$;
+ const [renote, renoteErr] = $.bool.optional().get(params.renote);
if (renoteErr) return rej('invalid renote param');
// Get 'media' parameter
- const [media, mediaErr] = $(params.media).optional.boolean().$;
+ const [media, mediaErr] = $.bool.optional().get(params.media);
if (mediaErr) return rej('invalid media param');
// Get 'poll' parameter
- const [poll, pollErr] = $(params.poll).optional.boolean().$;
+ const [poll, pollErr] = $.bool.optional().get(params.poll);
if (pollErr) return rej('invalid poll param');
// Get 'bot' parameter
- //const [bot, botErr] = $(params.bot).optional.boolean().$;
+ //const [bot, botErr] = $.bool.optional().get(params.bot);
//if (botErr) return rej('invalid bot param');
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) return rej('invalid sinceId param');
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) return rej('invalid untilId param');
// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/notes/context.ts b/src/server/api/endpoints/notes/context.ts
index 2caf742d26..1cd27250e2 100644
--- a/src/server/api/endpoints/notes/context.ts
+++ b/src/server/api/endpoints/notes/context.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Note, { pack } from '../../../../models/note';
/**
@@ -13,15 +13,15 @@ import Note, { pack } from '../../../../models/note';
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'noteId' parameter
- const [noteId, noteIdErr] = $(params.noteId).id().$;
+ const [noteId, noteIdErr] = $.type(ID).get(params.noteId);
if (noteIdErr) return rej('invalid noteId param');
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'offset' parameter
- const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset);
if (offsetErr) return rej('invalid offset param');
// Lookup note
diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts
index 7e79912b1b..429b6d370a 100644
--- a/src/server/api/endpoints/notes/create.ts
+++ b/src/server/api/endpoints/notes/create.ts
@@ -1,10 +1,9 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
-import deepEqual = require('deep-equal');
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Note, { INote, isValidText, isValidCw, pack } from '../../../../models/note';
-import { ILocalUser } from '../../../../models/user';
+import User, { ILocalUser } from '../../../../models/user';
import Channel, { IChannel } from '../../../../models/channel';
import DriveFile from '../../../../models/drive-file';
import create from '../../../../services/note/create';
@@ -12,48 +11,54 @@ import { IApp } from '../../../../models/app';
/**
* Create a note
- *
- * @param {any} params
- * @param {any} user
- * @param {any} app
- * @return {Promise<any>}
*/
module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res, rej) => {
// Get 'visibility' parameter
- const [visibility = 'public', visibilityErr] = $(params.visibility).optional.string().or(['public', 'unlisted', 'private', 'direct']).$;
+ const [visibility = 'public', visibilityErr] = $.str.optional().or(['public', 'home', 'followers', 'specified', 'private']).get(params.visibility);
if (visibilityErr) return rej('invalid visibility');
+ // Get 'visibleUserIds' parameter
+ const [visibleUserIds, visibleUserIdsErr] = $.arr($.type(ID)).optional().unique().min(1).get(params.visibleUserIds);
+ if (visibleUserIdsErr) return rej('invalid visibleUserIds');
+
+ let visibleUsers = [];
+ if (visibleUserIds !== undefined) {
+ visibleUsers = await Promise.all(visibleUserIds.map(id => User.findOne({
+ _id: id
+ })));
+ }
+
// Get 'text' parameter
- const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$;
+ const [text = null, textErr] = $.str.optional().nullable().pipe(isValidText).get(params.text);
if (textErr) return rej('invalid text');
// Get 'cw' parameter
- const [cw, cwErr] = $(params.cw).optional.string().pipe(isValidCw).$;
+ const [cw, cwErr] = $.str.optional().nullable().pipe(isValidCw).get(params.cw);
if (cwErr) return rej('invalid cw');
// Get 'viaMobile' parameter
- const [viaMobile = false, viaMobileErr] = $(params.viaMobile).optional.boolean().$;
+ const [viaMobile = false, viaMobileErr] = $.bool.optional().get(params.viaMobile);
if (viaMobileErr) return rej('invalid viaMobile');
// Get 'tags' parameter
- const [tags = [], tagsErr] = $(params.tags).optional.array('string').unique().eachQ(t => t.range(1, 32)).$;
+ const [tags = [], tagsErr] = $.arr($.str.range(1, 32)).optional().unique().get(params.tags);
if (tagsErr) return rej('invalid tags');
// Get 'geo' parameter
- const [geo, geoErr] = $(params.geo).optional.nullable.strict.object()
- .have('coordinates', $().array().length(2)
- .item(0, $().number().range(-180, 180))
- .item(1, $().number().range(-90, 90)))
- .have('altitude', $().nullable.number())
- .have('accuracy', $().nullable.number())
- .have('altitudeAccuracy', $().nullable.number())
- .have('heading', $().nullable.number().range(0, 360))
- .have('speed', $().nullable.number())
- .$;
+ const [geo, geoErr] = $.obj.optional().nullable().strict()
+ .have('coordinates', $.arr().length(2)
+ .item(0, $.num.range(-180, 180))
+ .item(1, $.num.range(-90, 90)))
+ .have('altitude', $.num.nullable())
+ .have('accuracy', $.num.nullable())
+ .have('altitudeAccuracy', $.num.nullable())
+ .have('heading', $.num.nullable().range(0, 360))
+ .have('speed', $.num.nullable())
+ .get(params.geo);
if (geoErr) return rej('invalid geo');
// Get 'mediaIds' parameter
- const [mediaIds, mediaIdsErr] = $(params.mediaIds).optional.array('id').unique().range(1, 4).$;
+ const [mediaIds, mediaIdsErr] = $.arr($.type(ID)).optional().unique().range(1, 4).get(params.mediaIds);
if (mediaIdsErr) return rej('invalid mediaIds');
let files = [];
@@ -80,7 +85,7 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res
}
// Get 'renoteId' parameter
- const [renoteId, renoteIdErr] = $(params.renoteId).optional.id().$;
+ const [renoteId, renoteIdErr] = $.type(ID).optional().get(params.renoteId);
if (renoteIdErr) return rej('invalid renoteId');
let renote: INote = null;
@@ -97,35 +102,11 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res
return rej('cannot renote to renote');
}
- // Fetch recently note
- const latestNote = await Note.findOne({
- userId: user._id
- }, {
- sort: {
- _id: -1
- }
- });
-
isQuote = text != null || files != null;
-
- // 直近と同じRenote対象かつ引用じゃなかったらエラー
- if (latestNote &&
- latestNote.renoteId &&
- latestNote.renoteId.equals(renote._id) &&
- !isQuote) {
- return rej('cannot renote same note that already reposted in your latest note');
- }
-
- // 直近がRenote対象かつ引用じゃなかったらエラー
- if (latestNote &&
- latestNote._id.equals(renote._id) &&
- !isQuote) {
- return rej('cannot renote your latest note');
- }
}
// Get 'replyId' parameter
- const [replyId, replyIdErr] = $(params.replyId).optional.id().$;
+ const [replyId, replyIdErr] = $.type(ID).optional().get(params.replyId);
if (replyIdErr) return rej('invalid replyId');
let reply: INote = null;
@@ -146,7 +127,7 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res
}
// Get 'channelId' parameter
- const [channelId, channelIdErr] = $(params.channelId).optional.id().$;
+ const [channelId, channelIdErr] = $.type(ID).optional().get(params.channelId);
if (channelIdErr) return rej('invalid channelId');
let channel: IChannel = null;
@@ -187,12 +168,12 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res
}
// Get 'poll' parameter
- const [poll, pollErr] = $(params.poll).optional.strict.object()
- .have('choices', $().array('string')
+ const [poll, pollErr] = $.obj.optional().strict()
+ .have('choices', $.arr($.str)
.unique()
.range(2, 10)
.each(c => c.length > 0 && c.length < 50))
- .$;
+ .get(params.poll);
if (pollErr) return rej('invalid poll');
if (poll) {
@@ -208,37 +189,20 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res
return rej('text, mediaIds, renoteId or poll is required');
}
- // 直近の投稿と重複してたらエラー
- // TODO: 直近の投稿が一日前くらいなら重複とは見なさない
- if (user.latestNote) {
- if (deepEqual({
- text: user.latestNote.text,
- reply: user.latestNote.replyId ? user.latestNote.replyId.toString() : null,
- renote: user.latestNote.renoteId ? user.latestNote.renoteId.toString() : null,
- mediaIds: (user.latestNote.mediaIds || []).map(id => id.toString())
- }, {
- text: text,
- reply: reply ? reply._id.toString() : null,
- renote: renote ? renote._id.toString() : null,
- mediaIds: (files || []).map(file => file._id.toString())
- })) {
- return rej('duplicate');
- }
- }
-
// 投稿を作成
const note = await create(user, {
createdAt: new Date(),
media: files,
- poll: poll,
- text: text,
+ poll,
+ text,
reply,
renote,
- cw: cw,
- tags: tags,
- app: app,
- viaMobile: viaMobile,
+ cw,
+ tags,
+ app,
+ viaMobile,
visibility,
+ visibleUsers,
geo
});
diff --git a/src/server/api/endpoints/notes/favorites/create.ts b/src/server/api/endpoints/notes/favorites/create.ts
index c8e7f52426..6832b52f75 100644
--- a/src/server/api/endpoints/notes/favorites/create.ts
+++ b/src/server/api/endpoints/notes/favorites/create.ts
@@ -1,20 +1,16 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import Favorite from '../../../../../models/favorite';
import Note from '../../../../../models/note';
/**
* Favorite a note
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'noteId' parameter
- const [noteId, noteIdErr] = $(params.noteId).id().$;
+ const [noteId, noteIdErr] = $.type(ID).get(params.noteId);
if (noteIdErr) return rej('invalid noteId param');
// Get favoritee
diff --git a/src/server/api/endpoints/notes/favorites/delete.ts b/src/server/api/endpoints/notes/favorites/delete.ts
index 92aceb343b..07112dae15 100644
--- a/src/server/api/endpoints/notes/favorites/delete.ts
+++ b/src/server/api/endpoints/notes/favorites/delete.ts
@@ -1,20 +1,16 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import Favorite from '../../../../../models/favorite';
import Note from '../../../../../models/note';
/**
* Unfavorite a note
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'noteId' parameter
- const [noteId, noteIdErr] = $(params.noteId).id().$;
+ const [noteId, noteIdErr] = $.type(ID).get(params.noteId);
if (noteIdErr) return rej('invalid noteId param');
// Get favoritee
diff --git a/src/server/api/endpoints/notes/global-timeline.ts b/src/server/api/endpoints/notes/global-timeline.ts
index 07e138ec54..d22a1763de 100644
--- a/src/server/api/endpoints/notes/global-timeline.ts
+++ b/src/server/api/endpoints/notes/global-timeline.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Note from '../../../../models/note';
import Mute from '../../../../models/mute';
import { pack } from '../../../../models/note';
@@ -11,23 +11,23 @@ import { pack } from '../../../../models/note';
*/
module.exports = async (params, user, app) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) throw 'invalid limit param';
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) throw 'invalid sinceId param';
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) throw 'invalid untilId param';
// Get 'sinceDate' parameter
- const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$;
+ const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate);
if (sinceDateErr) throw 'invalid sinceDate param';
// Get 'untilDate' parameter
- const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$;
+ const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate);
if (untilDateErr) throw 'invalid untilDate param';
// Check if only one of sinceId, untilId, sinceDate, untilDate specified
diff --git a/src/server/api/endpoints/notes/local-timeline.ts b/src/server/api/endpoints/notes/local-timeline.ts
index d63528c3cd..e7ebe5d960 100644
--- a/src/server/api/endpoints/notes/local-timeline.ts
+++ b/src/server/api/endpoints/notes/local-timeline.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Note from '../../../../models/note';
import Mute from '../../../../models/mute';
import { pack } from '../../../../models/note';
@@ -11,23 +11,23 @@ import { pack } from '../../../../models/note';
*/
module.exports = async (params, user, app) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) throw 'invalid limit param';
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) throw 'invalid sinceId param';
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) throw 'invalid untilId param';
// Get 'sinceDate' parameter
- const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$;
+ const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate);
if (sinceDateErr) throw 'invalid sinceDate param';
// Get 'untilDate' parameter
- const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$;
+ const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate);
if (untilDateErr) throw 'invalid untilDate param';
// Check if only one of sinceId, untilId, sinceDate, untilDate specified
diff --git a/src/server/api/endpoints/notes/mentions.ts b/src/server/api/endpoints/notes/mentions.ts
index c507acbaec..163a6b4866 100644
--- a/src/server/api/endpoints/notes/mentions.ts
+++ b/src/server/api/endpoints/notes/mentions.ts
@@ -1,9 +1,9 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Note from '../../../../models/note';
-import getFriends from '../../common/get-friends';
+import { getFriendIds } from '../../common/get-friends';
import { pack } from '../../../../models/note';
/**
@@ -16,19 +16,19 @@ import { pack } from '../../../../models/note';
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'following' parameter
const [following = false, followingError] =
- $(params.following).optional.boolean().$;
+ $.bool.optional().get(params.following);
if (followingError) return rej('invalid following param');
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) return rej('invalid sinceId param');
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) return rej('invalid untilId param');
// Check if both of sinceId and untilId is specified
@@ -46,7 +46,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
};
if (following) {
- const followingIds = await getFriends(user._id);
+ const followingIds = await getFriendIds(user._id);
query.userId = {
$in: followingIds
diff --git a/src/server/api/endpoints/notes/polls/recommendation.ts b/src/server/api/endpoints/notes/polls/recommendation.ts
index cb530ea2cf..a272378d19 100644
--- a/src/server/api/endpoints/notes/polls/recommendation.ts
+++ b/src/server/api/endpoints/notes/polls/recommendation.ts
@@ -7,18 +7,14 @@ import Note, { pack } from '../../../../../models/note';
/**
* Get recommended polls
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'offset' parameter
- const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset);
if (offsetErr) return rej('invalid offset param');
// Get votes
diff --git a/src/server/api/endpoints/notes/polls/vote.ts b/src/server/api/endpoints/notes/polls/vote.ts
index 03d94da60d..f8f4515308 100644
--- a/src/server/api/endpoints/notes/polls/vote.ts
+++ b/src/server/api/endpoints/notes/polls/vote.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import Vote from '../../../../../models/poll-vote';
import Note from '../../../../../models/note';
import Watching from '../../../../../models/note-watching';
@@ -11,14 +11,10 @@ import notify from '../../../../../publishers/notify';
/**
* Vote poll of a note
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'noteId' parameter
- const [noteId, noteIdErr] = $(params.noteId).id().$;
+ const [noteId, noteIdErr] = $.type(ID).get(params.noteId);
if (noteIdErr) return rej('invalid noteId param');
// Get votee
@@ -36,9 +32,9 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'choice' parameter
const [choice, choiceError] =
- $(params.choice).number()
+ $.num
.pipe(c => note.poll.choices.some(x => x.id == c))
- .$;
+ .get(params.choice);
if (choiceError) return rej('invalid choice param');
// if already voted
diff --git a/src/server/api/endpoints/notes/reactions.ts b/src/server/api/endpoints/notes/reactions.ts
index bbff97bb0a..4ad952a7a1 100644
--- a/src/server/api/endpoints/notes/reactions.ts
+++ b/src/server/api/endpoints/notes/reactions.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Note from '../../../../models/note';
import Reaction, { pack } from '../../../../models/note-reaction';
@@ -14,19 +14,19 @@ import Reaction, { pack } from '../../../../models/note-reaction';
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'noteId' parameter
- const [noteId, noteIdErr] = $(params.noteId).id().$;
+ const [noteId, noteIdErr] = $.type(ID).get(params.noteId);
if (noteIdErr) return rej('invalid noteId param');
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'offset' parameter
- const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset);
if (offsetErr) return rej('invalid offset param');
// Get 'sort' parameter
- const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$;
+ const [sort = 'desc', sortError] = $.str.optional().or('desc asc').get(params.sort);
if (sortError) return rej('invalid sort param');
// Lookup note
diff --git a/src/server/api/endpoints/notes/reactions/create.ts b/src/server/api/endpoints/notes/reactions/create.ts
index c80c5416b1..21757cb427 100644
--- a/src/server/api/endpoints/notes/reactions/create.ts
+++ b/src/server/api/endpoints/notes/reactions/create.ts
@@ -1,30 +1,21 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import Note from '../../../../../models/note';
import create from '../../../../../services/note/reaction/create';
+import { validateReaction } from '../../../../../models/note-reaction';
/**
* React to a note
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'noteId' parameter
- const [noteId, noteIdErr] = $(params.noteId).id().$;
+ const [noteId, noteIdErr] = $.type(ID).get(params.noteId);
if (noteIdErr) return rej('invalid noteId param');
// Get 'reaction' parameter
- const [reaction, reactionErr] = $(params.reaction).string().or([
- 'like',
- 'love',
- 'laugh',
- 'hmm',
- 'surprise',
- 'congrats',
- 'angry',
- 'confused',
- 'pudding'
- ]).$;
+ const [reaction, reactionErr] = $.str.pipe(validateReaction.ok).get(params.reaction);
if (reactionErr) return rej('invalid reaction param');
// Fetch reactee
diff --git a/src/server/api/endpoints/notes/reactions/delete.ts b/src/server/api/endpoints/notes/reactions/delete.ts
index b5d738b8ff..afb8629112 100644
--- a/src/server/api/endpoints/notes/reactions/delete.ts
+++ b/src/server/api/endpoints/notes/reactions/delete.ts
@@ -1,21 +1,16 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import Reaction from '../../../../../models/note-reaction';
import Note from '../../../../../models/note';
-// import event from '../../../publishers/stream';
/**
* Unreact to a note
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'noteId' parameter
- const [noteId, noteIdErr] = $(params.noteId).id().$;
+ const [noteId, noteIdErr] = $.type(ID).get(params.noteId);
if (noteIdErr) return rej('invalid noteId param');
// Fetch unreactee
diff --git a/src/server/api/endpoints/notes/replies.ts b/src/server/api/endpoints/notes/replies.ts
index 88d9ff329a..11d221d8f7 100644
--- a/src/server/api/endpoints/notes/replies.ts
+++ b/src/server/api/endpoints/notes/replies.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Note, { pack } from '../../../../models/note';
/**
@@ -13,19 +13,19 @@ import Note, { pack } from '../../../../models/note';
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'noteId' parameter
- const [noteId, noteIdErr] = $(params.noteId).id().$;
+ const [noteId, noteIdErr] = $.type(ID).get(params.noteId);
if (noteIdErr) return rej('invalid noteId param');
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'offset' parameter
- const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset);
if (offsetErr) return rej('invalid offset param');
// Get 'sort' parameter
- const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$;
+ const [sort = 'desc', sortError] = $.str.optional().or('desc asc').get(params.sort);
if (sortError) return rej('invalid sort param');
// Lookup note
diff --git a/src/server/api/endpoints/notes/reposts.ts b/src/server/api/endpoints/notes/reposts.ts
index 9dfc2c3cb5..3098211b61 100644
--- a/src/server/api/endpoints/notes/reposts.ts
+++ b/src/server/api/endpoints/notes/reposts.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Note, { pack } from '../../../../models/note';
/**
@@ -13,19 +13,19 @@ import Note, { pack } from '../../../../models/note';
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'noteId' parameter
- const [noteId, noteIdErr] = $(params.noteId).id().$;
+ const [noteId, noteIdErr] = $.type(ID).get(params.noteId);
if (noteIdErr) return rej('invalid noteId param');
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) return rej('invalid sinceId param');
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) return rej('invalid untilId param');
// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/notes/search.ts b/src/server/api/endpoints/notes/search.ts
index bfa17b000e..9705dcfd6e 100644
--- a/src/server/api/endpoints/notes/search.ts
+++ b/src/server/api/endpoints/notes/search.ts
@@ -1,12 +1,12 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
const escapeRegexp = require('escape-regexp');
import Note from '../../../../models/note';
import User from '../../../../models/user';
import Mute from '../../../../models/mute';
-import getFriends from '../../common/get-friends';
+import { getFriendIds } from '../../common/get-friends';
import { pack } from '../../../../models/note';
/**
@@ -18,63 +18,63 @@ import { pack } from '../../../../models/note';
*/
module.exports = (params, me) => new Promise(async (res, rej) => {
// Get 'text' parameter
- const [text, textError] = $(params.text).optional.string().$;
+ const [text, textError] = $.str.optional().get(params.text);
if (textError) return rej('invalid text param');
// Get 'includeUserIds' parameter
- const [includeUserIds = [], includeUserIdsErr] = $(params.includeUserIds).optional.array('id').$;
+ const [includeUserIds = [], includeUserIdsErr] = $.arr($.type(ID)).optional().get(params.includeUserIds);
if (includeUserIdsErr) return rej('invalid includeUserIds param');
// Get 'excludeUserIds' parameter
- const [excludeUserIds = [], excludeUserIdsErr] = $(params.excludeUserIds).optional.array('id').$;
+ const [excludeUserIds = [], excludeUserIdsErr] = $.arr($.type(ID)).optional().get(params.excludeUserIds);
if (excludeUserIdsErr) return rej('invalid excludeUserIds param');
// Get 'includeUserUsernames' parameter
- const [includeUserUsernames = [], includeUserUsernamesErr] = $(params.includeUserUsernames).optional.array('string').$;
+ const [includeUserUsernames = [], includeUserUsernamesErr] = $.arr($.str).optional().get(params.includeUserUsernames);
if (includeUserUsernamesErr) return rej('invalid includeUserUsernames param');
// Get 'excludeUserUsernames' parameter
- const [excludeUserUsernames = [], excludeUserUsernamesErr] = $(params.excludeUserUsernames).optional.array('string').$;
+ const [excludeUserUsernames = [], excludeUserUsernamesErr] = $.arr($.str).optional().get(params.excludeUserUsernames);
if (excludeUserUsernamesErr) return rej('invalid excludeUserUsernames param');
// Get 'following' parameter
- const [following = null, followingErr] = $(params.following).optional.nullable.boolean().$;
+ const [following = null, followingErr] = $.bool.optional().nullable().get(params.following);
if (followingErr) return rej('invalid following param');
// Get 'mute' parameter
- const [mute = 'mute_all', muteErr] = $(params.mute).optional.string().$;
+ const [mute = 'mute_all', muteErr] = $.str.optional().get(params.mute);
if (muteErr) return rej('invalid mute param');
// Get 'reply' parameter
- const [reply = null, replyErr] = $(params.reply).optional.nullable.boolean().$;
+ const [reply = null, replyErr] = $.bool.optional().nullable().get(params.reply);
if (replyErr) return rej('invalid reply param');
// Get 'renote' parameter
- const [renote = null, renoteErr] = $(params.renote).optional.nullable.boolean().$;
+ const [renote = null, renoteErr] = $.bool.optional().nullable().get(params.renote);
if (renoteErr) return rej('invalid renote param');
// Get 'media' parameter
- const [media = null, mediaErr] = $(params.media).optional.nullable.boolean().$;
+ const [media = null, mediaErr] = $.bool.optional().nullable().get(params.media);
if (mediaErr) return rej('invalid media param');
// Get 'poll' parameter
- const [poll = null, pollErr] = $(params.poll).optional.nullable.boolean().$;
+ const [poll = null, pollErr] = $.bool.optional().nullable().get(params.poll);
if (pollErr) return rej('invalid poll param');
// Get 'sinceDate' parameter
- const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$;
+ const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate);
if (sinceDateErr) throw 'invalid sinceDate param';
// Get 'untilDate' parameter
- const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$;
+ const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate);
if (untilDateErr) throw 'invalid untilDate param';
// Get 'offset' parameter
- const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset);
if (offsetErr) return rej('invalid offset param');
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 30).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 30).get(params.limit);
if (limitErr) return rej('invalid limit param');
let includeUsers = includeUserIds;
@@ -156,7 +156,7 @@ async function search(
}
if (following != null && me != null) {
- const ids = await getFriends(me._id, false);
+ const ids = await getFriendIds(me._id, false);
push({
userId: following ? {
$in: ids
diff --git a/src/server/api/endpoints/notes/show.ts b/src/server/api/endpoints/notes/show.ts
index 67cdc3038b..78dc55a703 100644
--- a/src/server/api/endpoints/notes/show.ts
+++ b/src/server/api/endpoints/notes/show.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Note, { pack } from '../../../../models/note';
/**
@@ -13,7 +13,7 @@ import Note, { pack } from '../../../../models/note';
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'noteId' parameter
- const [noteId, noteIdErr] = $(params.noteId).id().$;
+ const [noteId, noteIdErr] = $.type(ID).get(params.noteId);
if (noteIdErr) return rej('invalid noteId param');
// Get note
diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts
index b5feaac817..78786d4a16 100644
--- a/src/server/api/endpoints/notes/timeline.ts
+++ b/src/server/api/endpoints/notes/timeline.ts
@@ -1,12 +1,11 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
-import rap from '@prezzemolo/rap';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Note from '../../../../models/note';
import Mute from '../../../../models/mute';
import ChannelWatching from '../../../../models/channel-watching';
-import getFriends from '../../common/get-friends';
+import { getFriends } from '../../common/get-friends';
import { pack } from '../../../../models/note';
/**
@@ -14,23 +13,23 @@ import { pack } from '../../../../models/note';
*/
module.exports = async (params, user, app) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) throw 'invalid limit param';
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) throw 'invalid sinceId param';
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) throw 'invalid untilId param';
// Get 'sinceDate' parameter
- const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$;
+ const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate);
if (sinceDateErr) throw 'invalid sinceDate param';
// Get 'untilDate' parameter
- const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$;
+ const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate);
if (untilDateErr) throw 'invalid untilDate param';
// Check if only one of sinceId, untilId, sinceDate, untilDate specified
@@ -38,59 +37,130 @@ module.exports = async (params, user, app) => {
throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified';
}
- const { followingIds, watchingChannelIds, mutedUserIds } = await rap({
- // ID list of the user itself and other users who the user follows
- followingIds: getFriends(user._id),
+ // Get 'includeMyRenotes' parameter
+ const [includeMyRenotes = true, includeMyRenotesErr] = $.bool.optional().get(params.includeMyRenotes);
+ if (includeMyRenotesErr) throw 'invalid includeMyRenotes param';
+
+ // Get 'includeRenotedMyNotes' parameter
+ const [includeRenotedMyNotes = true, includeRenotedMyNotesErr] = $.bool.optional().get(params.includeRenotedMyNotes);
+ if (includeRenotedMyNotesErr) throw 'invalid includeRenotedMyNotes param';
+
+ const [followings, watchingChannelIds, mutedUserIds] = await Promise.all([
+ // フォローを取得
+ // Fetch following
+ getFriends(user._id),
// Watchしているチャンネルを取得
- watchingChannelIds: ChannelWatching.find({
+ ChannelWatching.find({
userId: user._id,
// 削除されたドキュメントは除く
deletedAt: { $exists: false }
}).then(watches => watches.map(w => w.channelId)),
// ミュートしているユーザーを取得
- mutedUserIds: Mute.find({
+ Mute.find({
muterId: user._id
}).then(ms => ms.map(m => m.muteeId))
- });
+ ]);
//#region Construct query
const sort = {
_id: -1
};
- const query = {
+ const followQuery = followings.map(f => f.stalk ? {
+ userId: f.id
+ } : {
+ userId: f.id,
+
+ // ストーキングしてないならリプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める)
$or: [{
- // フォローしている人のタイムラインへの投稿
- userId: {
- $in: followingIds
- },
- // 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る
+ // リプライでない
+ replyId: null
+ }, { // または
+ // リプライだが返信先が投稿者自身の投稿
+ $expr: {
+ $eq: ['$_reply.userId', '$userId']
+ }
+ }, { // または
+ // リプライだが返信先が自分(フォロワー)の投稿
+ '_reply.userId': user._id
+ }, { // または
+ // 自分(フォロワー)が送信したリプライ
+ userId: user._id
+ }]
+ });
+
+ const query = {
+ $and: [{
$or: [{
+ $and: [{
+ // フォローしている人のタイムラインへの投稿
+ $or: followQuery
+ }, {
+ // 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る
+ $or: [{
+ channelId: {
+ $exists: false
+ }
+ }, {
+ channelId: null
+ }]
+ }]
+ }, {
+ // Watchしているチャンネルへの投稿
channelId: {
- $exists: false
+ $in: watchingChannelIds
}
+ }],
+ // mute
+ userId: {
+ $nin: mutedUserIds
+ },
+ '_reply.userId': {
+ $nin: mutedUserIds
+ },
+ '_renote.userId': {
+ $nin: mutedUserIds
+ },
+ }]
+ } as any;
+
+ // MongoDBではトップレベルで否定ができないため、De Morganの法則を利用してクエリします。
+ // つまり、「『自分の投稿かつRenote』ではない」を「『自分の投稿ではない』または『Renoteではない』」と表現します。
+ // for details: https://en.wikipedia.org/wiki/De_Morgan%27s_laws
+
+ if (includeMyRenotes === false) {
+ query.$and.push({
+ $or: [{
+ userId: { $ne: user._id }
+ }, {
+ renoteId: null
+ }, {
+ text: { $ne: null }
+ }, {
+ mediaIds: { $ne: [] }
}, {
- channelId: null
+ poll: { $ne: null }
}]
- }, {
- // Watchしているチャンネルへの投稿
- channelId: {
- $in: watchingChannelIds
- }
- }],
- // mute
- userId: {
- $nin: mutedUserIds
- },
- '_reply.userId': {
- $nin: mutedUserIds
- },
- '_renote.userId': {
- $nin: mutedUserIds
- },
- } as any;
+ });
+ }
+
+ if (includeRenotedMyNotes === false) {
+ query.$and.push({
+ $or: [{
+ '_renote.userId': { $ne: user._id }
+ }, {
+ renoteId: null
+ }, {
+ text: { $ne: null }
+ }, {
+ mediaIds: { $ne: [] }
+ }, {
+ poll: { $ne: null }
+ }]
+ });
+ }
if (sinceId) {
sort._id = 1;
diff --git a/src/server/api/endpoints/notes/trend.ts b/src/server/api/endpoints/notes/trend.ts
index 48ecd5b843..4735bec51e 100644
--- a/src/server/api/endpoints/notes/trend.ts
+++ b/src/server/api/endpoints/notes/trend.ts
@@ -14,36 +14,33 @@ import Note, { pack } from '../../../../models/note';
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'offset' parameter
- const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset);
if (offsetErr) return rej('invalid offset param');
// Get 'reply' parameter
- const [reply, replyErr] = $(params.reply).optional.boolean().$;
+ const [reply, replyErr] = $.bool.optional().get(params.reply);
if (replyErr) return rej('invalid reply param');
// Get 'renote' parameter
- const [renote, renoteErr] = $(params.renote).optional.boolean().$;
+ const [renote, renoteErr] = $.bool.optional().get(params.renote);
if (renoteErr) return rej('invalid renote param');
// Get 'media' parameter
- const [media, mediaErr] = $(params.media).optional.boolean().$;
+ const [media, mediaErr] = $.bool.optional().get(params.media);
if (mediaErr) return rej('invalid media param');
// Get 'poll' parameter
- const [poll, pollErr] = $(params.poll).optional.boolean().$;
+ const [poll, pollErr] = $.bool.optional().get(params.poll);
if (pollErr) return rej('invalid poll param');
const query = {
- createdAt: {
- $gte: new Date(Date.now() - ms('1days'))
- },
- renoteCount: {
- $gt: 0
- }
+ _id: { $gte: new Date(Date.now() - ms('1days')) },
+ renoteCount: { $gt: 0 },
+ '_user.host': null
} as any;
if (reply != undefined) {
diff --git a/src/server/api/endpoints/notes/user-list-timeline.ts b/src/server/api/endpoints/notes/user-list-timeline.ts
new file mode 100644
index 0000000000..9f8397d679
--- /dev/null
+++ b/src/server/api/endpoints/notes/user-list-timeline.ts
@@ -0,0 +1,179 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy'; import ID from '../../../../cafy-id';
+import Note from '../../../../models/note';
+import Mute from '../../../../models/mute';
+import { pack } from '../../../../models/note';
+import UserList from '../../../../models/user-list';
+
+/**
+ * Get timeline of a user list
+ */
+module.exports = async (params, user, app) => {
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
+ if (limitErr) throw 'invalid limit param';
+
+ // Get 'sinceId' parameter
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
+ if (sinceIdErr) throw 'invalid sinceId param';
+
+ // Get 'untilId' parameter
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
+ if (untilIdErr) throw 'invalid untilId param';
+
+ // Get 'sinceDate' parameter
+ const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate);
+ if (sinceDateErr) throw 'invalid sinceDate param';
+
+ // Get 'untilDate' parameter
+ const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate);
+ if (untilDateErr) throw 'invalid untilDate param';
+
+ // Check if only one of sinceId, untilId, sinceDate, untilDate specified
+ if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) {
+ throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified';
+ }
+
+ // Get 'includeMyRenotes' parameter
+ const [includeMyRenotes = true, includeMyRenotesErr] = $.bool.optional().get(params.includeMyRenotes);
+ if (includeMyRenotesErr) throw 'invalid includeMyRenotes param';
+
+ // Get 'includeRenotedMyNotes' parameter
+ const [includeRenotedMyNotes = true, includeRenotedMyNotesErr] = $.bool.optional().get(params.includeRenotedMyNotes);
+ if (includeRenotedMyNotesErr) throw 'invalid includeRenotedMyNotes param';
+
+ // Get 'listId' parameter
+ const [listId, listIdErr] = $.type(ID).get(params.listId);
+ if (listIdErr) throw 'invalid listId param';
+
+ const [list, mutedUserIds] = await Promise.all([
+ // リストを取得
+ // Fetch the list
+ UserList.findOne({
+ _id: listId,
+ userId: user._id
+ }),
+
+ // ミュートしているユーザーを取得
+ Mute.find({
+ muterId: user._id
+ }).then(ms => ms.map(m => m.muteeId))
+ ]);
+
+ if (list.userIds.length == 0) {
+ return [];
+ }
+
+ //#region Construct query
+ const sort = {
+ _id: -1
+ };
+
+ const listQuery = list.userIds.map(u => ({
+ userId: u,
+
+ // リプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める)
+ $or: [{
+ // リプライでない
+ replyId: null
+ }, { // または
+ // リプライだが返信先が投稿者自身の投稿
+ $expr: {
+ $eq: ['$_reply.userId', '$userId']
+ }
+ }, { // または
+ // リプライだが返信先が自分(フォロワー)の投稿
+ '_reply.userId': user._id
+ }, { // または
+ // 自分(フォロワー)が送信したリプライ
+ userId: user._id
+ }]
+ }));
+
+ const query = {
+ $and: [{
+ // リストに入っている人のタイムラインへの投稿
+ $or: listQuery,
+
+ // mute
+ userId: {
+ $nin: mutedUserIds
+ },
+ '_reply.userId': {
+ $nin: mutedUserIds
+ },
+ '_renote.userId': {
+ $nin: mutedUserIds
+ },
+ }]
+ } as any;
+
+ // MongoDBではトップレベルで否定ができないため、De Morganの法則を利用してクエリします。
+ // つまり、「『自分の投稿かつRenote』ではない」を「『自分の投稿ではない』または『Renoteではない』」と表現します。
+ // for details: https://en.wikipedia.org/wiki/De_Morgan%27s_laws
+
+ if (includeMyRenotes === false) {
+ query.$and.push({
+ $or: [{
+ userId: { $ne: user._id }
+ }, {
+ renoteId: null
+ }, {
+ text: { $ne: null }
+ }, {
+ mediaIds: { $ne: [] }
+ }, {
+ poll: { $ne: null }
+ }]
+ });
+ }
+
+ if (includeRenotedMyNotes === false) {
+ query.$and.push({
+ $or: [{
+ '_renote.userId': { $ne: user._id }
+ }, {
+ renoteId: null
+ }, {
+ text: { $ne: null }
+ }, {
+ mediaIds: { $ne: [] }
+ }, {
+ poll: { $ne: null }
+ }]
+ });
+ }
+
+ if (sinceId) {
+ sort._id = 1;
+ query._id = {
+ $gt: sinceId
+ };
+ } else if (untilId) {
+ query._id = {
+ $lt: untilId
+ };
+ } else if (sinceDate) {
+ sort._id = 1;
+ query.createdAt = {
+ $gt: new Date(sinceDate)
+ };
+ } else if (untilDate) {
+ query.createdAt = {
+ $lt: new Date(untilDate)
+ };
+ }
+ //#endregion
+
+ // Issue query
+ const timeline = await Note
+ .find(query, {
+ limit: limit,
+ sort: sort
+ });
+
+ // Serialize
+ return await Promise.all(timeline.map(note => pack(note, user)));
+};
diff --git a/src/server/api/endpoints/notifications/get_unread_count.ts b/src/server/api/endpoints/notifications/get_unread_count.ts
index 283ecd63b1..600a80d194 100644
--- a/src/server/api/endpoints/notifications/get_unread_count.ts
+++ b/src/server/api/endpoints/notifications/get_unread_count.ts
@@ -6,10 +6,6 @@ import Mute from '../../../../models/mute';
/**
* Get count of unread notifications
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
const mute = await Mute.find({
diff --git a/src/server/api/endpoints/notifications/mark_as_read_all.ts b/src/server/api/endpoints/notifications/mark_as_read_all.ts
index 01c9145837..dce3cb4663 100644
--- a/src/server/api/endpoints/notifications/mark_as_read_all.ts
+++ b/src/server/api/endpoints/notifications/mark_as_read_all.ts
@@ -6,10 +6,6 @@ import event from '../../../../publishers/stream';
/**
* Mark as read all notifications
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Update documents
diff --git a/src/server/api/endpoints/othello/games.ts b/src/server/api/endpoints/othello/games.ts
index d05c1c2585..2320a34b04 100644
--- a/src/server/api/endpoints/othello/games.ts
+++ b/src/server/api/endpoints/othello/games.ts
@@ -1,21 +1,21 @@
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import OthelloGame, { pack } from '../../../../models/othello-game';
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'my' parameter
- const [my = false, myErr] = $(params.my).optional.boolean().$;
+ const [my = false, myErr] = $.bool.optional().get(params.my);
if (myErr) return rej('invalid my param');
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) return rej('invalid sinceId param');
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) return rej('invalid untilId param');
// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/othello/games/show.ts b/src/server/api/endpoints/othello/games/show.ts
index dd886936d4..6b2f5ce137 100644
--- a/src/server/api/endpoints/othello/games/show.ts
+++ b/src/server/api/endpoints/othello/games/show.ts
@@ -1,10 +1,10 @@
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
import OthelloGame, { pack } from '../../../../../models/othello-game';
import Othello from '../../../../../othello/core';
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'gameId' parameter
- const [gameId, gameIdErr] = $(params.gameId).id().$;
+ const [gameId, gameIdErr] = $.type(ID).get(params.gameId);
if (gameIdErr) return rej('invalid gameId param');
const game = await OthelloGame.findOne({ _id: gameId });
diff --git a/src/server/api/endpoints/othello/match.ts b/src/server/api/endpoints/othello/match.ts
index d9274f8f9c..e70e579755 100644
--- a/src/server/api/endpoints/othello/match.ts
+++ b/src/server/api/endpoints/othello/match.ts
@@ -1,4 +1,4 @@
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Matching, { pack as packMatching } from '../../../../models/othello-matching';
import OthelloGame, { pack as packGame } from '../../../../models/othello-game';
import User from '../../../../models/user';
@@ -7,7 +7,7 @@ import { eighteight } from '../../../../othello/maps';
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'userId' parameter
- const [childId, childIdErr] = $(params.userId).id().$;
+ const [childId, childIdErr] = $.type(ID).get(params.userId);
if (childIdErr) return rej('invalid userId param');
// Myself
diff --git a/src/server/api/endpoints/stats.ts b/src/server/api/endpoints/stats.ts
index 52e5195484..d1e17651f2 100644
--- a/src/server/api/endpoints/stats.ts
+++ b/src/server/api/endpoints/stats.ts
@@ -1,48 +1,26 @@
-/**
- * Module dependencies
- */
import Note from '../../../models/note';
import User from '../../../models/user';
/**
- * @swagger
- * /stats:
- * note:
- * summary: Show the misskey's statistics
- * responses:
- * 200:
- * description: Success
- * schema:
- * type: object
- * properties:
- * notesCount:
- * description: count of all notes of misskey
- * type: number
- * usersCount:
- * description: count of all users of misskey
- * type: number
- *
- * default:
- * description: Failed
- * schema:
- * $ref: "#/definitions/Error"
- */
-
-/**
- * Show the misskey's statistics
- *
- * @param {any} params
- * @return {Promise<any>}
+ * Get the misskey's statistics
*/
module.exports = params => new Promise(async (res, rej) => {
- const notesCount = await Note
- .count();
+ const notesCount = await Note.count();
- const usersCount = await User
- .count();
+ const usersCount = await User.count();
+
+ const originalNotesCount = await Note.count({
+ '_user.host': null
+ });
+
+ const originalUsersCount = await User.count({
+ host: null
+ });
res({
- notesCount: notesCount,
- usersCount: usersCount
+ notesCount,
+ usersCount,
+ originalNotesCount,
+ originalUsersCount
});
});
diff --git a/src/server/api/endpoints/sw/register.ts b/src/server/api/endpoints/sw/register.ts
index 3fe0bda4ee..b22a8b08ef 100644
--- a/src/server/api/endpoints/sw/register.ts
+++ b/src/server/api/endpoints/sw/register.ts
@@ -9,15 +9,15 @@ import Subscription from '../../../../models/sw-subscription';
*/
module.exports = async (params, user, app) => new Promise(async (res, rej) => {
// Get 'endpoint' parameter
- const [endpoint, endpointErr] = $(params.endpoint).string().$;
+ const [endpoint, endpointErr] = $.str.get(params.endpoint);
if (endpointErr) return rej('invalid endpoint param');
// Get 'auth' parameter
- const [auth, authErr] = $(params.auth).string().$;
+ const [auth, authErr] = $.str.get(params.auth);
if (authErr) return rej('invalid auth param');
// Get 'publickey' parameter
- const [publickey, publickeyErr] = $(params.publickey).string().$;
+ const [publickey, publickeyErr] = $.str.get(params.publickey);
if (publickeyErr) return rej('invalid publickey param');
// if already subscribed
diff --git a/src/server/api/endpoints/username/available.ts b/src/server/api/endpoints/username/available.ts
index bd27c37de0..b11bec4e58 100644
--- a/src/server/api/endpoints/username/available.ts
+++ b/src/server/api/endpoints/username/available.ts
@@ -13,7 +13,7 @@ import { validateUsername } from '../../../../models/user';
*/
module.exports = async (params) => new Promise(async (res, rej) => {
// Get 'username' parameter
- const [username, usernameError] = $(params.username).string().pipe(validateUsername).$;
+ const [username, usernameError] = $.str.pipe(validateUsername).get(params.username);
if (usernameError) return rej('invalid username param');
// Get exist
diff --git a/src/server/api/endpoints/users.ts b/src/server/api/endpoints/users.ts
index ae33e8af0c..eb581cb7e6 100644
--- a/src/server/api/endpoints/users.ts
+++ b/src/server/api/endpoints/users.ts
@@ -6,22 +6,18 @@ import User, { pack } from '../../../models/user';
/**
* Lists all users
- *
- * @param {any} params
- * @param {any} me
- * @return {Promise<any>}
*/
module.exports = (params, me) => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'offset' parameter
- const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset);
if (offsetErr) return rej('invalid offset param');
// Get 'sort' parameter
- const [sort, sortError] = $(params.sort).optional.string().or('+follower|-follower').$;
+ const [sort, sortError] = $.str.optional().or('+follower|-follower').get(params.sort);
if (sortError) return rej('invalid sort param');
// Construct query
diff --git a/src/server/api/endpoints/users/followers.ts b/src/server/api/endpoints/users/followers.ts
index 0222313e81..810cd7341b 100644
--- a/src/server/api/endpoints/users/followers.ts
+++ b/src/server/api/endpoints/users/followers.ts
@@ -1,34 +1,30 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import User from '../../../../models/user';
import Following from '../../../../models/following';
import { pack } from '../../../../models/user';
-import getFriends from '../../common/get-friends';
+import { getFriendIds } from '../../common/get-friends';
/**
* Get followers of a user
- *
- * @param {any} params
- * @param {any} me
- * @return {Promise<any>}
*/
module.exports = (params, me) => new Promise(async (res, rej) => {
// Get 'userId' parameter
- const [userId, userIdErr] = $(params.userId).id().$;
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
if (userIdErr) return rej('invalid userId param');
// Get 'iknow' parameter
- const [iknow = false, iknowErr] = $(params.iknow).optional.boolean().$;
+ const [iknow = false, iknowErr] = $.bool.optional().get(params.iknow);
if (iknowErr) return rej('invalid iknow param');
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'cursor' parameter
- const [cursor = null, cursorErr] = $(params.cursor).optional.id().$;
+ const [cursor = null, cursorErr] = $.type(ID).optional().get(params.cursor);
if (cursorErr) return rej('invalid cursor param');
// Lookup user
@@ -52,7 +48,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
// ログインしていてかつ iknow フラグがあるとき
if (me && iknow) {
// Get my friends
- const myFriends = await getFriends(me._id);
+ const myFriends = await getFriendIds(me._id);
query.followerId = {
$in: myFriends
diff --git a/src/server/api/endpoints/users/following.ts b/src/server/api/endpoints/users/following.ts
index 2372f57fbe..3373b9d632 100644
--- a/src/server/api/endpoints/users/following.ts
+++ b/src/server/api/endpoints/users/following.ts
@@ -1,11 +1,11 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import User from '../../../../models/user';
import Following from '../../../../models/following';
import { pack } from '../../../../models/user';
-import getFriends from '../../common/get-friends';
+import { getFriendIds } from '../../common/get-friends';
/**
* Get following users of a user
@@ -16,19 +16,19 @@ import getFriends from '../../common/get-friends';
*/
module.exports = (params, me) => new Promise(async (res, rej) => {
// Get 'userId' parameter
- const [userId, userIdErr] = $(params.userId).id().$;
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
if (userIdErr) return rej('invalid userId param');
// Get 'iknow' parameter
- const [iknow = false, iknowErr] = $(params.iknow).optional.boolean().$;
+ const [iknow = false, iknowErr] = $.bool.optional().get(params.iknow);
if (iknowErr) return rej('invalid iknow param');
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'cursor' parameter
- const [cursor = null, cursorErr] = $(params.cursor).optional.id().$;
+ const [cursor = null, cursorErr] = $.type(ID).optional().get(params.cursor);
if (cursorErr) return rej('invalid cursor param');
// Lookup user
@@ -52,7 +52,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
// ログインしていてかつ iknow フラグがあるとき
if (me && iknow) {
// Get my friends
- const myFriends = await getFriends(me._id);
+ const myFriends = await getFriendIds(me._id);
query.followeeId = {
$in: myFriends
diff --git a/src/server/api/endpoints/users/get_frequently_replied_users.ts b/src/server/api/endpoints/users/get_frequently_replied_users.ts
index 7a98f44e98..64d737a06b 100644
--- a/src/server/api/endpoints/users/get_frequently_replied_users.ts
+++ b/src/server/api/endpoints/users/get_frequently_replied_users.ts
@@ -1,17 +1,17 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import Note from '../../../../models/note';
import User, { pack } from '../../../../models/user';
module.exports = (params, me) => new Promise(async (res, rej) => {
// Get 'userId' parameter
- const [userId, userIdErr] = $(params.userId).id().$;
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
if (userIdErr) return rej('invalid userId param');
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Lookup user
diff --git a/src/server/api/endpoints/users/lists/create.ts b/src/server/api/endpoints/users/lists/create.ts
new file mode 100644
index 0000000000..100a78b872
--- /dev/null
+++ b/src/server/api/endpoints/users/lists/create.ts
@@ -0,0 +1,25 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import UserList, { pack } from '../../../../../models/user-list';
+
+/**
+ * Create a user list
+ */
+module.exports = async (params, user) => new Promise(async (res, rej) => {
+ // Get 'title' parameter
+ const [title, titleErr] = $.str.range(1, 100).get(params.title);
+ if (titleErr) return rej('invalid title param');
+
+ // insert
+ const userList = await UserList.insert({
+ createdAt: new Date(),
+ userId: user._id,
+ title: title,
+ userIds: []
+ });
+
+ // Response
+ res(await pack(userList));
+});
diff --git a/src/server/api/endpoints/users/lists/list.ts b/src/server/api/endpoints/users/lists/list.ts
new file mode 100644
index 0000000000..d19339a1f5
--- /dev/null
+++ b/src/server/api/endpoints/users/lists/list.ts
@@ -0,0 +1,13 @@
+import UserList, { pack } from '../../../../../models/user-list';
+
+/**
+ * Add a user to a user list
+ */
+module.exports = async (params, me) => new Promise(async (res, rej) => {
+ // Fetch lists
+ const userLists = await UserList.find({
+ userId: me._id,
+ });
+
+ res(await Promise.all(userLists.map(x => pack(x))));
+});
diff --git a/src/server/api/endpoints/users/lists/push.ts b/src/server/api/endpoints/users/lists/push.ts
new file mode 100644
index 0000000000..da5a9a134c
--- /dev/null
+++ b/src/server/api/endpoints/users/lists/push.ts
@@ -0,0 +1,61 @@
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
+import UserList from '../../../../../models/user-list';
+import User, { pack as packUser, isRemoteUser, getGhost } from '../../../../../models/user';
+import { publishUserListStream } from '../../../../../publishers/stream';
+import ap from '../../../../../remote/activitypub/renderer';
+import renderFollow from '../../../../../remote/activitypub/renderer/follow';
+import { deliver } from '../../../../../queue';
+
+/**
+ * Add a user to a user list
+ */
+module.exports = async (params, me) => new Promise(async (res, rej) => {
+ // Get 'listId' parameter
+ const [listId, listIdErr] = $.type(ID).get(params.listId);
+ if (listIdErr) return rej('invalid listId param');
+
+ // Fetch the list
+ const userList = await UserList.findOne({
+ _id: listId,
+ userId: me._id,
+ });
+
+ if (userList == null) {
+ return rej('list not found');
+ }
+
+ // Get 'userId' parameter
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
+ if (userIdErr) return rej('invalid userId param');
+
+ // Fetch the user
+ const user = await User.findOne({
+ _id: userId
+ });
+
+ if (user == null) {
+ return rej('user not found');
+ }
+
+ if (userList.userIds.map(id => id.toHexString()).includes(user._id.toHexString())) {
+ return rej('the user already added');
+ }
+
+ // Push the user
+ await UserList.update({ _id: userList._id }, {
+ $push: {
+ userIds: user._id
+ }
+ });
+
+ res();
+
+ publishUserListStream(userList._id, 'userAdded', await packUser(user));
+
+ // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
+ if (isRemoteUser(user)) {
+ const ghost = await getGhost();
+ const content = ap(renderFollow(ghost, user));
+ deliver(ghost, content, user.inbox);
+ }
+});
diff --git a/src/server/api/endpoints/users/lists/show.ts b/src/server/api/endpoints/users/lists/show.ts
new file mode 100644
index 0000000000..16cb3382fd
--- /dev/null
+++ b/src/server/api/endpoints/users/lists/show.ts
@@ -0,0 +1,23 @@
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
+import UserList, { pack } from '../../../../../models/user-list';
+
+/**
+ * Show a user list
+ */
+module.exports = async (params, me) => new Promise(async (res, rej) => {
+ // Get 'listId' parameter
+ const [listId, listIdErr] = $.type(ID).get(params.listId);
+ if (listIdErr) return rej('invalid listId param');
+
+ // Fetch the list
+ const userList = await UserList.findOne({
+ _id: listId,
+ userId: me._id,
+ });
+
+ if (userList == null) {
+ return rej('list not found');
+ }
+
+ res(await pack(userList));
+});
diff --git a/src/server/api/endpoints/users/notes.ts b/src/server/api/endpoints/users/notes.ts
index bd4247c79c..061c363d0f 100644
--- a/src/server/api/endpoints/users/notes.ts
+++ b/src/server/api/endpoints/users/notes.ts
@@ -1,7 +1,7 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import getHostLower from '../../common/get-host-lower';
import Note, { pack } from '../../../../models/note';
import User from '../../../../models/user';
@@ -11,11 +11,11 @@ import User from '../../../../models/user';
*/
module.exports = (params, me) => new Promise(async (res, rej) => {
// Get 'userId' parameter
- const [userId, userIdErr] = $(params.userId).optional.id().$;
+ const [userId, userIdErr] = $.type(ID).optional().get(params.userId);
if (userIdErr) return rej('invalid userId param');
// Get 'username' parameter
- const [username, usernameErr] = $(params.username).optional.string().$;
+ const [username, usernameErr] = $.str.optional().get(params.username);
if (usernameErr) return rej('invalid username param');
if (userId === undefined && username === undefined) {
@@ -23,7 +23,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
}
// Get 'host' parameter
- const [host, hostErr] = $(params.host).optional.string().$;
+ const [host, hostErr] = $.str.optional().get(params.host);
if (hostErr) return rej('invalid host param');
if (userId === undefined && host === undefined) {
@@ -31,31 +31,31 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
}
// Get 'includeReplies' parameter
- const [includeReplies = true, includeRepliesErr] = $(params.includeReplies).optional.boolean().$;
+ const [includeReplies = true, includeRepliesErr] = $.bool.optional().get(params.includeReplies);
if (includeRepliesErr) return rej('invalid includeReplies param');
// Get 'withMedia' parameter
- const [withMedia = false, withMediaErr] = $(params.withMedia).optional.boolean().$;
+ const [withMedia = false, withMediaErr] = $.bool.optional().get(params.withMedia);
if (withMediaErr) return rej('invalid withMedia param');
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'sinceId' parameter
- const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
if (sinceIdErr) return rej('invalid sinceId param');
// Get 'untilId' parameter
- const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
if (untilIdErr) return rej('invalid untilId param');
// Get 'sinceDate' parameter
- const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$;
+ const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate);
if (sinceDateErr) throw 'invalid sinceDate param';
// Get 'untilDate' parameter
- const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$;
+ const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate);
if (untilDateErr) throw 'invalid untilDate param';
// Check if only one of sinceId, untilId, sinceDate, untilDate specified
diff --git a/src/server/api/endpoints/users/recommendation.ts b/src/server/api/endpoints/users/recommendation.ts
index e367e65a6c..620ae17ca2 100644
--- a/src/server/api/endpoints/users/recommendation.ts
+++ b/src/server/api/endpoints/users/recommendation.ts
@@ -4,7 +4,8 @@
const ms = require('ms');
import $ from 'cafy';
import User, { pack } from '../../../../models/user';
-import getFriends from '../../common/get-friends';
+import { getFriendIds } from '../../common/get-friends';
+import Mute from '../../../../models/mute';
/**
* Get recommended users
@@ -15,23 +16,28 @@ import getFriends from '../../common/get-friends';
*/
module.exports = (params, me) => new Promise(async (res, rej) => {
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'offset' parameter
- const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset);
if (offsetErr) return rej('invalid offset param');
// ID list of the user itself and other users who the user follows
- const followingIds = await getFriends(me._id);
+ const followingIds = await getFriendIds(me._id);
+
+ // ミュートしているユーザーを取得
+ const mutedUserIds = (await Mute.find({
+ muterId: me._id
+ })).map(m => m.muteeId);
const users = await User
.find({
_id: {
- $nin: followingIds
+ $nin: followingIds.concat(mutedUserIds)
},
$or: [{
- 'lastUsedAt': {
+ lastUsedAt: {
$gte: new Date(Date.now() - ms('7days'))
}
}, {
diff --git a/src/server/api/endpoints/users/search.ts b/src/server/api/endpoints/users/search.ts
index da30f47c2a..cfbdc337bf 100644
--- a/src/server/api/endpoints/users/search.ts
+++ b/src/server/api/endpoints/users/search.ts
@@ -16,15 +16,15 @@ const escapeRegexp = require('escape-regexp');
*/
module.exports = (params, me) => new Promise(async (res, rej) => {
// Get 'query' parameter
- const [query, queryError] = $(params.query).string().pipe(x => x != '').$;
+ const [query, queryError] = $.str.pipe(x => x != '').get(params.query);
if (queryError) return rej('invalid query param');
// Get 'offset' parameter
- const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset);
if (offsetErr) return rej('invalid offset param');
// Get 'max' parameter
- const [max = 10, maxErr] = $(params.max).optional.number().range(1, 30).$;
+ const [max = 10, maxErr] = $.num.optional().range(1, 30).get(params.max);
if (maxErr) return rej('invalid max param');
// If Elasticsearch is available, search by $
diff --git a/src/server/api/endpoints/users/search_by_username.ts b/src/server/api/endpoints/users/search_by_username.ts
index 5f6ececff9..5927d00faf 100644
--- a/src/server/api/endpoints/users/search_by_username.ts
+++ b/src/server/api/endpoints/users/search_by_username.ts
@@ -6,22 +6,18 @@ import User, { pack } from '../../../../models/user';
/**
* Search a user by username
- *
- * @param {any} params
- * @param {any} me
- * @return {Promise<any>}
*/
module.exports = (params, me) => new Promise(async (res, rej) => {
// Get 'query' parameter
- const [query, queryError] = $(params.query).string().$;
+ const [query, queryError] = $.str.get(params.query);
if (queryError) return rej('invalid query param');
// Get 'offset' parameter
- const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset);
if (offsetErr) return rej('invalid offset param');
// Get 'limit' parameter
- const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
const users = await User
diff --git a/src/server/api/endpoints/users/show.ts b/src/server/api/endpoints/users/show.ts
index 7e7f5dc488..b8c6ff25c4 100644
--- a/src/server/api/endpoints/users/show.ts
+++ b/src/server/api/endpoints/users/show.ts
@@ -1,56 +1,68 @@
/**
* Module dependencies
*/
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
import User, { pack } from '../../../../models/user';
import resolveRemoteUser from '../../../../remote/resolve-user';
const cursorOption = { fields: { data: false } };
/**
- * Show a user
+ * Show user(s)
*/
module.exports = (params, me) => new Promise(async (res, rej) => {
let user;
// Get 'userId' parameter
- const [userId, userIdErr] = $(params.userId).optional.id().$;
+ const [userId, userIdErr] = $.type(ID).optional().get(params.userId);
if (userIdErr) return rej('invalid userId param');
+ // Get 'userIds' parameter
+ const [userIds, userIdsErr] = $.arr($.type(ID)).optional().get(params.userIds);
+ if (userIdsErr) return rej('invalid userIds param');
+
// Get 'username' parameter
- const [username, usernameErr] = $(params.username).optional.string().$;
+ const [username, usernameErr] = $.str.optional().get(params.username);
if (usernameErr) return rej('invalid username param');
// Get 'host' parameter
- const [host, hostErr] = $(params.host).nullable.optional.string().$;
+ const [host, hostErr] = $.str.optional().nullable().get(params.host);
if (hostErr) return rej('invalid host param');
- if (userId === undefined && typeof username !== 'string') {
- return rej('userId or pair of username and host is required');
- }
+ if (userIds) {
+ const users = await User.find({
+ _id: {
+ $in: userIds
+ }
+ });
- // Lookup user
- if (typeof host === 'string') {
- try {
- user = await resolveRemoteUser(username, host, cursorOption);
- } catch (e) {
- console.warn(`failed to resolve remote user: ${e}`);
- return rej('failed to resolve remote user');
- }
+ res(await Promise.all(users.map(u => pack(u, me, {
+ detail: true
+ }))));
} else {
- const q = userId !== undefined
- ? { _id: userId }
- : { usernameLower: username.toLowerCase(), host: null };
+ // Lookup user
+ if (typeof host === 'string') {
+ try {
+ user = await resolveRemoteUser(username, host, cursorOption);
+ } catch (e) {
+ console.warn(`failed to resolve remote user: ${e}`);
+ return rej('failed to resolve remote user');
+ }
+ } else {
+ const q = userId !== undefined
+ ? { _id: userId }
+ : { usernameLower: username.toLowerCase(), host: null };
- user = await User.findOne(q, cursorOption);
+ user = await User.findOne(q, cursorOption);
- if (user === null) {
- return rej('user not found');
+ if (user === null) {
+ return rej('user not found');
+ }
}
- }
- // Send response
- res(await pack(user, me, {
- detail: true
- }));
+ // Send response
+ res(await pack(user, me, {
+ detail: true
+ }));
+ }
});
diff --git a/src/server/api/stream/home.ts b/src/server/api/stream/home.ts
index a9d6ff241e..54fde2d776 100644
--- a/src/server/api/stream/home.ts
+++ b/src/server/api/stream/home.ts
@@ -32,17 +32,17 @@ export default async function(
//#region 流れてきたメッセージがミュートしているユーザーが関わるものだったら無視する
if (x.type == 'note') {
- if (mutedUserIds.indexOf(x.body.userId) != -1) {
+ if (mutedUserIds.includes(x.body.userId)) {
return;
}
- if (x.body.reply != null && mutedUserIds.indexOf(x.body.reply.userId) != -1) {
+ if (x.body.reply != null && mutedUserIds.includes(x.body.reply.userId)) {
return;
}
- if (x.body.renote != null && mutedUserIds.indexOf(x.body.renote.userId) != -1) {
+ if (x.body.renote != null && mutedUserIds.includes(x.body.renote.userId)) {
return;
}
} else if (x.type == 'notification') {
- if (mutedUserIds.indexOf(x.body.userId) != -1) {
+ if (mutedUserIds.includes(x.body.userId)) {
return;
}
}
@@ -53,6 +53,7 @@ export default async function(
connection.send(data);
}
break;
+
case 'note-stream':
const noteId = channel.split(':')[2];
log(`RECEIVED: ${noteId} ${data} by @${user.username}`);
@@ -69,12 +70,13 @@ export default async function(
}
});
- connection.on('message', data => {
+ connection.on('message', async data => {
const msg = JSON.parse(data.utf8Data);
switch (msg.type) {
case 'api':
- call(msg.endpoint, user, app, msg.data).then(res => {
+ // 新鮮なデータを利用するためにユーザーをフェッチ
+ call(msg.endpoint, await User.findOne({ _id: user._id }), app, msg.data).then(res => {
connection.send(JSON.stringify({
type: `api-res:${msg.id}`,
body: { res }
diff --git a/src/server/api/stream/user-list.ts b/src/server/api/stream/user-list.ts
new file mode 100644
index 0000000000..ba03b97860
--- /dev/null
+++ b/src/server/api/stream/user-list.ts
@@ -0,0 +1,14 @@
+import * as websocket from 'websocket';
+import * as redis from 'redis';
+import { ParsedUrlQuery } from 'querystring';
+
+export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void {
+ const q = request.resourceURL.query as ParsedUrlQuery;
+ const listId = q.listId as string;
+
+ // Subscribe stream
+ subscriber.subscribe(`misskey:user-list-stream:${listId}`);
+ subscriber.on('message', (_, data) => {
+ connection.send(data);
+ });
+}
diff --git a/src/server/api/streaming.ts b/src/server/api/streaming.ts
index ce13253649..e4884ed7c4 100644
--- a/src/server/api/streaming.ts
+++ b/src/server/api/streaming.ts
@@ -6,6 +6,7 @@ import config from '../../config';
import homeStream from './stream/home';
import localTimelineStream from './stream/local-timeline';
import globalTimelineStream from './stream/global-timeline';
+import userListStream from './stream/user-list';
import driveStream from './stream/drive';
import messagingStream from './stream/messaging';
import messagingIndexStream from './stream/messaging-index';
@@ -70,6 +71,7 @@ module.exports = (server: http.Server) => {
request.resourceURL.pathname === '/' ? homeStream :
request.resourceURL.pathname === '/local-timeline' ? localTimelineStream :
request.resourceURL.pathname === '/global-timeline' ? globalTimelineStream :
+ request.resourceURL.pathname === '/user-list' ? userListStream :
request.resourceURL.pathname === '/drive' ? driveStream :
request.resourceURL.pathname === '/messaging' ? messagingStream :
request.resourceURL.pathname === '/messaging-index' ? messagingIndexStream :
diff --git a/src/server/file/assets/cache-expired.png b/src/server/file/assets/cache-expired.png
new file mode 100644
index 0000000000..ea681af0a0
--- /dev/null
+++ b/src/server/file/assets/cache-expired.png
Binary files differ
diff --git a/src/server/file/assets/tombstone.png b/src/server/file/assets/tombstone.png
new file mode 100644
index 0000000000..86224e3182
--- /dev/null
+++ b/src/server/file/assets/tombstone.png
Binary files differ
diff --git a/src/server/file/index.ts b/src/server/file/index.ts
index 29056c63e7..973528da33 100644
--- a/src/server/file/index.ts
+++ b/src/server/file/index.ts
@@ -6,7 +6,6 @@ import * as fs from 'fs';
import * as Koa from 'koa';
import * as cors from '@koa/cors';
import * as Router from 'koa-router';
-import pour from './pour';
import sendDriveFile from './send-drive-file';
// Init app
@@ -24,12 +23,14 @@ const router = new Router();
router.get('/default-avatar.jpg', ctx => {
const file = fs.createReadStream(`${__dirname}/assets/avatar.jpg`);
- pour(file, 'image/jpeg', ctx);
+ ctx.set('Content-Type', 'image/jpeg');
+ ctx.body = file;
});
router.get('/app-default.jpg', ctx => {
const file = fs.createReadStream(`${__dirname}/assets/dummy.png`);
- pour(file, 'image/png', ctx);
+ ctx.set('Content-Type', 'image/jpeg');
+ ctx.body = file;
});
router.get('/:id', sendDriveFile);
diff --git a/src/server/file/pour.ts b/src/server/file/pour.ts
deleted file mode 100644
index 0fd0ad0e60..0000000000
--- a/src/server/file/pour.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import * as fs from 'fs';
-import * as stream from 'stream';
-import * as Koa from 'koa';
-import * as Gm from 'gm';
-
-const gm = Gm.subClass({
- imageMagick: true
-});
-
-interface ISend {
- contentType: string;
- stream: stream.Readable;
-}
-
-function thumbnail(data: stream.Readable, type: string, resize: number): ISend {
- const readable: stream.Readable = (() => {
- // 動画であれば
- if (/^video\/.*$/.test(type)) {
- // TODO
- // 使わないことになったストリームはしっかり取り壊す
- data.destroy();
- return fs.createReadStream(`${__dirname}/assets/thumbnail-not-available.png`);
- // 画像であれば
- // Note: SVGはapplication/xml
- } else if (/^image\/.*$/.test(type) || type == 'application/xml') {
- // 0フレーム目を送る
- try {
- return gm(data).selectFrame(0).stream();
- // だめだったら
- } catch (e) {
- // 使わないことになったストリームはしっかり取り壊す
- data.destroy();
- return fs.createReadStream(`${__dirname}/assets/thumbnail-not-available.png`);
- }
- // 動画か画像以外
- } else {
- data.destroy();
- return fs.createReadStream(`${__dirname}/assets/not-an-image.png`);
- }
- })();
-
- let g = gm(readable);
-
- if (resize) {
- g = g.resize(resize, resize);
- }
-
- const stream = g
- .compress('jpeg')
- .quality(80)
- .interlace('line')
- .stream();
-
- return {
- contentType: 'image/jpeg',
- stream
- };
-}
-
-const commonReadableHandlerGenerator = (ctx: Koa.Context) => (e: Error): void => {
- console.error(e);
- ctx.status = 500;
-};
-
-export default function(readable: stream.Readable, type: string, ctx: Koa.Context): void {
- readable.on('error', commonReadableHandlerGenerator(ctx));
-
- const data = ((): ISend => {
- if (ctx.query.thumbnail !== undefined) {
- return thumbnail(readable, type, ctx.query.size);
- }
- return {
- contentType: type,
- stream: readable
- };
- })();
-
- if (readable !== data.stream) {
- data.stream.on('error', commonReadableHandlerGenerator(ctx));
- }
-
- if (ctx.query.download !== undefined) {
- ctx.set('Content-Disposition', 'attachment');
- }
-
- ctx.set('Content-Type', data.contentType);
- ctx.body = data.stream;
-}
diff --git a/src/server/file/send-drive-file.ts b/src/server/file/send-drive-file.ts
index e6ee19ff1d..d613a3aa5f 100644
--- a/src/server/file/send-drive-file.ts
+++ b/src/server/file/send-drive-file.ts
@@ -1,8 +1,17 @@
+import * as fs from 'fs';
+
import * as Koa from 'koa';
import * as send from 'koa-send';
import * as mongodb from 'mongodb';
-import DriveFile, { getGridFSBucket } from '../../models/drive-file';
-import pour from './pour';
+import DriveFile, { getDriveFileBucket } from '../../models/drive-file';
+import DriveFileThumbnail, { getDriveFileThumbnailBucket } from '../../models/drive-file-thumbnail';
+
+const assets = `${__dirname}/../../server/file/assets/`;
+
+const commonReadableHandlerGenerator = (ctx: Koa.Context) => (e: Error): void => {
+ console.error(e);
+ ctx.status = 500;
+};
export default async function(ctx: Koa.Context) {
// Validate id
@@ -18,13 +27,52 @@ export default async function(ctx: Koa.Context) {
if (file == null) {
ctx.status = 404;
- await send(ctx, `${__dirname}/assets/dummy.png`);
+ await send(ctx, '/dummy.png', { root: assets });
return;
}
- const bucket = await getGridFSBucket();
+ if (file.metadata.deletedAt) {
+ ctx.status = 410;
+ if (file.metadata.isExpired) {
+ await send(ctx, '/cache-expired.png', { root: assets });
+ } else {
+ await send(ctx, '/tombstone.png', { root: assets });
+ }
+ return;
+ }
+
+ const sendRaw = async () => {
+ const bucket = await getDriveFileBucket();
+ const readable = bucket.openDownloadStream(fileId);
+ readable.on('error', commonReadableHandlerGenerator(ctx));
+ ctx.set('Content-Type', file.contentType);
+ ctx.body = readable;
+ };
- const readable = bucket.openDownloadStream(fileId);
+ if ('thumbnail' in ctx.query) {
+ // 画像以外
+ if (!file.contentType.startsWith('image/')) {
+ const readable = fs.createReadStream(`${__dirname}/assets/thumbnail-not-available.png`);
+ ctx.set('Content-Type', 'image/png');
+ ctx.body = readable;
+ } else if (file.contentType == 'image/gif') {
+ // GIF
+ await sendRaw();
+ } else {
+ const thumb = await DriveFileThumbnail.findOne({ 'metadata.originalId': fileId });
+ if (thumb != null) {
+ ctx.set('Content-Type', 'image/jpeg');
+ const bucket = await getDriveFileThumbnailBucket();
+ ctx.body = bucket.openDownloadStream(thumb._id);
+ } else {
+ await sendRaw();
+ }
+ }
+ } else {
+ if ('download' in ctx.query) {
+ ctx.set('Content-Disposition', 'attachment');
+ }
- pour(readable, file.contentType, ctx);
+ await sendRaw();
+ }
}
diff --git a/src/server/index.ts b/src/server/index.ts
index 5db3da2b93..ded8f7706e 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -4,12 +4,15 @@
import * as fs from 'fs';
import * as http from 'http';
-import * as http2 from 'http2';
+import * as https from 'https';
+//import * as http2 from 'http2';
import * as zlib from 'zlib';
import * as Koa from 'koa';
import * as Router from 'koa-router';
import * as mount from 'koa-mount';
import * as compress from 'koa-compress';
+import * as logger from 'koa-logger';
+//const slow = require('koa-slow');
import activityPub from './activitypub';
import webFinger from './webfinger';
@@ -19,6 +22,17 @@ import config from '../config';
const app = new Koa();
app.proxy = true;
+if (process.env.NODE_ENV != 'production') {
+ // Logger
+ app.use(logger());
+
+ // Delay
+ //app.use(slow({
+ // delay: 1000
+ //}));
+}
+
+// Compress response
app.use(compress({
flush: zlib.constants.Z_SYNC_FLUSH
}));
@@ -54,7 +68,8 @@ function createServer() {
certs[k] = fs.readFileSync(config.https[k]);
});
certs['allowHTTP1'] = true;
- return http2.createSecureServer(certs, app.callback());
+ //return http2.createSecureServer(certs, app.callback());
+ return https.createServer(certs, app.callback());
} else {
return http.createServer(app.callback());
}
diff --git a/src/server/web/index.ts b/src/server/web/index.ts
index eba0c372b0..6ceef17c1c 100644
--- a/src/server/web/index.ts
+++ b/src/server/web/index.ts
@@ -7,14 +7,32 @@ import * as Koa from 'koa';
import * as Router from 'koa-router';
import * as send from 'koa-send';
import * as favicon from 'koa-favicon';
+import * as views from 'koa-views';
import docs from './docs';
+import User from '../../models/user';
+import parseAcct from '../../acct/parse';
+import { fa } from '../../build/fa';
+import config from '../../config';
+import Note, { pack as packNote } from '../../models/note';
+import getNoteSummary from '../../renderers/get-note-summary';
+const consts = require('../../const.json');
const client = `${__dirname}/../../client/`;
// Init app
const app = new Koa();
+// Init renderer
+app.use(views(__dirname + '/views', {
+ extension: 'pug',
+ options: {
+ config,
+ themeColor: consts.themeColor,
+ facss: fa.dom.css()
+ }
+}));
+
// Serve favicon
app.use(favicon(`${client}/assets/favicon.ico`));
@@ -42,17 +60,21 @@ router.get('/assets/*', async ctx => {
// Apple touch icon
router.get('/apple-touch-icon.png', async ctx => {
- await send(ctx, `${client}/assets/apple-touch-icon.png`);
+ await send(ctx, '/assets/apple-touch-icon.png', {
+ root: client
+ });
});
// ServiceWroker
-router.get(/^\/sw\.(.+?)\.js$/, async ctx => {
- await send(ctx, `${client}/assets/sw.${ctx.params[0]}.js`);
-});
+//router.get(/^\/sw\.(.+?)\.js$/, async ctx => {
+// await send(ctx, `${client}/assets/sw.${ctx.params[0]}.js`);
+//});
// Manifest
router.get('/manifest.json', async ctx => {
- await send(ctx, `${client}/assets/manifest.json`);
+ await send(ctx, '/assets/manifest.json', {
+ root: client
+ });
});
//#endregion
@@ -61,7 +83,40 @@ router.get('/manifest.json', async ctx => {
router.use('/docs', docs.routes());
// URL preview endpoint
-router.get('url', require('./url-preview'));
+router.get('/url', require('./url-preview'));
+
+//#region for crawlers
+// User
+router.get('/@:user', async (ctx, next) => {
+ const { username, host } = parseAcct(ctx.params.user);
+ const user = await User.findOne({
+ usernameLower: username.toLowerCase(),
+ host
+ });
+
+ if (user != null) {
+ await ctx.render('user', { user });
+ } else {
+ // リモートユーザーなので
+ await next();
+ }
+});
+
+// Note
+router.get('/notes/:note', async ctx => {
+ const note = await Note.findOne({ _id: ctx.params.note });
+
+ if (note != null) {
+ const _note = await packNote(note);
+ await ctx.render('note', {
+ note: _note,
+ summary: getNoteSummary(_note)
+ });
+ } else {
+ ctx.status = 404;
+ }
+});
+//#endregion
// Render base html for all requests
router.get('*', async ctx => {
diff --git a/src/server/web/url-preview.ts b/src/server/web/url-preview.ts
index d5464d0cd4..cd53837a25 100644
--- a/src/server/web/url-preview.ts
+++ b/src/server/web/url-preview.ts
@@ -14,6 +14,8 @@ module.exports = async (ctx: Koa.Context) => {
function wrap(url: string): string {
return url != null
- ? `https://images.weserv.nl/?url=${url.replace(/^https?:\/\//, '')}`
+ ? url.startsWith('https://') || url.startsWith('data:')
+ ? url
+ : `https://images.weserv.nl/?url=${encodeURIComponent(url.replace(/^http:\/\//, ''))}`
: null;
}
diff --git a/src/server/web/views/note.pug b/src/server/web/views/note.pug
new file mode 100644
index 0000000000..22f1834059
--- /dev/null
+++ b/src/server/web/views/note.pug
@@ -0,0 +1,25 @@
+extends ../../../../src/client/app/base
+
+block vars
+ - const user = note.user;
+ - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
+ - const url = `${config.url}/notes/${note.id}`;
+
+block title
+ = `${title} | Misskey`
+
+block desc
+ meta(name='description' content= summary)
+
+block meta
+ meta(name='twitter:card' content='summary')
+ meta(property='og:type' content='article')
+ meta(property='og:title' content= title)
+ meta(property='og:description' content= summary)
+ meta(property='og:url' content= url)
+ meta(property='og:image' content= user.avatarUrl)
+
+ if note.prev
+ link(rel='prev' href=`${config.url}/notes/${note.prev}`)
+ if note.next
+ link(rel='next' href=`${config.url}/notes/${note.next}`)
diff --git a/src/server/web/views/user.pug b/src/server/web/views/user.pug
new file mode 100644
index 0000000000..b5ea2f6eb4
--- /dev/null
+++ b/src/server/web/views/user.pug
@@ -0,0 +1,20 @@
+extends ../../../../src/client/app/base
+
+block vars
+ - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
+ - const url = config.url + '/@' + (user.host ? `${user.username}@${user.host}` : user.username);
+ - const img = user.avatarId ? `${config.drive_url}/${user.avatarId}` : null;
+
+block title
+ = `${title} | Misskey`
+
+block desc
+ meta(name='description' content= user.description)
+
+block meta
+ meta(name='twitter:card' content='summary')
+ meta(property='og:type' content='blog')
+ meta(property='og:title' content= title)
+ meta(property='og:description' content= user.description)
+ meta(property='og:url' content= url)
+ meta(property='og:image' content= img)
diff --git a/src/server/webfinger.ts b/src/server/webfinger.ts
index e72592351b..ce0cb82fe2 100644
--- a/src/server/webfinger.ts
+++ b/src/server/webfinger.ts
@@ -1,8 +1,9 @@
+import * as mongo from 'mongodb';
import * as Router from 'koa-router';
import config from '../config';
import parseAcct from '../acct/parse';
-import User from '../models/user';
+import User, { IUser } from '../models/user';
// Init router
const router = new Router();
@@ -14,27 +15,38 @@ router.get('/.well-known/webfinger', async ctx => {
}
const resourceLower = ctx.query.resource.toLowerCase();
- const webPrefix = config.url.toLowerCase() + '/@';
let acctLower;
+ let id;
- if (resourceLower.startsWith(webPrefix)) {
- acctLower = resourceLower.slice(webPrefix.length);
+ if (resourceLower.startsWith(config.url.toLowerCase() + '/@')) {
+ acctLower = resourceLower.split('/').pop();
+ } else if (resourceLower.startsWith(config.url.toLowerCase() + '/users/')) {
+ id = new mongo.ObjectID(resourceLower.split('/').pop());
} else if (resourceLower.startsWith('acct:')) {
acctLower = resourceLower.slice('acct:'.length);
} else {
acctLower = resourceLower;
}
- const parsedAcctLower = parseAcct(acctLower);
- if (![null, config.host.toLowerCase()].includes(parsedAcctLower.host)) {
- ctx.status = 422;
- return;
- }
+ let user: IUser;
+
+ if (acctLower) {
+ const parsedAcctLower = parseAcct(acctLower);
+ if (![null, config.host.toLowerCase()].includes(parsedAcctLower.host)) {
+ ctx.status = 422;
+ return;
+ }
- const user = await User.findOne({
- usernameLower: parsedAcctLower.username,
- host: null
- });
+ user = await User.findOne({
+ usernameLower: parsedAcctLower.username,
+ host: null
+ });
+ } else {
+ user = await User.findOne({
+ _id: id,
+ host: null
+ });
+ }
if (user === null) {
ctx.status = 404;
diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts
index 30aae24ba6..efabe345d1 100644
--- a/src/services/drive/add-file.ts
+++ b/src/services/drive/add-file.ts
@@ -10,12 +10,14 @@ import * as debug from 'debug';
import fileType = require('file-type');
import prominence = require('prominence');
-import DriveFile, { IMetadata, getGridFSBucket, IDriveFile } from '../../models/drive-file';
+import DriveFile, { IMetadata, getDriveFileBucket, IDriveFile, DriveFileChunk } from '../../models/drive-file';
import DriveFolder from '../../models/drive-folder';
import { pack } from '../../models/drive-file';
import event, { publishDriveStream } from '../../publishers/stream';
import getAcct from '../../acct/render';
-import { IUser } from '../../models/user';
+import { IUser, isLocalUser, isRemoteUser } from '../../models/user';
+import DriveFileThumbnail, { getDriveFileThumbnailBucket, DriveFileThumbnailChunk } from '../../models/drive-file-thumbnail';
+import genThumbnail from '../../drive/gen-thumbnail';
const gm = _gm.subClass({
imageMagick: true
@@ -30,8 +32,8 @@ const tmpFile = (): Promise<[string, any]> => new Promise((resolve, reject) => {
});
});
-const addToGridFS = (name: string, readable: stream.Readable, type: string, metadata: any): Promise<any> =>
- getGridFSBucket()
+const writeChunks = (name: string, readable: stream.Readable, type: string, metadata: any) =>
+ getDriveFileBucket()
.then(bucket => new Promise((resolve, reject) => {
const writeStream = bucket.openUploadStream(name, { contentType: type, metadata });
writeStream.once('finish', resolve);
@@ -39,6 +41,20 @@ const addToGridFS = (name: string, readable: stream.Readable, type: string, meta
readable.pipe(writeStream);
}));
+const writeThumbnailChunks = (name: string, readable: stream.Readable, originalId) =>
+ getDriveFileThumbnailBucket()
+ .then(bucket => new Promise((resolve, reject) => {
+ const writeStream = bucket.openUploadStream(name, {
+ contentType: 'image/jpeg',
+ metadata: {
+ originalId
+ }
+ });
+ writeStream.once('finish', resolve);
+ writeStream.on('error', reject);
+ readable.pipe(writeStream);
+ }));
+
const addFile = async (
user: IUser,
path: string,
@@ -46,6 +62,7 @@ const addFile = async (
comment: string = null,
folderId: mongodb.ObjectID = null,
force: boolean = false,
+ url: string = null,
uri: string = null
): Promise<IDriveFile> => {
log(`registering ${name} (user: ${getAcct(user)}, path: ${path})`);
@@ -101,7 +118,8 @@ const addFile = async (
// Check if there is a file with the same hash
const much = await DriveFile.findOne({
md5: hash,
- 'metadata.userId': user._id
+ 'metadata.userId': user._id,
+ 'metadata.deletedAt': { $exists: false }
});
if (much !== null) {
@@ -185,7 +203,10 @@ const addFile = async (
// Calculate drive usage
const usage = await DriveFile
.aggregate([{
- $match: { 'metadata.userId': user._id }
+ $match: {
+ 'metadata.userId': user._id,
+ 'metadata.deletedAt': { $exists: false }
+ }
}, {
$project: {
length: true
@@ -207,7 +228,49 @@ const addFile = async (
// If usage limit exceeded
if (usage + size > user.driveCapacity) {
- throw 'no-free-space';
+ if (isLocalUser(user)) {
+ throw 'no-free-space';
+ } else {
+ //#region (アバターまたはバナーを含まず)最も古いファイルを削除する
+ const oldFile = await DriveFile.findOne({
+ _id: {
+ $nin: [user.avatarId, user.bannerId]
+ }
+ }, {
+ sort: {
+ _id: 1
+ }
+ });
+
+ if (oldFile) {
+ // チャンクをすべて削除
+ DriveFileChunk.remove({
+ files_id: oldFile._id
+ });
+
+ DriveFile.update({ _id: oldFile._id }, {
+ $set: {
+ 'metadata.deletedAt': new Date(),
+ 'metadata.isExpired': true
+ }
+ });
+
+ //#region サムネイルもあれば削除
+ const thumbnail = await DriveFileThumbnail.findOne({
+ 'metadata.originalId': oldFile._id
+ });
+
+ if (thumbnail) {
+ DriveFileThumbnailChunk.remove({
+ files_id: thumbnail._id
+ });
+
+ DriveFileThumbnail.remove({ _id: thumbnail._id });
+ }
+ //#endregion
+ }
+ //#endregion
+ }
}
})()
]);
@@ -227,16 +290,34 @@ const addFile = async (
const metadata = {
userId: user._id,
+ _user: {
+ host: user.host
+ },
folderId: folder !== null ? folder._id : null,
comment: comment,
properties: properties
} as IMetadata;
+ if (url !== null) {
+ metadata.url = url;
+ }
+
if (uri !== null) {
metadata.uri = uri;
}
- return addToGridFS(detectedName, readable, mime, metadata);
+ const file = await (writeChunks(detectedName, readable, mime, metadata) as Promise<IDriveFile>);
+
+ try {
+ const thumb = await genThumbnail(file);
+ if (thumb) {
+ await writeThumbnailChunks(detectedName, thumb, file._id);
+ }
+ } catch (e) {
+ // noop
+ }
+
+ return file;
};
/**
diff --git a/src/services/drive/upload-from-url.ts b/src/services/drive/upload-from-url.ts
index 08e0397706..ad2620c036 100644
--- a/src/services/drive/upload-from-url.ts
+++ b/src/services/drive/upload-from-url.ts
@@ -43,7 +43,7 @@ export default async (url, user, folderId = null, uri = null): Promise<IDriveFil
let error;
try {
- driveFile = await create(user, path, name, null, folderId, false, uri);
+ driveFile = await create(user, path, name, null, folderId, false, url, uri);
log(`created: ${driveFile._id}`);
} catch (e) {
error = e;
diff --git a/src/services/following/create.ts b/src/services/following/create.ts
index 375b028912..3424c55dae 100644
--- a/src/services/following/create.ts
+++ b/src/services/following/create.ts
@@ -13,7 +13,18 @@ export default async function(follower: IUser, followee: IUser, activity?) {
const following = await Following.insert({
createdAt: new Date(),
followerId: follower._id,
- followeeId: followee._id
+ followeeId: followee._id,
+ stalk: true,
+
+ // 非正規化
+ _follower: {
+ host: follower.host,
+ inbox: isRemoteUser(follower) ? follower.inbox : undefined
+ },
+ _followee: {
+ host: followee.host,
+ inbox: isRemoteUser(followee) ? followee.inbox : undefined
+ }
});
//#region Increment following count
diff --git a/src/services/note/create.ts b/src/services/note/create.ts
index e35e5ecfbd..f049c34b65 100644
--- a/src/services/note/create.ts
+++ b/src/services/note/create.ts
@@ -1,6 +1,6 @@
import Note, { pack, INote } from '../../models/note';
-import User, { isLocalUser, IUser, isRemoteUser } from '../../models/user';
-import stream, { publishLocalTimelineStream, publishGlobalTimelineStream } from '../../publishers/stream';
+import User, { isLocalUser, IUser, isRemoteUser, IRemoteUser, ILocalUser } from '../../models/user';
+import stream, { publishLocalTimelineStream, publishGlobalTimelineStream, publishUserListStream } from '../../publishers/stream';
import Following from '../../models/following';
import { deliver } from '../../queue';
import renderNote from '../../remote/activitypub/renderer/note';
@@ -15,8 +15,64 @@ import Mute from '../../models/mute';
import pushSw from '../../publishers/push-sw';
import event from '../../publishers/stream';
import parse from '../../text/parse';
-import html from '../../text/html';
import { IApp } from '../../models/app';
+import UserList from '../../models/user-list';
+import resolveUser from '../../remote/resolve-user';
+
+type Reason = 'reply' | 'quote' | 'mention';
+
+/**
+ * ServiceWorkerへの通知を担当
+ */
+class NotificationManager {
+ private user: IUser;
+ private note: any;
+ private list: Array<{
+ user: ILocalUser['_id'],
+ reason: Reason;
+ }> = [];
+
+ constructor(user, note) {
+ this.user = user;
+ this.note = note;
+ }
+
+ public push(user: ILocalUser['_id'], reason: Reason) {
+ // 自分自身へは通知しない
+ if (this.user._id.equals(user)) return;
+
+ const exist = this.list.find(x => x.user.equals(user));
+
+ if (exist) {
+ // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする
+ if (reason != 'mention') {
+ exist.reason = reason;
+ }
+ } else {
+ this.list.push({
+ user, reason
+ });
+ }
+ }
+
+ public deliver() {
+ this.list.forEach(async x => {
+ const mentionee = x.user;
+
+ // ミュート情報を取得
+ const mentioneeMutes = await Mute.find({
+ muterId: mentionee
+ });
+
+ const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId.toString());
+
+ // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
+ if (!mentioneesMutedUserIds.includes(this.user._id.toString())) {
+ pushSw(mentionee, x.reason, this.note);
+ }
+ });
+ }
+}
export default async (user: IUser, data: {
createdAt?: Date;
@@ -30,6 +86,7 @@ export default async (user: IUser, data: {
tags?: string[];
cw?: string;
visibility?: string;
+ visibleUsers?: IUser[];
uri?: string;
app?: IApp;
}, silent = false) => new Promise<INote>(async (res, rej) => {
@@ -39,7 +96,7 @@ export default async (user: IUser, data: {
const tags = data.tags || [];
- let tokens = null;
+ let tokens: any[] = null;
if (data.text) {
// Analyze
@@ -57,21 +114,29 @@ export default async (user: IUser, data: {
});
}
+ if (data.visibleUsers) {
+ data.visibleUsers = data.visibleUsers.filter(x => x != null);
+ }
+
const insert: any = {
createdAt: data.createdAt,
mediaIds: data.media ? data.media.map(file => file._id) : [],
replyId: data.reply ? data.reply._id : null,
renoteId: data.renote ? data.renote._id : null,
text: data.text,
- textHtml: tokens === null ? null : html(tokens),
poll: data.poll,
- cw: data.cw,
+ cw: data.cw == null ? null : data.cw,
tags,
userId: user._id,
viaMobile: data.viaMobile,
geo: data.geo || null,
appId: data.app ? data.app._id : null,
visibility: data.visibility,
+ visibleUserIds: data.visibility == 'specified'
+ ? data.visibleUsers
+ ? data.visibleUsers.map(u => u._id)
+ : []
+ : [],
// 以下非正規化データ
_reply: data.reply ? { userId: data.reply.userId } : null,
@@ -85,143 +150,167 @@ export default async (user: IUser, data: {
if (data.uri != null) insert.uri = data.uri;
// 投稿を作成
- const note = await Note.insert(insert);
+ let note: INote;
+ try {
+ note = await Note.insert(insert);
+ } catch (e) {
+ // duplicate key error
+ if (e.code === 11000) {
+ return res(null);
+ }
+
+ console.error(e);
+ return rej('something happened');
+ }
res(note);
+ // Increment notes count
User.update({ _id: user._id }, {
- // Increment notes count
$inc: {
notesCount: 1
- },
- // Update latest note
- $set: {
- latestNote: note
}
});
// Serialize
const noteObj = await pack(note);
- // タイムラインへの投稿
- if (note.channelId == null) {
+ const nm = new NotificationManager(user, noteObj);
+
+ const render = async () => {
+ const content = data.renote && data.text == null
+ ? renderAnnounce(data.renote.uri ? data.renote.uri : await renderNote(data.renote))
+ : renderCreate(await renderNote(note));
+ return packAp(content);
+ };
+
+ if (!silent) {
if (isLocalUser(user)) {
- // Publish event to myself's stream
- stream(note.userId, 'note', noteObj);
+ if (note.visibility == 'private' || note.visibility == 'followers' || note.visibility == 'specified') {
+ // Publish event to myself's stream
+ stream(note.userId, 'note', await pack(note, user, {
+ detail: true
+ }));
+ } else {
+ // Publish event to myself's stream
+ stream(note.userId, 'note', noteObj);
- // Publish note to local timeline stream
- publishLocalTimelineStream(noteObj);
+ // Publish note to local timeline stream
+ if (note.visibility != 'home') {
+ publishLocalTimelineStream(noteObj);
+ }
+ }
}
// Publish note to global timeline stream
publishGlobalTimelineStream(noteObj);
- // Fetch all followers
- const followers = await Following.aggregate([{
- $lookup: {
- from: 'users',
- localField: 'followerId',
- foreignField: '_id',
- as: 'user'
- }
- }, {
- $match: {
+ if (note.visibility == 'specified') {
+ data.visibleUsers.forEach(async u => {
+ stream(u._id, 'note', await pack(note, u, {
+ detail: true
+ }));
+ });
+ }
+
+ if (note.visibility == 'public' || note.visibility == 'home' || note.visibility == 'followers') {
+ // フォロワーに配信
+ Following.find({
followeeId: note.userId
- }
- }], {
- _id: false
- });
+ }).then(followers => {
+ followers.map(async following => {
+ const follower = following._follower;
- if (!silent) {
- const render = async () => {
- const content = data.renote && data.text == null
- ? renderAnnounce(data.renote.uri ? data.renote.uri : await renderNote(data.renote))
- : renderCreate(await renderNote(note));
- return packAp(content);
- };
+ if (isLocalUser(follower)) {
+ // ストーキングしていない場合
+ if (!following.stalk) {
+ // この投稿が返信ならスキップ
+ if (note.replyId && !note._reply.userId.equals(following.followerId) && !note._reply.userId.equals(note.userId)) return;
+ }
- // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送
- if (data.reply && isLocalUser(user) && isRemoteUser(data.reply._user)) {
- deliver(user, await render(), data.reply._user.inbox);
- }
+ // Publish event to followers stream
+ stream(following.followerId, 'note', noteObj);
+ } else {
+ //#region AP配送
+ // フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信
+ if (isLocalUser(user)) {
+ deliver(user, await render(), follower.inbox);
+ }
+ //#endergion
+ }
+ });
+ });
+ }
- // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送
- if (data.renote && isLocalUser(user) && isRemoteUser(data.renote._user)) {
- deliver(user, await render(), data.renote._user.inbox);
- }
+ // リストに配信
+ UserList.find({
+ userIds: note.userId
+ }).then(lists => {
+ lists.forEach(list => {
+ publishUserListStream(list._id, 'note', noteObj);
+ });
+ });
+ }
- Promise.all(followers.map(async follower => {
- follower = follower.user[0];
+ //#region リプライとAnnounceのAP配送
- if (isLocalUser(follower)) {
- // Publish event to followers stream
- stream(follower._id, 'note', noteObj);
- } else {
- // フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信
- if (isLocalUser(user)) {
- deliver(user, await render(), follower.inbox);
- }
- }
- }));
- }
+ // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送
+ if (data.reply && isLocalUser(user) && isRemoteUser(data.reply._user)) {
+ deliver(user, await render(), data.reply._user.inbox);
}
- // チャンネルへの投稿
- /* TODO
- if (note.channelId) {
- promises.push(
- // Increment channel index(notes count)
- Channel.update({ _id: note.channelId }, {
- $inc: {
- index: 1
- }
- }),
+ // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送
+ if (data.renote && isLocalUser(user) && isRemoteUser(data.renote._user)) {
+ deliver(user, await render(), data.renote._user.inbox);
+ }
+ //#endergion
- // Publish event to channel
- promisedNoteObj.then(noteObj => {
- publishChannelStream(note.channelId, 'note', noteObj);
- }),
+ //#region メンション
+ if (data.text) {
+ // TODO: Drop dupulicates
+ const mentions = tokens
+ .filter(t => t.type == 'mention');
- Promise.all([
- promisedNoteObj,
+ let mentionedUsers = await Promise.all(mentions.map(async m => {
+ try {
+ return await resolveUser(m.username, m.host);
+ } catch (e) {
+ return null;
+ }
+ }));
- // Get channel watchers
- ChannelWatching.find({
- channelId: note.channelId,
- // 削除されたドキュメントは除く
- deletedAt: { $exists: false }
- })
- ]).then(([noteObj, watches]) => {
- // チャンネルの視聴者(のタイムライン)に配信
- watches.forEach(w => {
- stream(w.userId, 'note', noteObj);
- });
- })
- );
- }*/
+ // TODO: Drop dupulicates
+ mentionedUsers = mentionedUsers.filter(x => x != null);
- const mentions = [];
+ mentionedUsers.filter(u => isLocalUser(u)).forEach(async u => {
+ // 既に言及されたユーザーに対する返信や引用renoteの場合も無視
+ if (data.reply && data.reply.userId.equals(u._id)) return;
+ if (data.renote && data.renote.userId.equals(u._id)) return;
- async function addMention(mentionee, reason) {
- // Reject if already added
- if (mentions.some(x => x.equals(mentionee))) return;
+ // Create notification
+ notify(u._id, user._id, 'mention', {
+ noteId: note._id
+ });
- // Add mention
- mentions.push(mentionee);
+ nm.push(u._id, 'mention');
+ });
- // Publish event
- if (!user._id.equals(mentionee)) {
- const mentioneeMutes = await Mute.find({
- muter_id: mentionee,
- deleted_at: { $exists: false }
+ if (isLocalUser(user)) {
+ mentionedUsers.filter(u => isRemoteUser(u)).forEach(async u => {
+ deliver(user, await render(), (u as IRemoteUser).inbox);
+ });
+ }
+
+ // Append mentions data
+ if (mentionedUsers.length > 0) {
+ Note.update({ _id: note._id }, {
+ $set: {
+ mentions: mentionedUsers.map(u => u._id)
+ }
});
- const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId.toString());
- if (mentioneesMutedUserIds.indexOf(user._id.toString()) == -1) {
- event(mentionee, reason, noteObj);
- pushSw(mentionee, reason, noteObj);
- }
}
}
+ //#endregion
// If has in reply to note
if (data.reply) {
@@ -260,8 +349,7 @@ export default async (user: IUser, data: {
watch(user._id, data.reply);
}
- // Add mention
- addMention(data.reply.userId, 'reply');
+ nm.push(data.reply.userId, 'reply');
}
// If it is renote
@@ -296,7 +384,7 @@ export default async (user: IUser, data: {
// If it is quote renote
if (data.text) {
// Add mention
- addMention(data.renote.userId, 'quote');
+ nm.push(data.renote.userId, 'quote');
} else {
// Publish event
if (!user._id.equals(data.renote.userId)) {
@@ -304,14 +392,17 @@ export default async (user: IUser, data: {
}
}
+ //#region TODO: これ重い
// 今までで同じ投稿をRenoteしているか
- const existRenote = await Note.findOne({
- userId: user._id,
- renoteId: data.renote._id,
- _id: {
- $ne: note._id
- }
- });
+ //const existRenote = await Note.findOne({
+ // userId: user._id,
+ // renoteId: data.renote._id,
+ // _id: {
+ // $ne: note._id
+ // }
+ //});
+ const existRenote = null;
+ //#endregion
if (!existRenote) {
// Update renoteee status
@@ -322,48 +413,4 @@ export default async (user: IUser, data: {
});
}
}
-
- // If has text content
- if (data.text) {
- // 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({
- usernameLower: mention.toLowerCase()
- }, { _id: true });
-
- // When mentioned user not found
- if (mentionee == null) return;
-
- // 既に言及されたユーザーに対する返信や引用renoteの場合も無視
- if (data.reply && data.reply.userId.equals(mentionee._id)) return;
- if (data.renote && data.renote.userId.equals(mentionee._id)) return;
-
- // Add mention
- addMention(mentionee._id, 'mention');
-
- // Create notification
- notify(mentionee._id, user._id, 'mention', {
- noteId: note._id
- });
- }));
- }
-
- // Append mentions data
- if (mentions.length > 0) {
- Note.update({ _id: note._id }, {
- $set: {
- mentions
- }
- });
- }
});
diff --git a/src/services/note/reaction/create.ts b/src/services/note/reaction/create.ts
index dd3d4be8b7..123c091c85 100644
--- a/src/services/note/reaction/create.ts
+++ b/src/services/note/reaction/create.ts
@@ -9,7 +9,6 @@ import watch from '../watch';
import renderLike from '../../../remote/activitypub/renderer/like';
import { deliver } from '../../../queue';
import pack from '../../../remote/activitypub/renderer';
-import { MongoError } from 'mongodb';
export default async (user: IUser, note: INote, reaction: string) => new Promise(async (res, rej) => {
// Myself
@@ -27,8 +26,8 @@ export default async (user: IUser, note: INote, reaction: string) => new Promise
});
} catch (e) {
// duplicate key error
- if (e instanceof MongoError && e.code === 11000) {
- return rej('already reacted');
+ if (e.code === 11000) {
+ return res(null);
}
console.error(e);
@@ -47,11 +46,13 @@ export default async (user: IUser, note: INote, reaction: string) => new Promise
publishNoteStream(note._id, 'reacted');
- // Notify
- notify(note.userId, user._id, 'reaction', {
- noteId: note._id,
- reaction: reaction
- });
+ // リアクションされたユーザーがローカルユーザーなら通知を作成
+ if (isLocalUser(note._user)) {
+ notify(note.userId, user._id, 'reaction', {
+ noteId: note._id,
+ reaction: reaction
+ });
+ }
pushSw(note.userId, 'reaction', {
user: await packUser(user, note.userId),
@@ -86,7 +87,7 @@ export default async (user: IUser, note: INote, reaction: string) => new Promise
//#region 配信
// リアクターがローカルユーザーかつリアクション対象がリモートユーザーの投稿なら配送
if (isLocalUser(user) && isRemoteUser(note._user)) {
- const content = pack(renderLike(user, note));
+ const content = pack(renderLike(user, note, reaction));
deliver(user, content, note._user.inbox);
}
//#endregion
diff --git a/src/text/html.ts b/src/text/html.ts
index 797f3b3f33..f33ef4997b 100644
--- a/src/text/html.ts
+++ b/src/text/html.ts
@@ -54,6 +54,12 @@ const handlers = {
document.body.appendChild(blockquote);
},
+ title({ document }, { content }) {
+ const h1 = document.createElement('h1');
+ h1.textContent = content;
+ document.body.appendChild(h1);
+ },
+
text({ document }, { content }) {
for (const text of content.split('\n')) {
const node = document.createTextNode(text);
@@ -69,6 +75,13 @@ const handlers = {
a.href = url;
a.textContent = url;
document.body.appendChild(a);
+ },
+
+ search({ document }, { content, query }) {
+ const a = document.createElement('a');
+ a.href = `https://www.google.com/?#q=${query}`;
+ a.textContent = content;
+ document.body.appendChild(a);
}
};
diff --git a/src/text/parse/elements/search.ts b/src/text/parse/elements/search.ts
new file mode 100644
index 0000000000..12ee8ecbb8
--- /dev/null
+++ b/src/text/parse/elements/search.ts
@@ -0,0 +1,13 @@
+/**
+ * Search
+ */
+
+module.exports = text => {
+ const match = text.match(/^(.+?) 検索(\n|$)/);
+ if (!match) return null;
+ return {
+ type: 'search',
+ content: match[0],
+ query: match[1]
+ };
+};
diff --git a/src/text/parse/elements/title.ts b/src/text/parse/elements/title.ts
new file mode 100644
index 0000000000..9f4708f5d6
--- /dev/null
+++ b/src/text/parse/elements/title.ts
@@ -0,0 +1,14 @@
+/**
+ * Title
+ */
+
+module.exports = text => {
+ const match = text.match(/^【(.+?)】\n/);
+ if (!match) return null;
+ const title = match[0];
+ return {
+ type: 'title',
+ content: title,
+ title: title.substr(1, title.length - 3)
+ };
+};
diff --git a/src/text/parse/index.ts b/src/text/parse/index.ts
index b958da81b0..cfddd9f615 100644
--- a/src/text/parse/index.ts
+++ b/src/text/parse/index.ts
@@ -4,6 +4,7 @@
const elements = [
require('./elements/bold'),
+ require('./elements/title'),
require('./elements/url'),
require('./elements/link'),
require('./elements/mention'),
@@ -11,7 +12,8 @@ const elements = [
require('./elements/code'),
require('./elements/inline-code'),
require('./elements/quote'),
- require('./elements/emoji')
+ require('./elements/emoji'),
+ require('./elements/search')
];
export default (source: string): any[] => {
diff --git a/src/version.ts b/src/version.ts
deleted file mode 100644
index d379b57f8f..0000000000
--- a/src/version.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * Version
- */
-
-const meta = require('../version.json');
-
-export default meta.version as string;
diff --git a/test/api.ts b/test/api.ts
index 87bbb8ee16..d8c163e920 100644
--- a/test/api.ts
+++ b/test/api.ts
@@ -15,11 +15,10 @@ process.on('unhandledRejection', console.dir);
const fs = require('fs');
const _chai = require('chai');
const chaiHttp = require('chai-http');
-const should = _chai.should();
_chai.use(chaiHttp);
-const server = require('../built/server/api');
+const server = require('../built/server/api').callback();
const db = require('../built/db/mongodb').default;
const async = fn => (done) => {
@@ -56,16 +55,6 @@ describe('API', () => {
db.get('authSessions').drop()
]));
- it('greet server', done => {
- _chai.request(server)
- .get('/')
- .end((err, res) => {
- res.should.have.status(200);
- res.text.should.be.equal('YEE HAW');
- done();
- });
- });
-
describe('signup', () => {
it('不正なユーザー名でアカウントが作成できない', async(async () => {
const res = await request('/signup', {
diff --git a/tslint.json b/tslint.json
index d3f96000b9..ae0df46b96 100644
--- a/tslint.json
+++ b/tslint.json
@@ -5,6 +5,7 @@
],
"jsRules": {},
"rules": {
+ "align": false,
"indent": ["tab"],
"quotemark": ["single"],
"no-var-requires": false,
diff --git a/webpack.config.ts b/webpack.config.ts
index 50e28972ac..0e12fda633 100644
--- a/webpack.config.ts
+++ b/webpack.config.ts
@@ -5,6 +5,7 @@
import * as fs from 'fs';
import * as webpack from 'webpack';
import chalk from 'chalk';
+const { VueLoaderPlugin } = require('vue-loader');
import jsonImporter from 'node-sass-json-importer';
const minifyHtml = require('html-minifier').minify;
const WebpackOnBuildPlugin = require('on-build-webpack');
@@ -19,7 +20,7 @@ import { licenseHtml } from './src/build/license';
import locales from './locales';
const meta = require('./package.json');
-const version = meta.version;
+const version = meta.clientVersion;
const codename = meta.codename;
//#region Replacer definitions
@@ -114,10 +115,11 @@ module.exports = entries.map(x => {
'process.env.NODE_ENV': JSON.stringify(isProduction ? 'production' : 'development')
}),
new WebpackOnBuildPlugin(stats => {
- fs.writeFileSync('./version.json', JSON.stringify({
+ fs.writeFileSync('./built/client/meta.json', JSON.stringify({
version
}), 'utf-8');
- })
+ }),
+ new VueLoaderPlugin()
];
if (isProduction) {
@@ -135,7 +137,9 @@ module.exports = entries.map(x => {
loader: 'vue-loader',
options: {
cssSourceMap: false,
- preserveWhitespace: false
+ compilerOptions: {
+ preserveWhitespace: false
+ }
}
}, {
loader: 'replace',
@@ -165,17 +169,32 @@ module.exports = entries.map(x => {
}
}]
}, {
- test: /\.styl$/,
+ test: /\.styl(us)?$/,
exclude: /node_modules/,
- use: [{
- loader: 'style-loader'
+ oneOf: [{
+ resourceQuery: /module/,
+ use: [{
+ loader: 'vue-style-loader'
+ }, {
+ loader: 'css-loader',
+ options: {
+ modules: true,
+ minimize: true
+ }
+ }, {
+ loader: 'stylus-loader'
+ }]
}, {
- loader: 'css-loader',
- options: {
- minimize: true
- }
- }, {
- loader: 'stylus-loader'
+ use: [{
+ loader: 'vue-style-loader'
+ }, {
+ loader: 'css-loader',
+ options: {
+ minimize: true
+ }
+ }, {
+ loader: 'stylus-loader'
+ }]
}]
}, {
test: /\.scss$/,
@@ -196,7 +215,7 @@ module.exports = entries.map(x => {
}, {
test: /\.css$/,
use: [{
- loader: 'style-loader'
+ loader: 'vue-style-loader'
}, {
loader: 'css-loader',
options: {