summaryrefslogtreecommitdiff
path: root/src/web/app/mobile
diff options
context:
space:
mode:
authorsyuilo <syuilotan@yahoo.co.jp>2016-12-29 07:49:51 +0900
committersyuilo <syuilotan@yahoo.co.jp>2016-12-29 07:49:51 +0900
commitb3f42e62af698a67c2250533c437569559f1fdf9 (patch)
treecdf6937576e99cccf85e6fa3aa8860a1173c7cfb /src/web/app/mobile
downloadsharkey-b3f42e62af698a67c2250533c437569559f1fdf9.tar.gz
sharkey-b3f42e62af698a67c2250533c437569559f1fdf9.tar.bz2
sharkey-b3f42e62af698a67c2250533c437569559f1fdf9.zip
Initial commit :four_leaf_clover:
Diffstat (limited to 'src/web/app/mobile')
-rw-r--r--src/web/app/mobile/mixins.ls19
-rw-r--r--src/web/app/mobile/router.ls110
-rw-r--r--src/web/app/mobile/script.js20
-rw-r--r--src/web/app/mobile/scripts/sp-slidemenu.js839
-rw-r--r--src/web/app/mobile/scripts/stream.ls13
-rw-r--r--src/web/app/mobile/scripts/ui.ls6
-rw-r--r--src/web/app/mobile/style.styl12
-rw-r--r--src/web/app/mobile/tags.ls44
-rw-r--r--src/web/app/mobile/tags/drive-selector.tag75
-rw-r--r--src/web/app/mobile/tags/drive.tag338
-rw-r--r--src/web/app/mobile/tags/drive/file-viewer.tag8
-rw-r--r--src/web/app/mobile/tags/drive/file.tag130
-rw-r--r--src/web/app/mobile/tags/drive/folder.tag45
-rw-r--r--src/web/app/mobile/tags/follow-button.tag108
-rw-r--r--src/web/app/mobile/tags/home-timeline.tag40
-rw-r--r--src/web/app/mobile/tags/home.tag17
-rw-r--r--src/web/app/mobile/tags/images-viewer.tag25
-rw-r--r--src/web/app/mobile/tags/notification-preview.tag117
-rw-r--r--src/web/app/mobile/tags/notification.tag142
-rw-r--r--src/web/app/mobile/tags/notifications.tag98
-rw-r--r--src/web/app/mobile/tags/notify.tag35
-rw-r--r--src/web/app/mobile/tags/page/drive.tag46
-rw-r--r--src/web/app/mobile/tags/page/entrance.tag57
-rw-r--r--src/web/app/mobile/tags/page/entrance/signin.tag45
-rw-r--r--src/web/app/mobile/tags/page/entrance/signup.tag35
-rw-r--r--src/web/app/mobile/tags/page/home.tag40
-rw-r--r--src/web/app/mobile/tags/page/new-post.tag5
-rw-r--r--src/web/app/mobile/tags/page/notifications.tag18
-rw-r--r--src/web/app/mobile/tags/page/post.tag31
-rw-r--r--src/web/app/mobile/tags/page/search.tag19
-rw-r--r--src/web/app/mobile/tags/page/user-followers.tag31
-rw-r--r--src/web/app/mobile/tags/page/user-following.tag31
-rw-r--r--src/web/app/mobile/tags/page/user.tag20
-rw-r--r--src/web/app/mobile/tags/post-detail.tag415
-rw-r--r--src/web/app/mobile/tags/post-form.tag254
-rw-r--r--src/web/app/mobile/tags/post-preview.tag89
-rw-r--r--src/web/app/mobile/tags/search-posts.tag29
-rw-r--r--src/web/app/mobile/tags/search.tag12
-rw-r--r--src/web/app/mobile/tags/stream-indicator.tag59
-rw-r--r--src/web/app/mobile/tags/sub-post-content.tag36
-rw-r--r--src/web/app/mobile/tags/timeline-post-sub.tag99
-rw-r--r--src/web/app/mobile/tags/timeline-post.tag296
-rw-r--r--src/web/app/mobile/tags/timeline.tag128
-rw-r--r--src/web/app/mobile/tags/ui-header.tag98
-rw-r--r--src/web/app/mobile/tags/ui-nav.tag169
-rw-r--r--src/web/app/mobile/tags/ui.tag50
-rw-r--r--src/web/app/mobile/tags/user-followers.tag22
-rw-r--r--src/web/app/mobile/tags/user-following.tag22
-rw-r--r--src/web/app/mobile/tags/user-preview.tag103
-rw-r--r--src/web/app/mobile/tags/user-timeline.tag28
-rw-r--r--src/web/app/mobile/tags/user.tag198
-rw-r--r--src/web/app/mobile/tags/users-list.tag125
52 files changed, 4851 insertions, 0 deletions
diff --git a/src/web/app/mobile/mixins.ls b/src/web/app/mobile/mixins.ls
new file mode 100644
index 0000000000..902774f91a
--- /dev/null
+++ b/src/web/app/mobile/mixins.ls
@@ -0,0 +1,19 @@
+riot = require \riot
+
+module.exports = (me) ~>
+ if me?
+ (require './scripts/stream.ls') me
+
+ require './scripts/ui.ls'
+
+ riot.mixin \open-post-form do
+ open-post-form: (opts) ->
+ app = document.get-element-by-id \app
+ app.style.display = \none
+ form = document.body.append-child document.create-element \mk-post-form
+ form = riot.mount form, opts .0
+ form.on \cancel recover
+ form.on \post recover
+
+ function recover
+ app.style.display = \block
diff --git a/src/web/app/mobile/router.ls b/src/web/app/mobile/router.ls
new file mode 100644
index 0000000000..33ae3e82da
--- /dev/null
+++ b/src/web/app/mobile/router.ls
@@ -0,0 +1,110 @@
+# Router
+#================================
+
+riot = require \riot
+route = require \page
+page = null
+
+module.exports = (me) ~>
+
+ # Routing
+ #--------------------------------
+
+ route \/ index
+ route \/i/notifications notifications
+ route \/i/drive drive
+ route \/i/drive/folder/:folder drive
+ route \/i/drive/file/:file drive
+ route \/post/new new-post
+ route \/post::post post
+ route \/search::query search
+ route \/:user user.bind null \posts
+ route \/:user/graphs user.bind null \graphs
+ route \/:user/followers user-followers
+ route \/:user/following user-following
+ route \/:user/:post post
+ route \* not-found
+
+ # Handlers
+ #--------------------------------
+
+ # /
+ function index
+ if me? then home! else entrance!
+
+ # ホーム
+ function home
+ mount document.create-element \mk-home-page
+
+ # 玄関
+ function entrance
+ mount document.create-element \mk-entrance
+
+ # 通知
+ function notifications
+ mount document.create-element \mk-notifications-page
+
+ # 新規投稿
+ function new-post
+ mount document.create-element \mk-new-post-page
+
+ # 検索
+ function search ctx
+ document.create-element \mk-search-page
+ ..set-attribute \query ctx.params.query
+ .. |> mount
+
+ # ユーザー
+ function user page, ctx
+ document.create-element \mk-user-page
+ ..set-attribute \user ctx.params.user
+ ..set-attribute \page page
+ .. |> mount
+
+ # フォロー一覧
+ function user-following ctx
+ document.create-element \mk-user-following-page
+ ..set-attribute \user ctx.params.user
+ .. |> mount
+
+ # フォロワー一覧
+ function user-followers ctx
+ document.create-element \mk-user-followers-page
+ ..set-attribute \user ctx.params.user
+ .. |> mount
+
+ # 投稿詳細ページ
+ function post ctx
+ document.create-element \mk-post-page
+ ..set-attribute \post ctx.params.post
+ .. |> mount
+
+ # ドライブ
+ function drive ctx
+ p = document.create-element \mk-drive-page
+ if ctx.params.folder then p.set-attribute \folder ctx.params.folder
+ if ctx.params.file then p.set-attribute \file ctx.params.file
+ mount p
+
+ # not found
+ function not-found
+ mount document.create-element \mk-not-found
+
+ # Register mixin
+ #--------------------------------
+
+ riot.mixin \page do
+ page: route
+
+ # Exec
+ #--------------------------------
+
+ route!
+
+# Mount
+#================================
+
+function mount content
+ if page? then page.unmount!
+ body = document.get-element-by-id \app
+ page := riot.mount body.append-child content .0
diff --git a/src/web/app/mobile/script.js b/src/web/app/mobile/script.js
new file mode 100644
index 0000000000..1c269a57d9
--- /dev/null
+++ b/src/web/app/mobile/script.js
@@ -0,0 +1,20 @@
+/**
+ * Mobile Client
+ */
+
+require('./tags.ls');
+require('./scripts/sp-slidemenu.js');
+const boot = require('../boot.ls');
+const mixins = require('./mixins.ls');
+const route = require('./router.ls');
+
+/**
+ * Boot
+ */
+boot(me => {
+ // Register mixins
+ mixins(me);
+
+ // Start routing
+ route(me);
+});
diff --git a/src/web/app/mobile/scripts/sp-slidemenu.js b/src/web/app/mobile/scripts/sp-slidemenu.js
new file mode 100644
index 0000000000..f2dcae9cef
--- /dev/null
+++ b/src/web/app/mobile/scripts/sp-slidemenu.js
@@ -0,0 +1,839 @@
+/**
+ * sp-slidemenu.js
+ *
+ * @version 0.1.0
+ * @url https://github.com/be-hase/sp-slidemenu
+ *
+ * Copyright 2013 be-hase.com, Inc.
+ * Licensed under the MIT License:
+ * http://www.opensource.org/licenses/mit-license.php
+ */
+
+/**
+ * CUSTOMIZED BY SYUILO
+ */
+
+; (function(window, document, undefined) {
+ "use strict";
+ var div, PREFIX, support, gestureStart, EVENTS, ANIME_SPEED, SLIDE_STATUS, SCROLL_STATUS, THRESHOLD, EVENT_MOE_TIME, rclass, ITEM_CLICK_CLASS_NAME;
+ div = document.createElement('div');
+ PREFIX = ['webkit', 'moz', 'o', 'ms'];
+ support = SpSlidemenu.support = {};
+ support.transform3d = hasProp([
+ 'perspectiveProperty',
+ 'WebkitPerspective',
+ 'MozPerspective',
+ 'OPerspective',
+ 'msPerspective'
+ ]);
+ support.transform = hasProp([
+ 'transformProperty',
+ 'WebkitTransform',
+ 'MozTransform',
+ 'OTransform',
+ 'msTransform'
+ ]);
+ support.transition = hasProp([
+ 'transitionProperty',
+ 'WebkitTransitionProperty',
+ 'MozTransitionProperty',
+ 'OTransitionProperty',
+ 'msTransitionProperty'
+ ]);
+ support.addEventListener = 'addEventListener' in window;
+ support.msPointer = window.navigator.msPointerEnabled;
+ support.cssAnimation = (support.transform3d || support.transform) && support.transition;
+ support.touch = 'ontouchend' in window;
+ EVENTS = {
+ start: {
+ touch: 'touchstart',
+ mouse: 'mousedown'
+ },
+ move: {
+ touch: 'touchmove',
+ mouse: 'mousemove'
+ },
+ end: {
+ touch: 'touchend',
+ mouse: 'mouseup'
+ }
+ };
+ gestureStart = false;
+ if (support.addEventListener) {
+ document.addEventListener('gesturestart', function() {
+ gestureStart = true;
+ });
+ document.addEventListener('gestureend', function() {
+ gestureStart = false;
+ });
+ }
+ ANIME_SPEED = {
+ slider: 200,
+ scrollOverBack: 400
+ };
+ SLIDE_STATUS = {
+ close: 0,
+ open: 1,
+ progress: 2
+ };
+ THRESHOLD = 10;
+ EVENT_MOE_TIME = 50;
+ rclass = /[\t\r\n\f]/g;
+ ITEM_CLICK_CLASS_NAME = 'menu-item';
+ /*
+ [MEMO]
+ SpSlidemenu properties which is not function is ...
+ -- element --
+ element: main
+ element: slidemenu
+ element: button
+ element: slidemenuBody
+ element: slidemenuContent
+ element: slidemenuHeader
+ -- options --
+ bool: disableCssAnimation
+ bool: disabled3d
+ -- animation --
+ bool: useCssAnimation
+ bool: use3d
+ -- slide --
+ int: slideWidth
+ string: htmlOverflowX
+ string: bodyOverflowX
+ int: buttonStartPageX
+ int: buttonStartPageY
+ -- scroll --
+ bool: scrollTouchStarted
+ bool: scrollMoveReady
+ int: scrollStartPageX
+ int: scrollStartPageY
+ int: scrollBasePageY
+ int: scrollTimeForVelocity
+ int: scrollCurrentY
+ int: scrollMoveEventCnt
+ int: scrollAnimationTimer
+ int: scrollOverTimer
+ int: scrollMaxY
+ */
+ function SpSlidemenu(main, slidemenu, button, options) {
+ if (this instanceof SpSlidemenu) {
+ return this.init(main, slidemenu, button, options);
+ } else {
+ return new SpSlidemenu(main, slidemenu, button, options);
+ }
+ }
+ SpSlidemenu.prototype.init = function(main, slidemenu, button, options) {
+ var _this = this;
+ // find and set element.
+ _this.setElement(main, slidemenu, button);
+ if (!_this.main || !_this.slidemenu || !_this.button || !_this.slidemenuBody || !_this.slidemenuContent) {
+ throw new Error('Element not found. Please set correctly.');
+ }
+ // options
+ options = options || {};
+ _this.disableCssAnimation = (options.disableCssAnimation === undefined) ? false : options.disableCssAnimation;
+ _this.disable3d = (options.disable3d === undefined) ? false : options.disable3d;
+ _this.direction = 'left';
+ if (options.direction === 'right') {
+ _this.direction = 'right';
+ }
+ // animation
+ _this.useCssAnimation = support.cssAnimation;
+ if (_this.disableCssAnimation === true) {
+ _this.useCssAnimation = false;
+ }
+ _this.use3d = support.transform3d;
+ if (_this.disable3d === true) {
+ _this.use3d = false;
+ }
+ // slide
+ _this.slideWidth = (getDimentions(_this.slidemenu)).width;
+ _this.main.SpSlidemenuStatus = SLIDE_STATUS.close;
+ _this.htmlOverflowX = '';
+ _this.bodyOverflowX = '';
+ // scroll
+ _this.scrollCurrentY = 0;
+ _this.scrollAnimationTimer = false;
+ _this.scrollOverTimer = false;
+ // set default style.
+ _this.setDefaultStyle();
+ // bind some method for callback.
+ _this.bindMethods();
+ // add event
+ addTouchEvent('start', _this.button, _this.buttonTouchStart, false);
+ addTouchEvent('move', _this.button, blockEvent, false);
+ addTouchEvent('end', _this.button, _this.buttonTouchEnd, false);
+ addTouchEvent('start', _this.slidemenuContent, _this.scrollTouchStart, false);
+ addTouchEvent('move', _this.slidemenuContent, _this.scrollTouchMove, false);
+ addTouchEvent('end', _this.slidemenuContent, _this.scrollTouchEnd, false);
+ _this.slidemenuContent.addEventListener('click', _this.itemClick, false);
+ // window size change
+ window.addEventListener('resize', debounce(_this.setHeight, 100), false);
+ return _this;
+ };
+ SpSlidemenu.prototype.bindMethods = function() {
+ var _this, funcs;
+ _this = this;
+ funcs = [
+ 'setHeight',
+ 'slideOpen', 'slideOpenEnd', 'slideClose', 'slideCloseEnd',
+ 'buttonTouchStart', 'buttonTouchEnd', 'mainTouchStart',
+ 'scrollTouchStart', 'scrollTouchMove', 'scrollTouchEnd', 'scrollInertiaMove', 'scrollOverBack', 'scrollOver',
+ 'itemClick'
+ ];
+ funcs.forEach(function(func) {
+ _this[func] = bind(_this[func], _this);
+ });
+ };
+ SpSlidemenu.prototype.setElement = function(main, slidemenu, button) {
+ var _this = this;
+ _this.main = main;
+ if (typeof main === 'string') {
+ _this.main = document.querySelector(main);
+ }
+ _this.slidemenu = slidemenu;
+ if (typeof slidemenu === 'string') {
+ _this.slidemenu = document.querySelector(slidemenu);
+ }
+ _this.button = button;
+ if (typeof button === 'string') {
+ _this.button = document.querySelector(button);
+ }
+ if (!_this.slidemenu) {
+ return;
+ }
+ _this.slidemenuBody = _this.slidemenu.querySelector('.body');
+ _this.slidemenuContent = _this.slidemenu.querySelector('.content');
+ _this.slidemenuHeader = _this.slidemenu.querySelector('.header');
+ };
+ SpSlidemenu.prototype.setDefaultStyle = function() {
+ var _this = this;
+ if (support.msPointer) {
+ _this.slidemenuContent.style.msTouchAction = 'none';
+ }
+ _this.setHeight();
+ if (_this.useCssAnimation) {
+ setStyles(_this.main, {
+ transitionProperty: getCSSName('transform'),
+ transitionTimingFunction: 'ease-in-out',
+ transitionDuration: ANIME_SPEED.slider + 'ms',
+ transitionDelay: '0ms',
+ transform: _this.getTranslateX(0)
+ });
+ setStyles(_this.slidemenu, {
+ transitionProperty: 'visibility',
+ transitionTimingFunction: 'linear',
+ transitionDuration: '0ms',
+ transitionDelay: ANIME_SPEED.slider + 'ms'
+ });
+ setStyles(_this.slidemenuContent, {
+ transitionProperty: getCSSName('transform'),
+ transitionTimingFunction: 'ease-in-out',
+ transitionDuration: '0ms',
+ transitionDelay: '0ms',
+ transform: _this.getTranslateY(0)
+ });
+ } else {
+ setStyles(_this.main, {
+ position: 'relative',
+ left: '0px'
+ });
+ setStyles(_this.slidemenuContent, {
+ top: '0px'
+ });
+ }
+ };
+ SpSlidemenu.prototype.setHeight = function(event) {
+ var _this, browserHeight;
+ _this = this;
+ browserHeight = getBrowserHeight();
+ setStyles(_this.main, {
+ minHeight: browserHeight + 'px'
+ });
+ setStyles(_this.slidemenu, {
+ height: browserHeight + 'px'
+ });
+ };
+ SpSlidemenu.prototype.buttonTouchStart = function(event) {
+ var _this = this;
+ event.preventDefault();
+ event.stopPropagation();
+ switch (_this.main.SpSlidemenuStatus) {
+ case SLIDE_STATUS.progress:
+ break;
+ case SLIDE_STATUS.open:
+ case SLIDE_STATUS.close:
+ _this.buttonStartPageX = getPage(event, 'pageX');
+ _this.buttonStartPageY = getPage(event, 'pageY');
+ break;
+ }
+ };
+ SpSlidemenu.prototype.buttonTouchEnd = function(event) {
+ var _this = this;
+ event.preventDefault();
+ event.stopPropagation();
+ if (_this.shouldTrigerNext(event)) {
+ switch (_this.main.SpSlidemenuStatus) {
+ case SLIDE_STATUS.progress:
+ break;
+ case SLIDE_STATUS.open:
+ _this.slideClose(event);
+ break;
+ case SLIDE_STATUS.close:
+ _this.slideOpen(event);
+ break;
+ }
+ }
+ };
+ SpSlidemenu.prototype.mainTouchStart = function(event) {
+ var _this = this;
+ event.preventDefault();
+ event.stopPropagation();
+ _this.slideClose(event);
+ };
+ SpSlidemenu.prototype.shouldTrigerNext = function(event) {
+ var _this = this,
+ buttonEndPageX = getPage(event, 'pageX'),
+ buttonEndPageY = getPage(event, 'pageY'),
+ deltaX = Math.abs(buttonEndPageX - _this.buttonStartPageX),
+ deltaY = Math.abs(buttonEndPageY - _this.buttonStartPageY);
+ return deltaX < 20 && deltaY < 20;
+ };
+ SpSlidemenu.prototype.slideOpen = function(event) {
+ var _this = this, toX;
+
+ /// Misskey Original
+ document.body.setAttribute('data-nav-open', 'true');
+
+ if (_this.direction === 'left') {
+ toX = _this.slideWidth;
+ } else {
+ toX = -_this.slideWidth;
+ }
+ _this.main.SpSlidemenuStatus = SLIDE_STATUS.progress;
+ //set event
+ addTouchEvent('move', document, blockEvent, false);
+ // change style
+ _this.htmlOverflowX = document.documentElement.style['overflowX'];
+ _this.bodyOverflowX = document.body.style['overflowX'];
+ document.documentElement.style['overflowX'] = document.body.style['overflowX'] = 'hidden';
+ if (_this.useCssAnimation) {
+ setStyles(_this.main, {
+ transform: _this.getTranslateX(toX)
+ });
+ setStyles(_this.slidemenu, {
+ transitionProperty: 'z-index',
+ visibility: 'visible',
+ zIndex: '1'
+ });
+ } else {
+ animate(_this.main, _this.direction, toX, ANIME_SPEED.slider);
+ setStyles(_this.slidemenu, {
+ visibility: 'visible'
+ });
+ }
+ // set callback
+ setTimeout(_this.slideOpenEnd, ANIME_SPEED.slider + EVENT_MOE_TIME);
+ };
+ SpSlidemenu.prototype.slideOpenEnd = function() {
+ var _this = this;
+ _this.main.SpSlidemenuStatus = SLIDE_STATUS.open;
+ // change style
+ if (_this.useCssAnimation) {
+ } else {
+ setStyles(_this.slidemenu, {
+ zIndex: '1'
+ });
+ }
+ // add event
+ addTouchEvent('start', _this.main, _this.mainTouchStart, false);
+ };
+ SpSlidemenu.prototype.slideClose = function(event) {
+ var _this = this;
+ _this.main.SpSlidemenuStatus = SLIDE_STATUS.progress;
+
+ /// Misskey Original
+ document.body.setAttribute('data-nav-open', 'false');
+
+ //event
+ removeTouchEvent('start', _this.main, _this.mainTouchStart, false);
+ // change style
+ if (_this.useCssAnimation) {
+ setStyles(_this.main, {
+ transform: _this.getTranslateX(0)
+ });
+ setStyles(_this.slidemenu, {
+ transitionProperty: 'visibility',
+ visibility: 'hidden',
+ zIndex: '-1'
+ });
+ } else {
+ animate(_this.main, _this.direction, 0, ANIME_SPEED.slider);
+ setStyles(_this.slidemenu, {
+ zIndex: '-1'
+ });
+ }
+ // set callback
+ setTimeout(_this.slideCloseEnd, ANIME_SPEED.slider + EVENT_MOE_TIME);
+ };
+ SpSlidemenu.prototype.slideCloseEnd = function() {
+ var _this = this;
+ _this.main.SpSlidemenuStatus = SLIDE_STATUS.close;
+ // change style
+ document.documentElement.style['overflowX'] = _this.htmlOverflowX;
+ document.body.style['overflowX'] = _this.bodyOverflowX;
+ if (_this.useCssAnimation) {
+ } else {
+ setStyles(_this.slidemenu, {
+ visibility: 'hidden'
+ });
+ }
+ // set event
+ removeTouchEvent('move', document, blockEvent, false);
+ };
+ SpSlidemenu.prototype.scrollTouchStart = function(event) {
+ var _this = this;
+ if (gestureStart) {
+ return;
+ }
+ if (_this.scrollOverTimer !== false) {
+ clearTimeout(_this.scrollOverTimer);
+ }
+ _this.scrollCurrentY = _this.getScrollCurrentY();
+ if (_this.useCssAnimation) {
+ setStyles(_this.slidemenuContent, {
+ transitionTimingFunction: 'ease-in-out',
+ transitionDuration: '0ms',
+ transform: _this.getTranslateY(_this.scrollCurrentY)
+ });
+ } else {
+ _this.stopScrollAnimate();
+ setStyles(_this.slidemenuContent, {
+ top: _this.scrollCurrentY + 'px'
+ });
+ }
+ _this.scrollOverTimer = false;
+ _this.scrollAnimationTimer = false;
+ _this.scrollTouchStarted = true;
+ _this.scrollMoveReady = false;
+ _this.scrollMoveEventCnt = 0;
+ _this.scrollMaxY = _this.calcMaxY();
+ _this.scrollStartPageX = getPage(event, 'pageX');
+ _this.scrollStartPageY = getPage(event, 'pageY');
+ _this.scrollBasePageY = _this.scrollStartPageY;
+ _this.scrollTimeForVelocity = event.timeStamp;
+ _this.scrollPageYForVelocity = _this.scrollStartPageY;
+ _this.slidemenuContent.removeEventListener('click', blockEvent, true);
+ };
+ SpSlidemenu.prototype.scrollTouchMove = function(event) {
+ var _this, pageX, pageY, distY, newY, deltaX, deltaY;
+ _this = this;
+ if (!_this.scrollTouchStarted || gestureStart) {
+ return;
+ }
+ pageX = getPage(event, 'pageX');
+ pageY = getPage(event, 'pageY');
+ if (_this.scrollMoveReady) {
+ event.preventDefault();
+ event.stopPropagation();
+ distY = pageY - _this.scrollBasePageY;
+ newY = _this.scrollCurrentY + distY;
+ if (newY > 0 || newY < _this.scrollMaxY) {
+ newY = Math.round(_this.scrollCurrentY + distY / 3);
+ }
+ _this.scrollSetY(newY);
+ if (_this.scrollMoveEventCnt % THRESHOLD === 0) {
+ _this.scrollPageYForVelocity = pageY;
+ _this.scrollTimeForVelocity = event.timeStamp;
+ }
+ _this.scrollMoveEventCnt++;
+ } else {
+ deltaX = Math.abs(pageX - _this.scrollStartPageX);
+ deltaY = Math.abs(pageY - _this.scrollStartPageY);
+ if (deltaX > 5 || deltaY > 5) {
+ _this.scrollMoveReady = true;
+ _this.slidemenuContent.addEventListener('click', blockEvent, true);
+ }
+ }
+ _this.scrollBasePageY = pageY;
+ };
+ SpSlidemenu.prototype.scrollTouchEnd = function(event) {
+ var _this, speed, deltaY, deltaTime;
+ _this = this;
+ if (!_this.scrollTouchStarted) {
+ return;
+ }
+ _this.scrollTouchStarted = false;
+ _this.scrollMaxY = _this.calcMaxY();
+ if (_this.scrollCurrentY > 0 || _this.scrollCurrentY < _this.scrollMaxY) {
+ _this.scrollOverBack();
+ return;
+ }
+ deltaY = getPage(event, 'pageY') - _this.scrollPageYForVelocity;
+ deltaTime = event.timeStamp - _this.scrollTimeForVelocity;
+ speed = deltaY / deltaTime;
+ if (Math.abs(speed) >= 0.01) {
+ _this.scrollInertia(speed);
+ }
+ };
+ SpSlidemenu.prototype.scrollInertia = function(speed) {
+ var _this, directionToTop, maxTo, distanceMaxTo, stopTime, canMove, to, duration, speedAtboundary, nextTo;
+ _this = this;
+ if (speed > 0) {
+ directionToTop = true;
+ maxTo = 0;
+ } else {
+ directionToTop = false;
+ maxTo = _this.scrollMaxY;
+ }
+ distanceMaxTo = Math.abs(_this.scrollCurrentY - maxTo);
+ speed = Math.abs(750 * speed);
+ if (speed > 1000) {
+ speed = 1000;
+ }
+ stopTime = speed / 500;
+ canMove = (speed * stopTime) - ((500 * Math.pow(stopTime, 2)) / 2);
+ if (canMove <= distanceMaxTo) {
+ if (directionToTop) {
+ to = _this.scrollCurrentY + canMove;
+ } else {
+ to = _this.scrollCurrentY - canMove;
+ }
+ duration = stopTime * 1000;
+ _this.scrollInertiaMove(to, duration, false);
+ } else {
+ to = maxTo;
+ speedAtboundary = Math.sqrt((2 * 500 * distanceMaxTo) + Math.pow(speed, 2));
+ duration = (speedAtboundary - speed) / 500 * 1000;
+ _this.scrollInertiaMove(to, duration, true, speedAtboundary, directionToTop);
+ }
+ };
+ SpSlidemenu.prototype.scrollInertiaMove = function(to, duration, isOver, speed, directionToTop) {
+ var _this = this, stopTime, canMove;
+ _this.scrollCurrentY = to;
+ if (_this.useCssAnimation) {
+ setStyles(_this.slidemenuContent, {
+ transitionTimingFunction: 'cubic-bezier(0.33, 0.66, 0.66, 1)',
+ transitionDuration: duration + 'ms',
+ transform: _this.getTranslateY(to)
+ });
+ } else {
+ _this.scrollAnimate(to, duration);
+ }
+ if (!isOver) {
+ return;
+ }
+ stopTime = speed / 7500;
+ canMove = (speed * stopTime) - ((7500 * Math.pow(stopTime, 2)) / 2);
+ if (directionToTop) {
+ to = _this.scrollCurrentY + canMove;
+ } else {
+ to = _this.scrollCurrentY - canMove;
+ }
+ duration = stopTime * 1000;
+ _this.scrollOver(to, duration);
+ };
+ SpSlidemenu.prototype.scrollOver = function(to, duration) {
+ var _this;
+ _this = this;
+ _this.scrollCurrentY = to;
+ if (_this.useCssAnimation) {
+ setStyles(_this.slidemenuContent, {
+ transitionTimingFunction: 'cubic-bezier(0.33, 0.66, 0.66, 1)',
+ transitionDuration: duration + 'ms',
+ transform: _this.getTranslateY(to)
+ });
+ } else {
+ _this.scrollAnimate(to, duration);
+ }
+ _this.scrollOverTimer = setTimeout(_this.scrollOverBack, duration);
+ };
+ SpSlidemenu.prototype.scrollOverBack = function() {
+ var _this, to;
+ _this = this;
+ if (_this.scrollCurrentY >= 0) {
+ to = 0;
+ } else {
+ to = _this.scrollMaxY;
+ }
+ _this.scrollCurrentY = to;
+ if (_this.useCssAnimation) {
+ setStyles(_this.slidemenuContent, {
+ transitionTimingFunction: 'ease-out',
+ transitionDuration: ANIME_SPEED.scrollOverBack + 'ms',
+ transform: _this.getTranslateY(to)
+ });
+ } else {
+ _this.scrollAnimate(to, ANIME_SPEED.scrollOverBack);
+ }
+ };
+ SpSlidemenu.prototype.scrollSetY = function(y) {
+ var _this = this;
+ _this.scrollCurrentY = y;
+ if (_this.useCssAnimation) {
+ setStyles(_this.slidemenuContent, {
+ transitionTimingFunction: 'ease-in-out',
+ transitionDuration: '0ms',
+ transform: _this.getTranslateY(y)
+ });
+ } else {
+ _this.slidemenuContent.style.top = y + 'px';
+ }
+ };
+ SpSlidemenu.prototype.scrollAnimate = function(to, transitionDuration) {
+ var _this = this;
+ _this.stopScrollAnimate();
+ _this.scrollAnimationTimer = animate(_this.slidemenuContent, 'top', to, transitionDuration);
+ };
+ SpSlidemenu.prototype.stopScrollAnimate = function() {
+ var _this = this;
+ if (_this.scrollAnimationTimer !== false) {
+ clearInterval(_this.scrollAnimationTimer);
+ }
+ };
+ SpSlidemenu.prototype.itemClick = function(event) {
+ var elem = event.target || event.srcElement;
+ if (hasClass(elem, ITEM_CLICK_CLASS_NAME)) {
+ this.slideClose();
+ }
+ };
+ SpSlidemenu.prototype.calcMaxY = function(x) {
+ var _this, contentHeight, bodyHeight, headerHeight;
+ _this = this;
+ contentHeight = _this.slidemenuContent.offsetHeight;
+ bodyHeight = _this.slidemenuBody.offsetHeight;
+ headerHeight = 0;
+ if (_this.slidemenuHeader) {
+ headerHeight = _this.slidemenuHeader.offsetHeight;
+ }
+ if (contentHeight > bodyHeight) {
+ return -(contentHeight - bodyHeight + headerHeight);
+ } else {
+ return 0;
+ }
+ };
+ SpSlidemenu.prototype.getScrollCurrentY = function() {
+ var ret = 0;
+ if (this.useCssAnimation) {
+ getStyle(window.getComputedStyle(this.slidemenuContent, ''), 'transform').split(',').forEach(function(value) {
+ var number = parseInt(value, 10);
+ if (!isNaN(number) && number !== 0 && number !== 1) {
+ ret = number;
+ }
+ });
+ } else {
+ var number = parseInt(getStyle(window.getComputedStyle(this.slidemenuContent, ''), 'top'), 10);
+ if (!isNaN(number) && number !== 0 && number !== 1) {
+ ret = number;
+ }
+ }
+ return ret;
+ };
+ SpSlidemenu.prototype.getTranslateX = function(x) {
+ var _this = this;
+ return _this.use3d ? 'translate3d(' + x + 'px, 0px, 0px)' : 'translate(' + x + 'px, 0px)';
+ };
+ SpSlidemenu.prototype.getTranslateY = function(y) {
+ var _this = this;
+ return _this.use3d ? 'translate3d(0px, ' + y + 'px, 0px)' : 'translate(0px, ' + y + 'px)';
+ };
+ //Utility Function
+ function hasProp(props) {
+ return some(props, function(prop) {
+ return div.style[prop] !== undefined;
+ });
+ }
+ function upperCaseFirst(str) {
+ return str.charAt(0).toUpperCase() + str.substr(1);
+ }
+ function some(ary, callback) {
+ var i, len;
+ for (i = 0, len = ary.length; i < len; i++) {
+ if (callback(ary[i], i)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ function setStyle(elem, prop, val) {
+ var style = elem.style;
+ if (!setStyle.cache) {
+ setStyle.cache = {};
+ }
+ if (setStyle.cache[prop] !== undefined) {
+ style[setStyle.cache[prop]] = val;
+ return;
+ }
+ if (style[prop] !== undefined) {
+ setStyle.cache[prop] = prop;
+ style[prop] = val;
+ return;
+ }
+ some(PREFIX, function(_prefix) {
+ var _prop = upperCaseFirst(_prefix) + upperCaseFirst(prop);
+ if (style[_prop] !== undefined) {
+ //setStyle.cache[prop] = _prop;
+ style[_prop] = val;
+ return true;
+ }
+ });
+ }
+ function setStyles(elem, styles) {
+ var style, prop;
+ for (prop in styles) {
+ if (styles.hasOwnProperty(prop)) {
+ setStyle(elem, prop, styles[prop]);
+ }
+ }
+ }
+ function getStyle(style, prop) {
+ var ret;
+ if (style[prop] !== undefined) {
+ return style[prop];
+ }
+ some(PREFIX, function(_prefix) {
+ var _prop = upperCaseFirst(_prefix) + upperCaseFirst(prop);
+ if (style[_prop] !== undefined) {
+ ret = style[_prop];
+ return true;
+ }
+ });
+ return ret;
+ }
+ function getCSSName(prop) {
+ var ret;
+ if (!getCSSName.cache) {
+ getCSSName.cache = {};
+ }
+ if (getCSSName.cache[prop] !== undefined) {
+ return getCSSName.cache[prop];
+ }
+ if (div.style[prop] !== undefined) {
+ getCSSName.cache[prop] = prop;
+ return prop;
+ }
+ some(PREFIX, function(_prefix) {
+ var _prop = upperCaseFirst(_prefix) + upperCaseFirst(prop);
+ if (div.style[_prop] !== undefined) {
+ ret = '-' + _prefix + '-' + prop;
+ return true;
+ }
+ });
+ getCSSName.cache[prop] = ret;
+ return ret;
+ }
+ function bind(func, context) {
+ var nativeBind, slice, args;
+ nativeBind = Function.prototype.bind;
+ slice = Array.prototype.slice;
+ if (func.bind === nativeBind && nativeBind) {
+ return nativeBind.apply(func, slice.call(arguments, 1));
+ }
+ args = slice.call(arguments, 2);
+ return function() {
+ return func.apply(context, args.concat(slice.call(arguments)));
+ };
+ }
+ function blockEvent(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ function getDimentions(element) {
+ var previous, key, properties, result;
+ previous = {};
+ properties = {
+ position: 'absolute',
+ visibility: 'hidden',
+ display: 'block'
+ };
+ for (key in properties) {
+ previous[key] = element.style[key];
+ element.style[key] = properties[key];
+ }
+ result = {
+ width: element.offsetWidth,
+ height: element.offsetHeight
+ };
+ for (key in properties) {
+ element.style[key] = previous[key];
+ }
+ return result;
+ }
+ function getPage(event, page) {
+ return event.changedTouches ? event.changedTouches[0][page] : event[page];
+ }
+ function addTouchEvent(eventType, element, listener, useCapture) {
+ useCapture = useCapture || false;
+ if (support.touch) {
+ element.addEventListener(EVENTS[eventType].touch, listener, { passive: useCapture });
+ } else {
+ element.addEventListener(EVENTS[eventType].mouse, listener, { passive: useCapture });
+ }
+ }
+ function removeTouchEvent(eventType, element, listener, useCapture) {
+ useCapture = useCapture || false;
+ if (support.touch) {
+ element.removeEventListener(EVENTS[eventType].touch, listener, useCapture);
+ } else {
+ element.removeEventListener(EVENTS[eventType].mouse, listener, useCapture);
+ }
+ }
+ function hasClass(elem, className) {
+ className = " " + className + " ";
+ if (elem.nodeType === 1 && (" " + elem.className + " ").replace(rclass, " ").indexOf(className) >= 0) {
+ return true;
+ }
+ return false;
+ }
+ function animate(elem, prop, to, transitionDuration) {
+ var begin, from, duration, easing, timer;
+ begin = +new Date();
+ from = parseInt(elem.style[prop], 10);
+ to = parseInt(to, 10);
+ duration = parseInt(transitionDuration, 10);
+ easing = function(time, duration) {
+ return -(time /= duration) * (time - 2);
+ };
+ timer = setInterval(function() {
+ var time, pos, now;
+ time = new Date() - begin;
+ if (time > duration) {
+ clearInterval(timer);
+ now = to;
+ } else {
+ pos = easing(time, duration);
+ now = pos * (to - from) + from;
+ }
+ elem.style[prop] = now + 'px';
+ }, 10);
+ return timer;
+ }
+ function getBrowserHeight() {
+ if (window.innerHeight) {
+ return window.innerHeight;
+ }
+ else if (document.documentElement && document.documentElement.clientHeight !== 0) {
+ return document.documentElement.clientHeight;
+ }
+ else if (document.body) {
+ return document.body.clientHeight;
+ }
+ return 0;
+ }
+ function debounce(func, wait, immediate) {
+ var timeout, result;
+ return function() {
+ var context = this, args = arguments;
+ var later = function() {
+ timeout = null;
+ if (!immediate) result = func.apply(context, args);
+ };
+ var callNow = immediate && !timeout;
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ if (callNow) result = func.apply(context, args);
+ return result;
+ };
+ }
+ window.SpSlidemenu = SpSlidemenu;
+})(window, window.document);
diff --git a/src/web/app/mobile/scripts/stream.ls b/src/web/app/mobile/scripts/stream.ls
new file mode 100644
index 0000000000..b7810b49ae
--- /dev/null
+++ b/src/web/app/mobile/scripts/stream.ls
@@ -0,0 +1,13 @@
+# Stream
+#================================
+
+stream = require '../../common/scripts/stream.ls'
+riot = require \riot
+
+module.exports = (me) ~>
+ s = stream me
+
+ riot.mixin \stream do
+ stream: s.event
+ get-stream-state: s.get-state
+ stream-state-ev: s.state-ev
diff --git a/src/web/app/mobile/scripts/ui.ls b/src/web/app/mobile/scripts/ui.ls
new file mode 100644
index 0000000000..aa94a8b052
--- /dev/null
+++ b/src/web/app/mobile/scripts/ui.ls
@@ -0,0 +1,6 @@
+riot = require \riot
+
+ui = riot.observable!
+
+riot.mixin \ui do
+ ui: ui
diff --git a/src/web/app/mobile/style.styl b/src/web/app/mobile/style.styl
new file mode 100644
index 0000000000..bc7859844a
--- /dev/null
+++ b/src/web/app/mobile/style.styl
@@ -0,0 +1,12 @@
+@import "../base"
+
+body[data-nav-open='true']
+ #hamburger
+ > i
+ -webkit-transform rotate(-90deg)
+ transform rotate(-90deg)
+
+#wait
+ top auto
+ bottom 15px
+ left 15px
diff --git a/src/web/app/mobile/tags.ls b/src/web/app/mobile/tags.ls
new file mode 100644
index 0000000000..5805cb6b28
--- /dev/null
+++ b/src/web/app/mobile/tags.ls
@@ -0,0 +1,44 @@
+require './tags/ui.tag'
+require './tags/ui-header.tag'
+require './tags/ui-nav.tag'
+require './tags/stream-indicator.tag'
+require './tags/page/entrance.tag'
+require './tags/page/entrance/signin.tag'
+require './tags/page/entrance/signup.tag'
+require './tags/page/home.tag'
+require './tags/page/drive.tag'
+require './tags/page/notifications.tag'
+require './tags/page/user.tag'
+require './tags/page/user-followers.tag'
+require './tags/page/user-following.tag'
+require './tags/page/post.tag'
+require './tags/page/new-post.tag'
+require './tags/page/search.tag'
+require './tags/home.tag'
+require './tags/home-timeline.tag'
+require './tags/timeline.tag'
+require './tags/timeline-post.tag'
+require './tags/timeline-post-sub.tag'
+require './tags/post-preview.tag'
+require './tags/sub-post-content.tag'
+require './tags/images-viewer.tag'
+require './tags/drive.tag'
+require './tags/drive-selector.tag'
+require './tags/drive/file.tag'
+require './tags/drive/folder.tag'
+require './tags/drive/file-viewer.tag'
+require './tags/post-form.tag'
+require './tags/notification.tag'
+require './tags/notifications.tag'
+require './tags/notify.tag'
+require './tags/notification-preview.tag'
+require './tags/search.tag'
+require './tags/search-posts.tag'
+require './tags/post-detail.tag'
+require './tags/user.tag'
+require './tags/user-timeline.tag'
+require './tags/follow-button.tag'
+require './tags/user-preview.tag'
+require './tags/users-list.tag'
+require './tags/user-following.tag'
+require './tags/user-followers.tag'
diff --git a/src/web/app/mobile/tags/drive-selector.tag b/src/web/app/mobile/tags/drive-selector.tag
new file mode 100644
index 0000000000..442299026e
--- /dev/null
+++ b/src/web/app/mobile/tags/drive-selector.tag
@@ -0,0 +1,75 @@
+mk-drive-selector
+ div.body
+ header
+ h1
+ | ファイルを選択
+ span.count(if={ files.length > 0 }) ({ files.length })
+ button.close(onclick={ cancel }): i.fa.fa-times
+ button.ok(onclick={ ok }): i.fa.fa-check
+ mk-drive@browser(select={ true }, multiple={ opts.multiple })
+
+style.
+ display block
+
+ > .body
+ position fixed
+ z-index 2048
+ top 0
+ left 0
+ right 0
+ margin 0 auto
+ width 100%
+ max-width 500px
+ height 100%
+ overflow hidden
+ background #fff
+ box-shadow 0 0 16px rgba(#000, 0.3)
+
+ > header
+ border-bottom solid 1px #eee
+
+ > h1
+ margin 0
+ padding 0
+ text-align center
+ line-height 42px
+ font-size 1em
+ font-weight normal
+
+ > .count
+ margin-left 4px
+ opacity 0.5
+
+ > .close
+ position absolute
+ top 0
+ left 0
+ line-height 42px
+ width 42px
+
+ > .ok
+ position absolute
+ top 0
+ right 0
+ line-height 42px
+ width 42px
+
+ > mk-drive
+ height calc(100% - 42px)
+ overflow scroll
+
+script.
+ @files = []
+
+ @on \mount ~>
+ @refs.browser.on \change-selected (files) ~>
+ @files = files
+ @update!
+
+ @cancel = ~>
+ @trigger \canceled
+ @unmount!
+
+ @ok = ~>
+ @trigger \selected @files
+ @unmount!
diff --git a/src/web/app/mobile/tags/drive.tag b/src/web/app/mobile/tags/drive.tag
new file mode 100644
index 0000000000..fcc78d1e68
--- /dev/null
+++ b/src/web/app/mobile/tags/drive.tag
@@ -0,0 +1,338 @@
+mk-drive
+ nav
+ p(onclick={ go-root })
+ i.fa.fa-cloud
+ | ドライブ
+ virtual(each={ folder in hierarchy-folders })
+ span: i.fa.fa-angle-right
+ p(onclick={ _move }) { folder.name }
+ span(if={ folder != null }): i.fa.fa-angle-right
+ p(if={ folder != null }) { folder.name }
+ div.browser(if={ file == null }, class={ loading: loading })
+ div.folders(if={ folders.length > 0 })
+ virtual(each={ folder in folders })
+ mk-drive-folder(folder={ folder })
+ p(if={ more-folders })
+ | もっと読み込む
+ div.files(if={ files.length > 0 })
+ virtual(each={ file in files })
+ mk-drive-file(file={ file })
+ p(if={ more-files })
+ | もっと読み込む
+ div.empty(if={ files.length == 0 && folders.length == 0 && !loading })
+ p(if={ !folder == null })
+ | ドライブには何もありません。
+ p(if={ folder != null })
+ | このフォルダーは空です
+ div.loading(if={ loading }).
+ <div class="spinner">
+ <div class="dot1"></div>
+ <div class="dot2"></div>
+ </div>
+ mk-drive-file-viewer(if={ file != null }, file={ file })
+
+style.
+ display block
+ background #fff
+
+ > nav
+ display block
+ width 100%
+ padding 10px 12px
+ overflow auto
+ white-space nowrap
+ font-size 0.9em
+ color #555
+ background #fff
+ border-bottom solid 1px #dfdfdf
+
+ > p
+ display inline
+ margin 0
+ padding 0
+
+ &:last-child
+ font-weight bold
+
+ > i
+ margin-right 4px
+
+ > span
+ margin 0 8px
+ opacity 0.5
+
+ > .browser
+ &.loading
+ opacity 0.5
+
+ > .folders
+ > mk-drive-folder
+ border-bottom solid 1px #eee
+
+ > .files
+ > mk-drive-file
+ border-bottom solid 1px #eee
+
+ > .empty
+ padding 16px
+ text-align center
+ color #999
+ pointer-events none
+
+ > p
+ margin 0
+
+ > .loading
+ .spinner
+ margin 100px auto
+ width 40px
+ height 40px
+ text-align center
+
+ animation sk-rotate 2.0s infinite linear
+
+ .dot1, .dot2
+ width 60%
+ height 60%
+ display inline-block
+ position absolute
+ top 0
+ background-color rgba(0, 0, 0, 0.3)
+ border-radius 100%
+
+ animation sk-bounce 2.0s infinite ease-in-out
+
+ .dot2
+ top auto
+ bottom 0
+ animation-delay -1.0s
+
+ @keyframes sk-rotate { 100% { transform: rotate(360deg); }}
+
+ @keyframes sk-bounce {
+ 0%, 100% {
+ transform: scale(0.0);
+ } 50% {
+ transform: scale(1.0);
+ }
+ }
+
+script.
+ @mixin \api
+ @mixin \stream
+
+ @files = []
+ @folders = []
+ @hierarchy-folders = []
+ @selected-files = []
+
+ # 現在の階層(フォルダ)
+ # * null でルートを表す
+ @folder = null
+
+ @file = null
+
+ @is-select-mode = @opts.select? and @opts.select
+ @multiple = if @opts.multiple? then @opts.multiple else false
+
+ @on \mount ~>
+ @stream.on \drive_file_created @on-stream-drive-file-created
+ @stream.on \drive_file_updated @on-stream-drive-file-updated
+ @stream.on \drive_folder_created @on-stream-drive-folder-created
+ @stream.on \drive_folder_updated @on-stream-drive-folder-updated
+
+ # Riotのバグでnullを渡しても""になる
+ # https://github.com/riot/riot/issues/2080
+ #if @opts.folder?
+ if @opts.folder? and @opts.folder != ''
+ @cd @opts.folder
+ else
+ @load!
+
+ @on \unmount ~>
+ @stream.off \drive_file_created @on-stream-drive-file-created
+ @stream.off \drive_file_updated @on-stream-drive-file-updated
+ @stream.off \drive_folder_created @on-stream-drive-folder-created
+ @stream.off \drive_folder_updated @on-stream-drive-folder-updated
+
+ @on-stream-drive-file-created = (file) ~>
+ @add-file file, true
+
+ @on-stream-drive-file-updated = (file) ~>
+ current = if @folder? then @folder.id else null
+ if current != file.folder_id
+ @remove-file file
+ else
+ @add-file file, true
+
+ @on-stream-drive-folder-created = (folder) ~>
+ @add-folder folder, true
+
+ @on-stream-drive-folder-updated = (folder) ~>
+ current = if @folder? then @folder.id else null
+ if current != folder.parent_id
+ @remove-folder folder
+ else
+ @add-folder folder, true
+
+ @_move = (ev) ~>
+ @move ev.item.folder
+
+ @move = (target-folder) ~>
+ @cd target-folder, true
+
+ @cd = (target-folder, is-move) ~>
+ if target-folder? and typeof target-folder == \object
+ target-folder = target-folder.id
+
+ if target-folder == null
+ @go-root!
+ return
+
+ @loading = true
+ @update!
+
+ @api \drive/folders/show do
+ folder_id: target-folder
+ .then (folder) ~>
+ @folder = folder
+ @hierarchy-folders = []
+
+ x = (f) ~>
+ @hierarchy-folders.unshift f
+ if f.parent?
+ x f.parent
+
+ if folder.parent?
+ x folder.parent
+
+ @update!
+ if is-move then @trigger \move @folder
+ @trigger \cd @folder
+ @load!
+ .catch (err, text-status) ->
+ console.error err
+
+ @add-folder = (folder, unshift = false) ~>
+ current = if @folder? then @folder.id else null
+ if current != folder.parent_id
+ return
+
+ if (@folders.some (f) ~> f.id == folder.id)
+ return
+
+ if unshift
+ @folders.unshift folder
+ else
+ @folders.push folder
+
+ @update!
+
+ @add-file = (file, unshift = false) ~>
+ current = if @folder? then @folder.id else null
+ if current != file.folder_id
+ return
+
+ if (@files.some (f) ~> f.id == file.id)
+ exist = (@files.map (f) -> f.id).index-of file.id
+ @files[exist] = file
+ @update!
+ return
+
+ if unshift
+ @files.unshift file
+ else
+ @files.push file
+
+ @update!
+
+ @remove-folder = (folder) ~>
+ if typeof folder == \object
+ folder = folder.id
+ @folders = @folders.filter (f) -> f.id != folder
+ @update!
+
+ @remove-file = (file) ~>
+ if typeof file == \object
+ file = file.id
+ @files = @files.filter (f) -> f.id != file
+ @update!
+
+ @go-root = ~>
+ if @folder != null
+ @folder = null
+ @hierarchy-folders = []
+ @update!
+ @trigger \move-root
+ @load!
+
+ @load = ~>
+ @folders = []
+ @files = []
+ @more-folders = false
+ @more-files = false
+ @loading = true
+ @update!
+
+ @trigger \begin-load
+
+ load-folders = null
+ load-files = null
+
+ folders-max = 20
+ files-max = 20
+
+ # フォルダ一覧取得
+ @api \drive/folders do
+ folder_id: if @folder? then @folder.id else null
+ limit: folders-max + 1
+ .then (folders) ~>
+ if folders.length == folders-max + 1
+ @more-folders = true
+ folders.pop!
+ load-folders := folders
+ complete!
+ .catch (err, text-status) ~>
+ console.error err
+
+ # ファイル一覧取得
+ @api \drive/files do
+ folder_id: if @folder? then @folder.id else null
+ limit: files-max + 1
+ .then (files) ~>
+ if files.length == files-max + 1
+ @more-files = true
+ files.pop!
+ load-files := files
+ complete!
+ .catch (err, text-status) ~>
+ console.error err
+
+ flag = false
+ complete = ~>
+ if flag
+ load-folders.for-each (folder) ~>
+ @add-folder folder
+ load-files.for-each (file) ~>
+ @add-file file
+ @loading = false
+ @update!
+
+ @trigger \loaded
+ else
+ flag := true
+ @trigger \load-mid
+
+ @choose-file = (file) ~>
+ if @is-select-mode
+ exist = @selected-files.some (f) ~> f.id == file.id
+ if exist
+ @selected-files = (@selected-files.filter (f) ~> f.id != file.id)
+ else
+ @selected-files.push file
+ @update!
+ @trigger \change-selected @selected-files
+ else
+ @file = file
+ @update!
+ @trigger \open-file @file
diff --git a/src/web/app/mobile/tags/drive/file-viewer.tag b/src/web/app/mobile/tags/drive/file-viewer.tag
new file mode 100644
index 0000000000..8ce89a06f4
--- /dev/null
+++ b/src/web/app/mobile/tags/drive/file-viewer.tag
@@ -0,0 +1,8 @@
+mk-drive-file-viewer
+ p.name { file.name }
+
+style.
+ display block
+
+script.
+ @file = @opts.file
diff --git a/src/web/app/mobile/tags/drive/file.tag b/src/web/app/mobile/tags/drive/file.tag
new file mode 100644
index 0000000000..ec271441a5
--- /dev/null
+++ b/src/web/app/mobile/tags/drive/file.tag
@@ -0,0 +1,130 @@
+mk-drive-file(onclick={ onclick }, data-is-selected={ is-selected })
+ div.container
+ div.thumbnail(style={ 'background-image: url(' + file.url + '?thumbnail&size=128)' })
+ div.body
+ p.name { file.name }
+ //
+ if file.tags.length > 0
+ ul.tags
+ each tag in file.tags
+ li.tag(style={background: tag.color, color: contrast(tag.color)})= tag.name
+ footer
+ p.type
+ mk-file-type-icon(file={ file })
+ | { file.type }
+ p.separator
+ p.data-size { bytes-to-size(file.datasize) }
+ p.separator
+ p.created-at
+ i.fa.fa-clock-o
+ mk-time(time={ file.created_at })
+
+style.
+ display block
+
+ &, *
+ user-select none
+
+ *
+ pointer-events none
+
+ > .container
+ max-width 500px
+ margin 0 auto
+ padding 16px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .thumbnail
+ display block
+ float left
+ width 64px
+ height 64px
+ background-size cover
+ background-position center center
+
+ > .body
+ display block
+ float left
+ width calc(100% - 74px)
+ margin-left 10px
+
+ > .name
+ display block
+ margin 0
+ padding 0
+ font-size 0.9em
+ font-weight bold
+ color #555
+ text-overflow ellipsis
+ word-wrap break-word
+
+ > .tags
+ display block
+ margin 4px 0 0 0
+ padding 0
+ list-style none
+ font-size 0.5em
+
+ > .tag
+ display inline-block
+ margin 0 5px 0 0
+ padding 1px 5px
+ border-radius 2px
+
+ > footer
+ display block
+ margin 4px 0 0 0
+ font-size 0.7em
+
+ > .separator
+ display inline
+ margin 0
+ padding 0 4px
+ color #CDCDCD
+
+ > .type
+ display inline
+ margin 0
+ padding 0
+ color #9D9D9D
+
+ > mk-file-type-icon
+ margin-right 4px
+
+ > .data-size
+ display inline
+ margin 0
+ padding 0
+ color #9D9D9D
+
+ > .created-at
+ display inline
+ margin 0
+ padding 0
+ color #BDBDBD
+
+ > i
+ margin-right 2px
+
+ &[data-is-selected]
+ background $theme-color
+
+ &, *
+ color #fff !important
+
+script.
+ @mixin \bytes-to-size
+
+ @browser = @parent
+ @file = @opts.file
+ @is-selected = @browser.selected-files.some (f) ~> f.id == @file.id
+
+ @browser.on \change-selected (selects) ~>
+ @is-selected = selects.some (f) ~> f.id == @file.id
+
+ @onclick = ~>
+ @browser.choose-file @file
diff --git a/src/web/app/mobile/tags/drive/folder.tag b/src/web/app/mobile/tags/drive/folder.tag
new file mode 100644
index 0000000000..ef3a72ea93
--- /dev/null
+++ b/src/web/app/mobile/tags/drive/folder.tag
@@ -0,0 +1,45 @@
+mk-drive-folder(onclick={ onclick })
+ div.container
+ p.name
+ i.fa.fa-folder
+ | { folder.name }
+ i.fa.fa-angle-right
+
+style.
+ display block
+ color #777
+
+ &, *
+ user-select none
+
+ *
+ pointer-events none
+
+ > .container
+ max-width 500px
+ margin 0 auto
+ padding 16px
+
+ > .name
+ display block
+ margin 0
+ padding 0
+
+ > i
+ margin-right 6px
+
+ > i
+ position absolute
+ top 0
+ bottom 0
+ right 8px
+ margin auto 0 auto 0
+ width 1em
+ height 1em
+
+script.
+ @browser = @parent
+ @folder = @opts.folder
+
+ @onclick = ~>
+ @browser.move @folder
diff --git a/src/web/app/mobile/tags/follow-button.tag b/src/web/app/mobile/tags/follow-button.tag
new file mode 100644
index 0000000000..7cedbbee88
--- /dev/null
+++ b/src/web/app/mobile/tags/follow-button.tag
@@ -0,0 +1,108 @@
+mk-follow-button
+ button(if={ !init }, class={ wait: wait, follow: !user.is_following, unfollow: user.is_following },
+ onclick={ onclick },
+ disabled={ wait })
+ i.fa.fa-minus(if={ !wait && user.is_following })
+ i.fa.fa-plus(if={ !wait && !user.is_following })
+ i.fa.fa-spinner.fa-pulse.fa-fw(if={ wait })
+ | { user.is_following ? 'フォロー解除' : 'フォロー' }
+ div.init(if={ init }): i.fa.fa-spinner.fa-pulse.fa-fw
+
+style.
+ display block
+
+ > button
+ > .init
+ display block
+ user-select none
+ cursor pointer
+ padding 0 16px
+ margin 0
+ height inherit
+ font-size 16px
+ outline none
+ border solid 1px $theme-color
+ border-radius 4px
+
+ *
+ pointer-events none
+
+ &.follow
+ color $theme-color
+ background transparent
+
+ &:hover
+ background rgba($theme-color, 0.1)
+
+ &:active
+ background rgba($theme-color, 0.2)
+
+ &.unfollow
+ color $theme-color-foreground
+ background $theme-color
+
+ &.wait
+ cursor wait !important
+ opacity 0.7
+
+ &.init
+ cursor wait !important
+ opacity 0.7
+
+ > i
+ margin-right 4px
+
+script.
+ @mixin \api
+ @mixin \is-promise
+ @mixin \stream
+
+ @user = null
+ @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user
+ @init = true
+ @wait = false
+
+ @on \mount ~>
+ @user-promise.then (user) ~>
+ @user = user
+ @init = false
+ @update!
+ @stream.on \follow @on-stream-follow
+ @stream.on \unfollow @on-stream-unfollow
+
+ @on \unmount ~>
+ @stream.off \follow @on-stream-follow
+ @stream.off \unfollow @on-stream-unfollow
+
+ @on-stream-follow = (user) ~>
+ if user.id == @user.id
+ @user = user
+ @update!
+
+ @on-stream-unfollow = (user) ~>
+ if user.id == @user.id
+ @user = user
+ @update!
+
+ @onclick = ~>
+ @wait = true
+ if @user.is_following
+ @api \following/delete do
+ user_id: @user.id
+ .then ~>
+ @user.is_following = false
+ .catch (err) ->
+ console.error err
+ .then ~>
+ @wait = false
+ @update!
+ else
+ @api \following/create do
+ user_id: @user.id
+ .then ~>
+ @user.is_following = true
+ .catch (err) ->
+ console.error err
+ .then ~>
+ @wait = false
+ @update!
diff --git a/src/web/app/mobile/tags/home-timeline.tag b/src/web/app/mobile/tags/home-timeline.tag
new file mode 100644
index 0000000000..1754bb2b07
--- /dev/null
+++ b/src/web/app/mobile/tags/home-timeline.tag
@@ -0,0 +1,40 @@
+mk-home-timeline
+ mk-timeline@timeline(init={ init }, more={ more }, empty={ '表示する投稿がありません。誰かしらをフォローするなどしましょう。' })
+
+style.
+ display block
+
+script.
+ @mixin \api
+ @mixin \stream
+
+ @init = new Promise (res, rej) ~>
+ @api \posts/timeline
+ .then (posts) ~>
+ res posts
+ @trigger \loaded
+
+ @on \mount ~>
+ @stream.on \post @on-stream-post
+ @stream.on \follow @on-stream-follow
+ @stream.on \unfollow @on-stream-unfollow
+
+ @on \unmount ~>
+ @stream.off \post @on-stream-post
+ @stream.off \follow @on-stream-follow
+ @stream.off \unfollow @on-stream-unfollow
+
+ @more = ~>
+ @api \posts/timeline do
+ max_id: @refs.timeline.tail!.id
+
+ @on-stream-post = (post) ~>
+ @is-empty = false
+ @update!
+ @refs.timeline.add-post post
+
+ @on-stream-follow = ~>
+ @fetch!
+
+ @on-stream-unfollow = ~>
+ @fetch!
diff --git a/src/web/app/mobile/tags/home.tag b/src/web/app/mobile/tags/home.tag
new file mode 100644
index 0000000000..ebcf8f0bb2
--- /dev/null
+++ b/src/web/app/mobile/tags/home.tag
@@ -0,0 +1,17 @@
+mk-home
+ mk-home-timeline@tl
+
+style.
+ display block
+
+ > mk-home-timeline
+ max-width 600px
+ margin 0 auto
+
+ @media (min-width 500px)
+ padding 16px
+
+script.
+ @on \mount ~>
+ @refs.tl.on \loaded ~>
+ @trigger \loaded
diff --git a/src/web/app/mobile/tags/images-viewer.tag b/src/web/app/mobile/tags/images-viewer.tag
new file mode 100644
index 0000000000..f9d774a124
--- /dev/null
+++ b/src/web/app/mobile/tags/images-viewer.tag
@@ -0,0 +1,25 @@
+mk-images-viewer
+ div.image@view(onclick={ click })
+ img@img(src={ image.url + '?thumbnail&size=512' }, alt={ image.name }, title={ image.name })
+
+style.
+ display block
+ padding 8px
+ overflow hidden
+ box-shadow 0 0 4px rgba(0, 0, 0, 0.2)
+ border-radius 4px
+
+ > .image
+
+ > img
+ display block
+ max-height 256px
+ max-width 100%
+ margin 0 auto
+
+script.
+ @images = @opts.images
+ @image = @images.0
+
+ @click = ~>
+ window.open @image.url
diff --git a/src/web/app/mobile/tags/notification-preview.tag b/src/web/app/mobile/tags/notification-preview.tag
new file mode 100644
index 0000000000..ee936df7ab
--- /dev/null
+++ b/src/web/app/mobile/tags/notification-preview.tag
@@ -0,0 +1,117 @@
+mk-notification-preview(class={ notification.type })
+ div.main(if={ notification.type == 'like' })
+ img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-thumbs-o-up
+ | { notification.user.name }
+ p.post-ref { get-post-summary(notification.post) }
+
+ div.main(if={ notification.type == 'repost' })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-retweet
+ | { notification.post.user.name }
+ p.post-ref { get-post-summary(notification.post.repost) }
+
+ div.main(if={ notification.type == 'quote' })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-quote-left
+ | { notification.post.user.name }
+ p.post-preview { get-post-summary(notification.post) }
+
+ div.main(if={ notification.type == 'follow' })
+ img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-user-plus
+ | { notification.user.name }
+
+ div.main(if={ notification.type == 'reply' })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-reply
+ | { notification.post.user.name }
+ p.post-preview { get-post-summary(notification.post) }
+
+ div.main(if={ notification.type == 'mention' })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-at
+ | { notification.post.user.name }
+ p.post-preview { get-post-summary(notification.post) }
+
+style.
+ display block
+ margin 0
+ padding 8px
+ color #fff
+
+ > .main
+ word-wrap break-word
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ img
+ display block
+ float left
+ min-width 36px
+ min-height 36px
+ max-width 36px
+ max-height 36px
+ border-radius 6px
+
+ .text
+ float right
+ width calc(100% - 36px)
+ padding-left 8px
+
+ p
+ margin 0
+
+ i
+ margin-right 4px
+
+ .post-ref
+
+ &:before, &:after
+ font-family FontAwesome
+ font-size 1em
+ font-weight normal
+ font-style normal
+ display inline-block
+ margin-right 3px
+
+ &:before
+ content "\f10d"
+
+ &:after
+ content "\f10e"
+
+ &.like
+ .text p i
+ color #FFAC33
+
+ &.repost, &.quote
+ .text p i
+ color #77B255
+
+ &.follow
+ .text p i
+ color #53c7ce
+
+ &.reply, &.mention
+ .text p i
+ color #fff
+
+script.
+ @mixin \get-post-summary
+ @notification = @opts.notification
diff --git a/src/web/app/mobile/tags/notification.tag b/src/web/app/mobile/tags/notification.tag
new file mode 100644
index 0000000000..afcc7441b4
--- /dev/null
+++ b/src/web/app/mobile/tags/notification.tag
@@ -0,0 +1,142 @@
+mk-notification(class={ notification.type })
+ mk-time(time={ notification.created_at })
+
+ div.main(if={ notification.type == 'like' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username })
+ img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-thumbs-o-up
+ a(href={ CONFIG.url + '/' + notification.user.username }) { notification.user.name }
+ a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) }
+
+ div.main(if={ notification.type == 'repost' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-retweet
+ a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name }
+ a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post.repost) }
+
+ div.main(if={ notification.type == 'quote' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-quote-left
+ a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name }
+ a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) }
+
+ div.main(if={ notification.type == 'follow' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username })
+ img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-user-plus
+ a(href={ CONFIG.url + '/' + notification.user.username }) { notification.user.name }
+
+ div.main(if={ notification.type == 'reply' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-reply
+ a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name }
+ a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) }
+
+ div.main(if={ notification.type == 'mention' })
+ a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username })
+ img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.text
+ p
+ i.fa.fa-at
+ a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name }
+ a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) }
+
+style.
+ display block
+ margin 0
+ padding 16px
+
+ > mk-time
+ display inline
+ position absolute
+ top 16px
+ right 12px
+ vertical-align top
+ color rgba(0, 0, 0, 0.6)
+ font-size 12px
+
+ > .main
+ word-wrap break-word
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ .avatar-anchor
+ display block
+ float left
+
+ img
+ min-width 36px
+ min-height 36px
+ max-width 36px
+ max-height 36px
+ border-radius 6px
+
+ .text
+ float right
+ width calc(100% - 36px)
+ padding-left 8px
+
+ p
+ margin 0
+
+ i
+ margin-right 4px
+
+ .post-preview
+ color rgba(0, 0, 0, 0.7)
+
+ .post-ref
+ color rgba(0, 0, 0, 0.7)
+
+ &:before, &:after
+ font-family FontAwesome
+ font-size 1em
+ font-weight normal
+ font-style normal
+ display inline-block
+ margin-right 3px
+
+ &:before
+ content "\f10d"
+
+ &:after
+ content "\f10e"
+
+ &.like
+ .text p i
+ color #FFAC33
+
+ &.repost, &.quote
+ .text p i
+ color #77B255
+
+ &.follow
+ .text p i
+ color #53c7ce
+
+ &.reply, &.mention
+ .text p i
+ color #555
+
+ .post-preview
+ color rgba(0, 0, 0, 0.7)
+
+script.
+ @mixin \get-post-summary
+ @notification = @opts.notification
diff --git a/src/web/app/mobile/tags/notifications.tag b/src/web/app/mobile/tags/notifications.tag
new file mode 100644
index 0000000000..7510d59967
--- /dev/null
+++ b/src/web/app/mobile/tags/notifications.tag
@@ -0,0 +1,98 @@
+mk-notifications
+ div.notifications(if={ notifications.length != 0 })
+ virtual(each={ notification, i in notifications })
+ mk-notification(notification={ notification })
+
+ p.date(if={ i != notifications.length - 1 && notification._date != notifications[i + 1]._date })
+ span
+ i.fa.fa-angle-up
+ | { notification._datetext }
+ span
+ i.fa.fa-angle-down
+ | { notifications[i + 1]._datetext }
+
+ p.empty(if={ notifications.length == 0 && !loading })
+ | ありません!
+ p.loading(if={ loading })
+ i.fa.fa-spinner.fa-pulse.fa-fw
+ | 読み込んでいます
+ mk-ellipsis
+
+style.
+ display block
+ background #fff
+
+ > .notifications
+ margin 0 auto
+ max-width 500px
+
+ > mk-notification
+ border-bottom solid 1px rgba(0, 0, 0, 0.05)
+
+ &:last-child
+ border-bottom none
+
+ > .date
+ display block
+ margin 0
+ line-height 32px
+ text-align center
+ font-size 0.8em
+ color #aaa
+ background #fdfdfd
+ border-bottom solid 1px rgba(0, 0, 0, 0.05)
+
+ span
+ margin 0 16px
+
+ i
+ margin-right 8px
+
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > .loading
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+script.
+ @mixin \api
+ @mixin \stream
+ @mixin \get-post-summary
+
+ @notifications = []
+ @loading = true
+
+ @on \mount ~>
+ @api \i/notifications
+ .then (notifications) ~>
+ @notifications = notifications
+ @loading = false
+ @update!
+ @trigger \loaded
+ .catch (err, text-status) ->
+ console.error err
+
+ @stream.on \notification @on-notification
+
+ @on \unmount ~>
+ @stream.off \notification @on-notification
+
+ @on-notification = (notification) ~>
+ @notifications.unshift notification
+ @update!
+
+ @on \update ~>
+ @notifications.for-each (notification) ~>
+ date = (new Date notification.created_at).get-date!
+ month = (new Date notification.created_at).get-month! + 1
+ notification._date = date
+ notification._datetext = month + '月 ' + date + '日'
diff --git a/src/web/app/mobile/tags/notify.tag b/src/web/app/mobile/tags/notify.tag
new file mode 100644
index 0000000000..9dd93ccf25
--- /dev/null
+++ b/src/web/app/mobile/tags/notify.tag
@@ -0,0 +1,35 @@
+mk-notify
+ mk-notification-preview(notification={ opts.notification })
+
+style.
+ display block
+ position fixed
+ z-index 1024
+ bottom -64px
+ left 0
+ width 100%
+ height 64px
+ pointer-events none
+ -webkit-backdrop-filter blur(2px)
+ backdrop-filter blur(2px)
+ background-color rgba(#000, 0.5)
+
+script.
+ @on \mount ~>
+ Velocity @root, {
+ bottom: \0px
+ } {
+ duration: 500ms
+ easing: \ease-out
+ }
+
+ set-timeout ~>
+ Velocity @root, {
+ bottom: \-64px
+ } {
+ duration: 500ms
+ easing: \ease-out
+ complete: ~>
+ @unmount!
+ }
+ , 6000ms
diff --git a/src/web/app/mobile/tags/page/drive.tag b/src/web/app/mobile/tags/page/drive.tag
new file mode 100644
index 0000000000..9bef7e6648
--- /dev/null
+++ b/src/web/app/mobile/tags/page/drive.tag
@@ -0,0 +1,46 @@
+mk-drive-page
+ mk-ui@ui: mk-drive@browser(folder={ parent.opts.folder }, file={ parent.opts.file })
+
+style.
+ display block
+
+script.
+ @mixin \ui
+ @mixin \ui-progress
+
+ @on \mount ~>
+ document.title = 'Misskey Drive'
+ @ui.trigger \title '<i class="fa fa-cloud"></i>ドライブ'
+
+ @refs.ui.refs.browser.on \begin-load ~>
+ @Progress.start!
+
+ @refs.ui.refs.browser.on \loaded-mid ~>
+ @Progress.set 0.5
+
+ @refs.ui.refs.browser.on \loaded ~>
+ @Progress.done!
+
+ @refs.ui.refs.browser.on \move-root ~>
+ @ui.trigger \title '<i class="fa fa-cloud"></i>ドライブ'
+
+ # Rewrite URL
+ history.push-state null null '/i/drive'
+
+ @refs.ui.refs.browser.on \cd (folder) ~>
+ # TODO: escape html characters in folder.name
+ @ui.trigger \title '<i class="fa fa-folder-open"></i>' + folder.name
+
+ @refs.ui.refs.browser.on \move (folder) ~>
+ # Rewrite URL
+ history.push-state null null '/i/drive/folder/' + folder.id
+
+ @refs.ui.refs.browser.on \open-file (file) ~>
+ # TODO: escape html characters in file.name
+ @ui.trigger \title '<mk-file-type-icon class="icon"></mk-file-type-icon>' + file.name
+
+ # Rewrite URL
+ history.push-state null null '/i/drive/file/' + file.id
+
+ riot.mount \mk-file-type-icon do
+ file: file
diff --git a/src/web/app/mobile/tags/page/entrance.tag b/src/web/app/mobile/tags/page/entrance.tag
new file mode 100644
index 0000000000..67d8bc9bbf
--- /dev/null
+++ b/src/web/app/mobile/tags/page/entrance.tag
@@ -0,0 +1,57 @@
+mk-entrance
+ main
+ img(src='/_/resources/title.svg', alt='Misskey')
+
+ mk-entrance-signin(if={ mode == 'signin' })
+ mk-entrance-signup(if={ mode == 'signup' })
+ div.introduction(if={ mode == 'introduction' })
+ mk-introduction
+ button(onclick={ signin }) わかった
+
+ footer
+ mk-copyright
+
+style.
+ display block
+ height 100%
+
+ > main
+ display block
+
+ > img
+ display block
+ width 130px
+ height 120px
+ margin 0 auto
+
+ > .introduction
+ max-width 300px
+ margin 0 auto
+ color #666
+
+ > button
+ display block
+ margin 16px auto 0 auto
+
+ > footer
+ > mk-copyright
+ margin 0
+ text-align center
+ line-height 64px
+ font-size 10px
+ color rgba(#000, 0.5)
+
+script.
+ @mode = \signin
+
+ @signup = ~>
+ @mode = \signup
+ @update!
+
+ @signin = ~>
+ @mode = \signin
+ @update!
+
+ @introduction = ~>
+ @mode = \introduction
+ @update!
diff --git a/src/web/app/mobile/tags/page/entrance/signin.tag b/src/web/app/mobile/tags/page/entrance/signin.tag
new file mode 100644
index 0000000000..484c414e8e
--- /dev/null
+++ b/src/web/app/mobile/tags/page/entrance/signin.tag
@@ -0,0 +1,45 @@
+mk-entrance-signin
+ mk-signin
+ div.divider: span or
+ button.signup(onclick={ parent.signup }) 新規登録
+ a.introduction(onclick={ parent.introduction }) Misskeyについて
+
+style.
+ display block
+ margin 0 auto
+ padding 0 8px
+ max-width 350px
+ text-align center
+
+ > .signup
+ padding 16px
+ width 100%
+ font-size 1em
+ color #fff
+ background $theme-color
+ border-radius 3px
+
+ > .divider
+ padding 16px 0
+ text-align center
+
+ &:after
+ content ""
+ display block
+ position absolute
+ top 50%
+ width 100%
+ height 1px
+ border-top solid 1px rgba(0, 0, 0, 0.1)
+
+ > *
+ z-index 1
+ padding 0 8px
+ color rgba(0, 0, 0, 0.5)
+ background #fdfdfd
+
+ > .introduction
+ display inline-block
+ margin-top 16px
+ font-size 12px
+ color #666
diff --git a/src/web/app/mobile/tags/page/entrance/signup.tag b/src/web/app/mobile/tags/page/entrance/signup.tag
new file mode 100644
index 0000000000..a28f85e634
--- /dev/null
+++ b/src/web/app/mobile/tags/page/entrance/signup.tag
@@ -0,0 +1,35 @@
+mk-entrance-signup
+ mk-signup
+ button.cancel(type='button', onclick={ parent.signin }, title='キャンセル'): i.fa.fa-times
+
+style.
+ display block
+ margin 0 auto
+ padding 0 8px
+ max-width 350px
+
+ > .cancel
+ cursor pointer
+ display block
+ position absolute
+ top 0
+ right 0
+ z-index 1
+ margin 0
+ padding 0
+ font-size 1.2em
+ color #999
+ border none
+ outline none
+ box-shadow none
+ background transparent
+ transition opacity 0.1s ease
+
+ &:hover
+ color #555
+
+ &:active
+ color #222
+
+ > i
+ padding 14px
diff --git a/src/web/app/mobile/tags/page/home.tag b/src/web/app/mobile/tags/page/home.tag
new file mode 100644
index 0000000000..c8d7729652
--- /dev/null
+++ b/src/web/app/mobile/tags/page/home.tag
@@ -0,0 +1,40 @@
+mk-home-page
+ mk-ui@ui: mk-home@home
+
+style.
+ display block
+
+script.
+ @mixin \i
+ @mixin \ui
+ @mixin \ui-progress
+ @mixin \stream
+ @mixin \get-post-summary
+
+ @unread-count = 0
+
+ @on \mount ~>
+ document.title = 'Misskey'
+ @ui.trigger \title '<i class="fa fa-home"></i>ホーム'
+
+ @Progress.start!
+
+ @stream.on \post @on-stream-post
+ document.add-event-listener \visibilitychange @window-on-visibilitychange, false
+
+ @refs.ui.refs.home.on \loaded ~>
+ @Progress.done!
+
+ @on \unmount ~>
+ @stream.off \post @on-stream-post
+ document.remove-event-listener \visibilitychange @window-on-visibilitychange
+
+ @on-stream-post = (post) ~>
+ if document.hidden and post.user_id !== @I.id
+ @unread-count++
+ document.title = '(' + @unread-count + ') ' + @get-post-summary post
+
+ @window-on-visibilitychange = ~>
+ if !document.hidden
+ @unread-count = 0
+ document.title = 'Misskey'
diff --git a/src/web/app/mobile/tags/page/new-post.tag b/src/web/app/mobile/tags/page/new-post.tag
new file mode 100644
index 0000000000..21e00fc1f9
--- /dev/null
+++ b/src/web/app/mobile/tags/page/new-post.tag
@@ -0,0 +1,5 @@
+mk-new-post-page
+ mk-post-form@form
+
+style.
+ display block
diff --git a/src/web/app/mobile/tags/page/notifications.tag b/src/web/app/mobile/tags/page/notifications.tag
new file mode 100644
index 0000000000..9fb34dcd75
--- /dev/null
+++ b/src/web/app/mobile/tags/page/notifications.tag
@@ -0,0 +1,18 @@
+mk-notifications-page
+ mk-ui@ui: mk-notifications@notifications
+
+style.
+ display block
+
+script.
+ @mixin \ui
+ @mixin \ui-progress
+
+ @on \mount ~>
+ document.title = 'Misskey | 通知'
+ @ui.trigger \title '<i class="fa fa-bell-o"></i>通知'
+
+ @Progress.start!
+
+ @refs.ui.refs.notifications.on \loaded ~>
+ @Progress.done!
diff --git a/src/web/app/mobile/tags/page/post.tag b/src/web/app/mobile/tags/page/post.tag
new file mode 100644
index 0000000000..1dc74d267a
--- /dev/null
+++ b/src/web/app/mobile/tags/page/post.tag
@@ -0,0 +1,31 @@
+mk-post-page
+ mk-ui@ui: main: mk-post-detail@post(post={ parent.post })
+
+style.
+ display block
+
+ main
+ background #fff
+
+ > mk-post-detail
+ width 100%
+ max-width 500px
+ margin 0 auto
+
+script.
+ @mixin \ui
+ @mixin \ui-progress
+
+ @post = @opts.post
+
+ @on \mount ~>
+ document.title = 'Misskey'
+ @ui.trigger \title '<i class="fa fa-sticky-note-o"></i>投稿'
+
+ @Progress.start!
+
+ @refs.ui.refs.post.on \post-fetched ~>
+ @Progress.set 0.5
+
+ @refs.ui.refs.post.on \loaded ~>
+ @Progress.done!
diff --git a/src/web/app/mobile/tags/page/search.tag b/src/web/app/mobile/tags/page/search.tag
new file mode 100644
index 0000000000..20de271f73
--- /dev/null
+++ b/src/web/app/mobile/tags/page/search.tag
@@ -0,0 +1,19 @@
+mk-search-page
+ mk-ui@ui: mk-search@search(query={ parent.opts.query })
+
+style.
+ display block
+
+script.
+ @mixin \ui
+ @mixin \ui-progress
+
+ @on \mount ~>
+ document.title = '検索: ' + @opts.query + ' | Misskey'
+ # TODO: クエリをHTMLエスケープ
+ @ui.trigger \title '<i class="fa fa-search"></i>' + @opts.query
+
+ @Progress.start!
+
+ @refs.ui.refs.search.on \loaded ~>
+ @Progress.done!
diff --git a/src/web/app/mobile/tags/page/user-followers.tag b/src/web/app/mobile/tags/page/user-followers.tag
new file mode 100644
index 0000000000..e7e9a6fd1e
--- /dev/null
+++ b/src/web/app/mobile/tags/page/user-followers.tag
@@ -0,0 +1,31 @@
+mk-user-followers-page
+ mk-ui@ui: mk-user-followers@list(if={ !parent.fetching }, user={ parent.user })
+
+style.
+ display block
+
+script.
+ @mixin \ui
+ @mixin \ui-progress
+ @mixin \api
+
+ @fetching = true
+ @user = null
+
+ @on \mount ~>
+ @Progress.start!
+
+ @api \users/show do
+ username: @opts.user
+ .then (user) ~>
+ @user = user
+ @fetching = false
+
+ document.title = user.name + 'のフォロワー | Misskey'
+ # TODO: ユーザー名をエスケープ
+ @ui.trigger \title '<img src="' + user.avatar_url + '?thumbnail&size=64">' + user.name + 'のフォロー'
+
+ @update!
+
+ @refs.ui.refs.list.on \loaded ~>
+ @Progress.done!
diff --git a/src/web/app/mobile/tags/page/user-following.tag b/src/web/app/mobile/tags/page/user-following.tag
new file mode 100644
index 0000000000..a74ba97b72
--- /dev/null
+++ b/src/web/app/mobile/tags/page/user-following.tag
@@ -0,0 +1,31 @@
+mk-user-following-page
+ mk-ui@ui: mk-user-following@list(if={ !parent.fetching }, user={ parent.user })
+
+style.
+ display block
+
+script.
+ @mixin \ui
+ @mixin \ui-progress
+ @mixin \api
+
+ @fetching = true
+ @user = null
+
+ @on \mount ~>
+ @Progress.start!
+
+ @api \users/show do
+ username: @opts.user
+ .then (user) ~>
+ @user = user
+ @fetching = false
+
+ document.title = user.name + 'のフォロー | Misskey'
+ # TODO: ユーザー名をエスケープ
+ @ui.trigger \title '<img src="' + user.avatar_url + '?thumbnail&size=64">' + user.name + 'のフォロー'
+
+ @update!
+
+ @refs.ui.refs.list.on \loaded ~>
+ @Progress.done!
diff --git a/src/web/app/mobile/tags/page/user.tag b/src/web/app/mobile/tags/page/user.tag
new file mode 100644
index 0000000000..9667abfd14
--- /dev/null
+++ b/src/web/app/mobile/tags/page/user.tag
@@ -0,0 +1,20 @@
+mk-user-page
+ mk-ui@ui: mk-user@user(user={ parent.user }, page={ parent.opts.page })
+
+style.
+ display block
+
+script.
+ @mixin \ui
+ @mixin \ui-progress
+
+ @user = @opts.user
+
+ @on \mount ~>
+ @Progress.start!
+
+ @refs.ui.refs.user.on \loaded (user) ~>
+ @Progress.done!
+ document.title = user.name + ' | Misskey'
+ # TODO: ユーザー名をエスケープ
+ @ui.trigger \title '<i class="fa fa-user"></i>' + user.name
diff --git a/src/web/app/mobile/tags/post-detail.tag b/src/web/app/mobile/tags/post-detail.tag
new file mode 100644
index 0000000000..c7eb091ce6
--- /dev/null
+++ b/src/web/app/mobile/tags/post-detail.tag
@@ -0,0 +1,415 @@
+mk-post-detail
+
+ div.fetching(if={ fetching })
+ mk-ellipsis-icon
+
+ div.main(if={ !fetching })
+
+ button.read-more(if={ p.reply_to && p.reply_to.reply_to_id && context == null }, onclick={ load-context }, disabled={ loading-context })
+ i.fa.fa-ellipsis-v(if={ !loading-context })
+ i.fa.fa-spinner.fa-pulse(if={ loading-context })
+
+ div.context
+ virtual(each={ post in context })
+ mk-post-preview(post={ post })
+
+ div.reply-to(if={ p.reply_to })
+ mk-post-preview(post={ p.reply_to })
+
+ div.repost(if={ is-repost })
+ p
+ a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=32' }, alt='avatar')
+ i.fa.fa-retweet
+ a.name(href={ CONFIG.url + '/' + post.user.username }) { post.user.name }
+ | がRepost
+
+ article
+ a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username })
+ img.avatar(src={ p.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ header
+ a.name(href={ CONFIG.url + '/' + p.user.username })
+ | { p.user.name }
+ span.username
+ | @{ p.user.username }
+ div.body
+ div.text@text
+ div.media(if={ p.media })
+ virtual(each={ file in p.media })
+ img(src={ file.url + '?thumbnail&size=512' }, alt={ file.name }, title={ file.name })
+ a.time(href={ url })
+ mk-time(time={ p.created_at }, mode='detail')
+ footer
+ button(onclick={ reply }, title='返信')
+ i.fa.fa-reply
+ p.count(if={ p.replies_count > 0 }) { p.replies_count }
+ button(onclick={ repost }, title='Repost')
+ i.fa.fa-retweet
+ p.count(if={ p.repost_count > 0 }) { p.repost_count }
+ button(class={ liked: p.is_liked }, onclick={ like }, title='善哉')
+ i.fa.fa-thumbs-o-up
+ p.count(if={ p.likes_count > 0 }) { p.likes_count }
+ button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h
+ div.reposts-and-likes
+ div.reposts(if={ reposts && reposts.length > 0 })
+ header
+ a { p.repost_count }
+ p Repost
+ ol.users
+ li.user(each={ reposts })
+ a.avatar-anchor(href={ CONFIG.url + '/' + user.username }, title={ user.name })
+ img.avatar(src={ user.avatar_url + '?thumbnail&size=32' }, alt='')
+ div.likes(if={ likes && likes.length > 0 })
+ header
+ a { p.likes_count }
+ p いいね
+ ol.users
+ li.user(each={ likes })
+ a.avatar-anchor(href={ CONFIG.url + '/' + username }, title={ name })
+ img.avatar(src={ avatar_url + '?thumbnail&size=32' }, alt='')
+
+ div.replies
+ virtual(each={ post in replies })
+ mk-post-detail-sub(post={ post })
+
+style.
+ display block
+ margin 0
+ padding 0
+
+ > .fetching
+ padding 64px 0
+
+ > .main
+
+ > .read-more
+ display block
+ margin 0
+ padding 10px 0
+ width 100%
+ font-size 1em
+ text-align center
+ color #999
+ cursor pointer
+ background #fafafa
+ outline none
+ border none
+ border-bottom solid 1px #eef0f2
+ border-radius 6px 6px 0 0
+ box-shadow none
+
+ &:hover
+ background #f6f6f6
+
+ &:active
+ background #f0f0f0
+
+ &:disabled
+ color #ccc
+
+ > .context
+ > *
+ border-bottom 1px solid #eef0f2
+
+ > .repost
+ color #9dbb00
+ background linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+
+ > p
+ margin 0
+ padding 16px 32px
+
+ .avatar-anchor
+ display inline-block
+
+ .avatar
+ vertical-align bottom
+ min-width 28px
+ min-height 28px
+ max-width 28px
+ max-height 28px
+ margin 0 8px 0 0
+ border-radius 6px
+
+ i
+ margin-right 4px
+
+ .name
+ font-weight bold
+
+ & + article
+ padding-top 8px
+
+ > .reply-to
+ border-bottom 1px solid #eef0f2
+
+ > article
+ padding 14px 16px 9px 16px
+
+ @media (min-width 500px)
+ padding 28px 32px 18px 32px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ &:hover
+ > .main > footer > button
+ color #888
+
+ > .avatar-anchor
+ display block
+
+ > .avatar
+ display block
+ width 54px
+ height 54px
+ margin 0
+ border-radius 8px
+ vertical-align bottom
+
+ @media (min-width 500px)
+ width 60px
+ height 60px
+
+ > header
+ position absolute
+ top 18px
+ left 80px
+ width calc(100% - 80px)
+
+ @media (min-width 500px)
+ top 28px
+ left 108px
+ width calc(100% - 108px)
+
+ > .name
+ display inline-block
+ margin 0
+ color #777
+ font-size 16px
+ font-weight bold
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ display block
+ text-align left
+ margin 0
+ color #ccc
+
+ > .body
+ padding 8px 0
+
+ > .text
+ cursor default
+ display block
+ margin 0
+ padding 0
+ word-wrap break-word
+ font-size 16px
+ color #717171
+
+ @media (min-width 500px)
+ font-size 24px
+
+ > mk-url-preview
+ margin-top 8px
+
+ > .media
+ > img
+ display block
+ max-width 100%
+
+ > .time
+ font-size 16px
+ color #c0c0c0
+
+ > footer
+ font-size 1.2em
+
+ > button
+ margin 0 28px 0 0
+ padding 8px
+ background transparent
+ border none
+ box-shadow none
+ font-size 1em
+ color #ddd
+ cursor pointer
+
+ &:hover
+ color #666
+
+ > .count
+ display inline
+ margin 0 0 0 8px
+ color #999
+
+ &.liked
+ color $theme-color
+
+ > .reposts-and-likes
+ display flex
+ justify-content center
+ padding 0
+ margin 16px 0
+
+ &:empty
+ display none
+
+ > .reposts
+ > .likes
+ display flex
+ flex 1 1
+ padding 0
+ border-top solid 1px #F2EFEE
+
+ > header
+ flex 1 1 80px
+ max-width 80px
+ padding 8px 5px 0px 10px
+
+ > a
+ display block
+ font-size 1.5em
+ line-height 1.4em
+
+ > p
+ display block
+ margin 0
+ font-size 0.7em
+ line-height 1em
+ font-weight normal
+ color #a0a2a5
+
+ > .users
+ display block
+ flex 1 1
+ margin 0
+ padding 10px 10px 10px 5px
+ list-style none
+
+ > .user
+ display block
+ float left
+ margin 4px
+ padding 0
+
+ > .avatar-anchor
+ display:block
+
+ > .avatar
+ vertical-align bottom
+ width 24px
+ height 24px
+ border-radius 4px
+
+ > .reposts + .likes
+ margin-left 16px
+
+ > .replies
+ > *
+ border-top 1px solid #eef0f2
+
+script.
+ @mixin \api
+ @mixin \text
+ @mixin \get-post-summary
+ @mixin \open-post-form
+
+ @fetching = true
+ @loading-context = false
+ @content = null
+ @post = null
+
+ @on \mount ~>
+ @api \posts/show do
+ post_id: @opts.post
+ .then (post) ~>
+ @post = post
+ @is-repost = @post.repost?
+ @p = if @is-repost then @post.repost else @post
+ @summary = @get-post-summary @p
+ @trigger \loaded
+ @fetching = false
+ @update!
+
+ if @p.text?
+ tokens = @analyze @p.text
+ @refs.text.innerHTML = @compile tokens
+
+ @refs.text.children.for-each (e) ~>
+ if e.tag-name == \MK-URL
+ riot.mount e
+
+ # URLをプレビュー
+ tokens
+ .filter (t) -> t.type == \link
+ .map (t) ~>
+ @preview = @refs.text.append-child document.create-element \mk-url-preview
+ riot.mount @preview, do
+ url: t.content
+
+ # Get likes
+ @api \posts/likes do
+ post_id: @p.id
+ limit: 8
+ .then (likes) ~>
+ @likes = likes
+ @update!
+
+ # Get reposts
+ @api \posts/reposts do
+ post_id: @p.id
+ limit: 8
+ .then (reposts) ~>
+ @reposts = reposts
+ @update!
+
+ # Get replies
+ @api \posts/replies do
+ post_id: @p.id
+ limit: 8
+ .then (replies) ~>
+ @replies = replies
+ @update!
+
+ @reply = ~>
+ @open-post-form do
+ reply: @p
+
+ @repost = ~>
+ text = window.prompt '「' + @summary + '」をRepost'
+ if text?
+ @api \posts/create do
+ repost_id: @p.id
+ text: if text == '' then undefined else text
+
+ @like = ~>
+ if @p.is_liked
+ @api \posts/likes/delete do
+ post_id: @p.id
+ .then ~>
+ @p.is_liked = false
+ @update!
+ else
+ @api \posts/likes/create do
+ post_id: @p.id
+ .then ~>
+ @p.is_liked = true
+ @update!
+
+ @load-context = ~>
+ @loading-context = true
+
+ # Get context
+ @api \posts/context do
+ post_id: @p.reply_to_id
+ .then (context) ~>
+ @context = context.reverse!
+ @loading-context = false
+ @update!
diff --git a/src/web/app/mobile/tags/post-form.tag b/src/web/app/mobile/tags/post-form.tag
new file mode 100644
index 0000000000..759a0820b8
--- /dev/null
+++ b/src/web/app/mobile/tags/post-form.tag
@@ -0,0 +1,254 @@
+mk-post-form
+ header: div
+ button.cancel(onclick={ cancel }): i.fa.fa-times
+ div
+ span.text-count(class={ over: refs.text.value.length > 300 }) { 300 - refs.text.value.length }
+ button.submit(onclick={ post }) 投稿
+ div.form
+ mk-post-preview(if={ opts.reply }, post={ opts.reply })
+ textarea@text(disabled={ wait }, oninput={ update }, onkeypress={ onkeypress }, onpaste={ onpaste }, placeholder={ opts.reply ? 'この投稿への返信...' : 'いまどうしてる?' })
+ div.attaches(if={ files.length != 0 })
+ ul.files@attaches
+ li.file(each={ files })
+ div.img(style='background-image: url({ url + "?thumbnail&size=64" })', title={ name })
+ li.add(if={ files.length < 4 }, title='PCからファイルを添付', onclick={ select-file }): i.fa.fa-plus
+ mk-uploader@uploader
+ button@upload(onclick={ select-file }): i.fa.fa-upload
+ button@drive(onclick={ select-file-from-drive }): i.fa.fa-cloud
+ input@file(type='file', accept='image/*', multiple, onchange={ change-file })
+
+style.
+ display block
+ padding-top 50px
+
+ > header
+ position fixed
+ z-index 1000
+ top 0
+ left 0
+ width 100%
+ height 50px
+ background #fff
+
+ > div
+ max-width 500px
+ margin 0 auto
+
+ > .cancel
+ width 50px
+ line-height 50px
+ font-size 24px
+ color #555
+
+ > div
+ position absolute
+ top 0
+ right 0
+
+ > .text-count
+ line-height 50px
+ color #657786
+
+ > .submit
+ margin 8px
+ padding 0 16px
+ line-height 34px
+ color $theme-color-foreground
+ background $theme-color
+ border-radius 4px
+
+ &:disabled
+ opacity 0.7
+
+ > .form
+ max-width 500px
+ margin 0 auto
+
+ > mk-post-preview
+ padding 16px
+
+ > .attaches
+
+ > .files
+ display block
+ margin 0
+ padding 4px
+ list-style none
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .file
+ display block
+ float left
+ margin 4px
+ padding 0
+ cursor move
+
+ &:hover > .remove
+ display block
+
+ > .img
+ width 64px
+ height 64px
+ background-size cover
+ background-position center center
+
+ > .remove
+ display none
+ position absolute
+ top -6px
+ right -6px
+ width 16px
+ height 16px
+ cursor pointer
+
+ > .add
+ display block
+ float left
+ margin 4px
+ padding 0
+ border dashed 2px rgba($theme-color, 0.2)
+ cursor pointer
+
+ &:hover
+ border-color rgba($theme-color, 0.3)
+
+ > i
+ color rgba($theme-color, 0.4)
+
+ > i
+ display block
+ width 60px
+ height 60px
+ line-height 60px
+ text-align center
+ font-size 1.2em
+ color rgba($theme-color, 0.2)
+
+ > mk-uploader
+ margin 8px 0 0 0
+ padding 8px
+
+ > [ref='file']
+ display none
+
+ > [ref='text']
+ display block
+ padding 12px
+ margin 0
+ width 100%
+ max-width 100%
+ min-width 100%
+ min-height 80px
+ font-size 16px
+ color #333
+ border none
+ border-bottom solid 1px #ddd
+ border-radius 0
+
+ &:disabled
+ opacity 0.5
+
+ > [ref='upload']
+ > [ref='drive']
+ display inline-block
+ padding 0
+ margin 0
+ width 48px
+ height 48px
+ font-size 20px
+ color #657786
+ background transparent
+ outline none
+ border none
+ border-radius 0
+ box-shadow none
+
+script.
+ @mixin \api
+
+ @wait = false
+ @uploadings = []
+ @files = []
+
+ @on \mount ~>
+ @refs.uploader.on \uploaded (file) ~>
+ @add-file file
+
+ @refs.uploader.on \change-uploads (uploads) ~>
+ @trigger \change-uploading-files uploads
+
+ @refs.text.focus!
+
+ @onkeypress = (e) ~>
+ if (e.char-code == 10 || e.char-code == 13) && e.ctrl-key
+ @post!
+ else
+ return true
+
+ @onpaste = (e) ~>
+ data = e.clipboard-data
+ items = data.items
+ for i from 0 to items.length - 1
+ item = items[i]
+ switch (item.kind)
+ | \file =>
+ @upload item.get-as-file!
+ return true
+
+ @select-file = ~>
+ @refs.file.click!
+
+ @select-file-from-drive = ~>
+ browser = document.body.append-child document.create-element \mk-drive-selector
+ browser = riot.mount browser, do
+ multiple: true
+ .0
+ browser.on \selected (files) ~>
+ files.for-each @add-file
+
+ @change-file = ~>
+ files = @refs.file.files
+ for i from 0 to files.length - 1
+ file = files.item i
+ @upload file
+
+ @upload = (file) ~>
+ @refs.uploader.upload file
+
+ @add-file = (file) ~>
+ file._remove = ~>
+ @files = @files.filter (x) -> x.id != file.id
+ @trigger \change-files @files
+ @update!
+
+ @files.push file
+ @trigger \change-files @files
+ @update!
+
+ @post = ~>
+ @wait = true
+
+ files = if @files? and @files.length > 0
+ then @files.map (f) -> f.id
+ else undefined
+
+ @api \posts/create do
+ text: @refs.text.value
+ media_ids: files
+ reply_to_id: if @opts.reply? then @opts.reply.id else undefined
+ .then (data) ~>
+ @trigger \post
+ @unmount!
+ .catch (err) ~>
+ console.error err
+ #@opts.ui.trigger \notification 'Error!'
+ @wait = false
+ @update!
+
+ @cancel = ~>
+ @trigger \cancel
+ @unmount!
diff --git a/src/web/app/mobile/tags/post-preview.tag b/src/web/app/mobile/tags/post-preview.tag
new file mode 100644
index 0000000000..e15b2be244
--- /dev/null
+++ b/src/web/app/mobile/tags/post-preview.tag
@@ -0,0 +1,89 @@
+mk-post-preview
+ article
+ a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username })
+ img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.main
+ header
+ a.name(href={ CONFIG.url + '/' + post.user.username })
+ | { post.user.name }
+ span.username
+ | @{ post.user.username }
+ a.time(href={ CONFIG.url + '/' + post.user.username + '/' + post.id })
+ mk-time(time={ post.created_at })
+ div.body
+ mk-sub-post-content.text(post={ post })
+
+style.
+ display block
+ margin 0
+ padding 0
+ font-size 0.9em
+ background #fff
+
+ > article
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ &:hover
+ > .main > footer > button
+ color #888
+
+ > .avatar-anchor
+ display block
+ float left
+ margin 0 12px 0 0
+
+ > .avatar
+ display block
+ width 48px
+ height 48px
+ margin 0
+ border-radius 8px
+ vertical-align bottom
+
+ > .main
+ float left
+ width calc(100% - 60px)
+
+ > header
+ margin-bottom 4px
+ white-space nowrap
+
+ > .name
+ display inline
+ margin 0
+ padding 0
+ color #607073
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 0 0 8px
+ color #d1d8da
+
+ > .time
+ position absolute
+ top 0
+ right 0
+ color #b2b8bb
+
+ > .body
+
+ > .text
+ cursor default
+ margin 0
+ padding 0
+ font-size 1.1em
+ color #717171
+
+script.
+ @post = @opts.post
diff --git a/src/web/app/mobile/tags/search-posts.tag b/src/web/app/mobile/tags/search-posts.tag
new file mode 100644
index 0000000000..4b1b12af27
--- /dev/null
+++ b/src/web/app/mobile/tags/search-posts.tag
@@ -0,0 +1,29 @@
+mk-search-posts
+ mk-timeline(init={ init }, more={ more }, empty={ '「' + query + '」に関する投稿は見つかりませんでした。' })
+
+style.
+ display block
+ background #fff
+
+script.
+ @mixin \api
+
+ @max = 30
+ @offset = 0
+
+ @query = @opts.query
+ @with-media = @opts.with-media
+
+ @init = new Promise (res, rej) ~>
+ @api \posts/search do
+ query: @query
+ .then (posts) ~>
+ res posts
+ @trigger \loaded
+
+ @more = ~>
+ @offset += @max
+ @api \posts/search do
+ query: @query
+ max: @max
+ offset: @offset
diff --git a/src/web/app/mobile/tags/search.tag b/src/web/app/mobile/tags/search.tag
new file mode 100644
index 0000000000..bf2299cc9b
--- /dev/null
+++ b/src/web/app/mobile/tags/search.tag
@@ -0,0 +1,12 @@
+mk-search
+ mk-search-posts@posts(query={ query })
+
+style.
+ display block
+
+script.
+ @query = @opts.query
+
+ @on \mount ~>
+ @refs.posts.on \loaded ~>
+ @trigger \loaded
diff --git a/src/web/app/mobile/tags/stream-indicator.tag b/src/web/app/mobile/tags/stream-indicator.tag
new file mode 100644
index 0000000000..2eb5889ca6
--- /dev/null
+++ b/src/web/app/mobile/tags/stream-indicator.tag
@@ -0,0 +1,59 @@
+mk-stream-indicator
+ p(if={ state == 'initializing' })
+ i.fa.fa-spinner.fa-spin
+ span
+ | 接続中
+ mk-ellipsis
+ p(if={ state == 'reconnecting' })
+ i.fa.fa-spinner.fa-spin
+ span
+ | 切断されました 接続中
+ mk-ellipsis
+ p(if={ state == 'connected' })
+ i.fa.fa-check
+ span 接続完了
+
+style.
+ display block
+ pointer-events none
+ position fixed
+ z-index 16384
+ bottom 8px
+ right 8px
+ margin 0
+ padding 6px 12px
+ font-size 0.9em
+ color #fff
+ background rgba(0, 0, 0, 0.8)
+
+ > p
+ display block
+ margin 0
+
+ > i
+ margin-right 0.25em
+
+script.
+ @mixin \stream
+
+ @on \before-mount ~>
+ @state = @get-stream-state!
+
+ if @state == \connected
+ @root.style.opacity = 0
+
+ @stream-state-ev.on \connected ~>
+ @state = @get-stream-state!
+ @update!
+ set-timeout ~>
+ Velocity @root, {
+ opacity: 0
+ } 200ms \linear
+ , 1000ms
+
+ @stream-state-ev.on \closed ~>
+ @state = @get-stream-state!
+ @update!
+ Velocity @root, {
+ opacity: 1
+ } 0ms
diff --git a/src/web/app/mobile/tags/sub-post-content.tag b/src/web/app/mobile/tags/sub-post-content.tag
new file mode 100644
index 0000000000..595f63d794
--- /dev/null
+++ b/src/web/app/mobile/tags/sub-post-content.tag
@@ -0,0 +1,36 @@
+mk-sub-post-content
+ div.body
+ a.reply(if={ post.reply_to_id }): i.fa.fa-reply
+ span@text
+ a.quote(if={ post.repost_id }, href={ '/post:' + post.repost_id }) RP: ...
+ details(if={ post.media })
+ summary ({ post.media.length }枚の画像)
+ mk-images-viewer(images={ post.media })
+
+style.
+ display block
+ word-wrap break-word
+
+ > .body
+ > .reply
+ margin-right 6px
+ color #717171
+
+ > .quote
+ margin-left 4px
+ font-style oblique
+ color #a0bf46
+
+script.
+ @mixin \text
+
+ @post = @opts.post
+
+ @on \mount ~>
+ if @post.text?
+ tokens = @analyze @post.text
+ @refs.text.innerHTML = @compile tokens, false
+
+ @refs.text.children.for-each (e) ~>
+ if e.tag-name == \MK-URL
+ riot.mount e
diff --git a/src/web/app/mobile/tags/timeline-post-sub.tag b/src/web/app/mobile/tags/timeline-post-sub.tag
new file mode 100644
index 0000000000..920503ebcc
--- /dev/null
+++ b/src/web/app/mobile/tags/timeline-post-sub.tag
@@ -0,0 +1,99 @@
+mk-timeline-post-sub
+ article
+ a.avatar-anchor(href={ '/' + post.user.username })
+ img.avatar(src={ post.user.avatar_url + '?thumbnail&size=96' }, alt='avatar')
+ div.main
+ header
+ a.name(href={ '/' + post.user.username })
+ | { post.user.name }
+ span.username
+ | @{ post.user.username }
+ a.created-at(href={ '/' + post.user.username + '/' + post.id })
+ mk-time(time={ post.created_at })
+ div.body
+ mk-sub-post-content.text(post={ post })
+
+style.
+ display block
+ margin 0
+ padding 0
+ font-size 0.9em
+
+ > article
+ padding 16px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ &:hover
+ > .main > footer > button
+ color #888
+
+ > .avatar-anchor
+ display block
+ float left
+ margin 0 10px 0 0
+
+ @media (min-width 500px)
+ margin-right 16px
+
+ > .avatar
+ display block
+ width 44px
+ height 44px
+ margin 0
+ border-radius 8px
+ vertical-align bottom
+
+ @media (min-width 500px)
+ width 52px
+ height 52px
+
+ > .main
+ float left
+ width calc(100% - 54px)
+
+ @media (min-width 500px)
+ width calc(100% - 68px)
+
+ > header
+ margin-bottom 4px
+ white-space nowrap
+
+ > .name
+ display inline
+ margin 0
+ padding 0
+ color #607073
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 0 0 8px
+ color #d1d8da
+
+ > .created-at
+ position absolute
+ top 0
+ right 0
+ color #b2b8bb
+
+ > .body
+
+ > .text
+ cursor default
+ margin 0
+ padding 0
+ font-size 1.1em
+ color #717171
+
+script.
+ @post = @opts.post
diff --git a/src/web/app/mobile/tags/timeline-post.tag b/src/web/app/mobile/tags/timeline-post.tag
new file mode 100644
index 0000000000..a71fab26f0
--- /dev/null
+++ b/src/web/app/mobile/tags/timeline-post.tag
@@ -0,0 +1,296 @@
+mk-timeline-post(class={ repost: is-repost })
+
+ div.reply-to(if={ p.reply_to })
+ mk-timeline-post-sub(post={ p.reply_to })
+
+ div.repost(if={ is-repost })
+ p
+ a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ i.fa.fa-retweet
+ a.name(href={ CONFIG.url + '/' + post.user.username }) { post.user.name }
+ | がRepost
+ mk-time(time={ post.created_at })
+
+ article
+ a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username })
+ img.avatar(src={ p.user.avatar_url + '?thumbnail&size=96' }, alt='avatar')
+ div.main
+ header
+ a.name(href={ CONFIG.url + '/' + p.user.username })
+ | { p.user.name }
+ span.username
+ | @{ p.user.username }
+ a.created-at(href={ url })
+ mk-time(time={ p.created_at })
+ div.body
+ div.text
+ a.reply(if={ p.reply_to }): i.fa.fa-reply
+ soan@text
+ a.quote(if={ p.repost != null }) RP:
+ div.media(if={ p.media })
+ mk-images-viewer(images={ p.media })
+ div.repost(if={ p.repost })
+ i.fa.fa-quote-right.fa-flip-horizontal
+ mk-post-preview.repost(post={ p.repost })
+ footer
+ button(onclick={ reply })
+ i.fa.fa-reply
+ p.count(if={ p.replies_count > 0 }) { p.replies_count }
+ button(onclick={ repost }, title='Repost')
+ i.fa.fa-retweet
+ p.count(if={ p.repost_count > 0 }) { p.repost_count }
+ button(class={ liked: p.is_liked }, onclick={ like })
+ i.fa.fa-thumbs-o-up
+ p.count(if={ p.likes_count > 0 }) { p.likes_count }
+
+style.
+ display block
+ margin 0
+ padding 0
+ font-size 12px
+
+ @media (min-width 350px)
+ font-size 14px
+
+ @media (min-width 500px)
+ font-size 16px
+
+ > .repost
+ color #9dbb00
+ background linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+
+ > p
+ margin 0
+ padding 8px 16px
+ line-height 28px
+
+ @media (min-width 500px)
+ padding 16px
+
+ .avatar-anchor
+ display inline-block
+
+ .avatar
+ vertical-align bottom
+ width 28px
+ height 28px
+ margin 0 8px 0 0
+ border-radius 6px
+
+ i
+ margin-right 4px
+
+ .name
+ font-weight bold
+
+ > mk-time
+ position absolute
+ top 8px
+ right 16px
+ font-size 0.9em
+ line-height 28px
+
+ @media (min-width 500px)
+ top 16px
+
+ & + article
+ padding-top 8px
+
+ > .reply-to
+ background rgba(0, 0, 0, 0.0125)
+
+ > mk-post-preview
+ background transparent
+
+ > article
+ padding 14px 16px 9px 16px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .avatar-anchor
+ display block
+ float left
+ margin 0 10px 0 0
+
+ @media (min-width 500px)
+ margin-right 16px
+
+ > .avatar
+ display block
+ width 48px
+ height 48px
+ margin 0
+ border-radius 6px
+ vertical-align bottom
+
+ @media (min-width 500px)
+ width 58px
+ height 58px
+ border-radius 8px
+
+ > .main
+ float left
+ width calc(100% - 58px)
+
+ @media (min-width 500px)
+ width calc(100% - 74px)
+
+ > header
+ white-space nowrap
+
+ @media (min-width 500px)
+ margin-bottom 2px
+
+ > .name
+ display inline
+ margin 0
+ padding 0
+ color #777
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 0 0 8px
+ color #ccc
+
+ > .created-at
+ position absolute
+ top 0
+ right 0
+ font-size 0.9em
+ color #c0c0c0
+
+ > .body
+
+ > .text
+ cursor default
+ display block
+ margin 0
+ padding 0
+ word-wrap break-word
+ font-size 1.1em
+ color #717171
+
+ mk-url-preview
+ margin-top 8px
+
+ > .reply
+ margin-right 8px
+ color #717171
+
+ > .quote
+ margin-left 4px
+ font-style oblique
+ color #a0bf46
+
+ > .media
+ > img
+ display block
+ max-width 100%
+
+ > .repost
+ margin 8px 0
+
+ > i:first-child
+ position absolute
+ top -8px
+ left -8px
+ z-index 1
+ color #c0dac6
+ font-size 28px
+ background #fff
+
+ > mk-post-preview
+ padding 16px
+ border dashed 1px #c0dac6
+ border-radius 8px
+
+ > footer
+ > button
+ margin 0 28px 0 0
+ padding 8px
+ background transparent
+ border none
+ box-shadow none
+ font-size 1em
+ color #ddd
+ cursor pointer
+
+ &:hover
+ color #666
+
+ > .count
+ display inline
+ margin 0 0 0 8px
+ color #999
+
+ &.liked
+ color $theme-color
+
+script.
+ @mixin \api
+ @mixin \text
+ @mixin \get-post-summary
+ @mixin \open-post-form
+
+ @post = @opts.post
+ @is-repost = @post.repost? and !@post.text?
+ @p = if @is-repost then @post.repost else @post
+ @summary = @get-post-summary @p
+ @url = CONFIG.url + '/' + @p.user.username + '/' + @p.id
+
+ @on \mount ~>
+ if @p.text?
+ tokens = if @p._highlight?
+ then @analyze @p._highlight
+ else @analyze @p.text
+
+ @refs.text.innerHTML = if @p._highlight?
+ then @compile tokens, true, false
+ else @compile tokens
+
+ @refs.text.children.for-each (e) ~>
+ if e.tag-name == \MK-URL
+ riot.mount e
+
+ # URLをプレビュー
+ tokens
+ .filter (t) -> t.type == \link
+ .map (t) ~>
+ @preview = @refs.text.append-child document.create-element \mk-url-preview
+ riot.mount @preview, do
+ url: t.content
+
+ @reply = ~>
+ @open-post-form do
+ reply: @p
+
+ @repost = ~>
+ text = window.prompt '「' + @summary + '」をRepost'
+ if text?
+ @api \posts/create do
+ repost_id: @p.id
+ text: if text == '' then undefined else text
+
+ @like = ~>
+ if @p.is_liked
+ @api \posts/likes/delete do
+ post_id: @p.id
+ .then ~>
+ @p.is_liked = false
+ @update!
+ else
+ @api \posts/likes/create do
+ post_id: @p.id
+ .then ~>
+ @p.is_liked = true
+ @update!
diff --git a/src/web/app/mobile/tags/timeline.tag b/src/web/app/mobile/tags/timeline.tag
new file mode 100644
index 0000000000..7114824872
--- /dev/null
+++ b/src/web/app/mobile/tags/timeline.tag
@@ -0,0 +1,128 @@
+mk-timeline
+ div.init(if={ init })
+ i.fa.fa-spinner.fa-pulse
+ | 読み込んでいます
+ div.empty(if={ !init && posts.length == 0 })
+ i.fa.fa-comments-o
+ | { opts.empty || '表示するものがありません' }
+ virtual(each={ post, i in posts })
+ mk-timeline-post(post={ post })
+ p.date(if={ i != posts.length - 1 && post._date != posts[i + 1]._date })
+ span
+ i.fa.fa-angle-up
+ | { post._datetext }
+ span
+ i.fa.fa-angle-down
+ | { posts[i + 1]._datetext }
+ footer(if={ !init })
+ button(if={ can-fetch-more }, onclick={ more }, disabled={ fetching })
+ span(if={ !fetching }) もっとみる
+ span(if={ fetching })
+ | 読み込み中
+ mk-ellipsis
+
+style.
+ display block
+ background #fff
+ background-clip content-box
+ overflow hidden
+
+ > .init
+ padding 64px 0
+ text-align center
+ color #999
+
+ > i
+ margin-right 4px
+
+ > .empty
+ margin 0 auto
+ padding 32px
+ max-width 400px
+ text-align center
+ color #999
+
+ > i
+ display block
+ margin-bottom 16px
+ font-size 3em
+ color #ccc
+
+ > mk-timeline-post
+ border-bottom solid 1px #eaeaea
+
+ &:last-of-type
+ border-bottom none
+
+ > .date
+ display block
+ margin 0
+ line-height 32px
+ text-align center
+ font-size 0.9em
+ color #aaa
+ background #fdfdfd
+ border-bottom solid 1px #eaeaea
+
+ span
+ margin 0 16px
+
+ i
+ margin-right 8px
+
+ > footer
+ text-align center
+ border-top solid 1px #eaeaea
+ border-bottom-left-radius 4px
+ border-bottom-right-radius 4px
+
+ > button
+ margin 0
+ padding 16px
+ width 100%
+ color $theme-color
+
+ &:disabled
+ opacity 0.7
+
+script.
+ @posts = []
+ @init = true
+ @fetching = false
+ @can-fetch-more = true
+
+ @on \mount ~>
+ @opts.init.then (posts) ~>
+ @init = false
+ @set-posts posts
+
+ @on \update ~>
+ @posts.for-each (post) ~>
+ date = (new Date post.created_at).get-date!
+ month = (new Date post.created_at).get-month! + 1
+ post._date = date
+ post._datetext = month + '月 ' + date + '日'
+
+ @more = ~>
+ if @init or @fetching or @posts.length == 0 then return
+ @fetching = true
+ @update!
+ @opts.more!.then (posts) ~>
+ @fetching = false
+ @prepend-posts posts
+
+ @set-posts = (posts) ~>
+ @posts = posts
+ @update!
+
+ @prepend-posts = (posts) ~>
+ posts.for-each (post) ~>
+ @posts.push post
+ @update!
+
+ @add-post = (post) ~>
+ @posts.unshift post
+ @update!
+
+ @tail = ~>
+ @posts[@posts.length - 1]
diff --git a/src/web/app/mobile/tags/ui-header.tag b/src/web/app/mobile/tags/ui-header.tag
new file mode 100644
index 0000000000..7105d065f8
--- /dev/null
+++ b/src/web/app/mobile/tags/ui-header.tag
@@ -0,0 +1,98 @@
+mk-ui-header
+ mk-special-message
+ div.main
+ div.backdrop
+ div.content
+ button.nav#hamburger: i.fa.fa-bars
+ h1@title Misskey
+ button.post(onclick={ post }): i.fa.fa-pencil
+
+style.
+ $height = 48px
+
+ display block
+ position fixed
+ top 0
+ z-index 1024
+ width 100%
+ box-shadow 0 1px 0 rgba(#000, 0.075)
+
+ > .main
+ color rgba(#000, 0.6)
+
+ > .backdrop
+ position absolute
+ top 0
+ z-index 1023
+ width 100%
+ height $height
+ -webkit-backdrop-filter blur(12px)
+ backdrop-filter blur(12px)
+ background-color rgba(#fff, 0.75)
+
+ > .content
+ z-index 1024
+
+ > h1
+ display block
+ margin 0 auto
+ padding 0
+ width 100%
+ max-width calc(100% - 112px)
+ text-align center
+ font-size 1.1em
+ font-weight normal
+ line-height $height
+ white-space nowrap
+ overflow hidden
+ text-overflow ellipsis
+
+ > i
+ margin-right 8px
+
+ > img
+ display inline-block
+ vertical-align bottom
+ width ($height - 16px)
+ height ($height - 16px)
+ margin 8px
+ border-radius 6px
+
+ > .nav
+ display block
+ position absolute
+ top 0
+ left 0
+ width $height
+ font-size 1.4em
+ line-height $height
+ border-right solid 1px rgba(#000, 0.1)
+
+ > i
+ transition all 0.2s ease
+
+ > .post
+ display block
+ position absolute
+ top 0
+ right 0
+ width $height
+ text-align center
+ font-size 1.4em
+ color inherit
+ line-height $height
+ border-left solid 1px rgba(#000, 0.1)
+
+script.
+ @mixin \ui
+ @mixin \open-post-form
+
+ @on \mount ~>
+ @opts.ready!
+
+ @ui.one \title (title) ~>
+ if @refs.title?
+ @refs.title.innerHTML = title
+
+ @post = ~>
+ @open-post-form!
diff --git a/src/web/app/mobile/tags/ui-nav.tag b/src/web/app/mobile/tags/ui-nav.tag
new file mode 100644
index 0000000000..2c551b30ad
--- /dev/null
+++ b/src/web/app/mobile/tags/ui-nav.tag
@@ -0,0 +1,169 @@
+mk-ui-nav
+ div.body: div.content
+ a.me(if={ SIGNIN }, href={ CONFIG.url + '/' + I.username })
+ img.avatar(src={ I.avatar_url + '?thumbnail&size=128' }, alt='avatar')
+ p.name { I.name }
+ div.links
+ ul
+ li.post: a(href='/i/post')
+ i.icon.fa.fa-pencil-square-o
+ | 新規投稿
+ i.angle.fa.fa-angle-right
+ ul
+ li.home: a(href='/')
+ i.icon.fa.fa-home
+ | ホーム
+ i.angle.fa.fa-angle-right
+ li.mentions: a(href='/i/mentions')
+ i.icon.fa.fa-at
+ | あなた宛て
+ i.angle.fa.fa-angle-right
+ li.notifications: a(href='/i/notifications')
+ i.icon.fa.fa-bell-o
+ | 通知
+ i.angle.fa.fa-angle-right
+ li.messaging: a
+ i.icon.fa.fa-comments-o
+ | メッセージ
+ i.angle.fa.fa-angle-right
+ ul
+ li.settings: a(onclick={ search })
+ i.icon.fa.fa-search
+ | 検索
+ i.angle.fa.fa-angle-right
+ ul
+ li.settings: a(href='/i/drive')
+ i.icon.fa.fa-cloud
+ | ドライブ
+ i.angle.fa.fa-angle-right
+ li.settings: a(href='/i/upload')
+ i.icon.fa.fa-upload
+ | アップロード
+ i.angle.fa.fa-angle-right
+ ul
+ li.settings: a(href='/i/settings')
+ i.icon.fa.fa-cog
+ | 設定
+ i.angle.fa.fa-angle-right
+ p.about
+ a Misskeyについて
+
+style.
+ display block
+ position fixed
+ top 0
+ left 0
+ z-index -1
+ width 240px
+ color #fff
+ background #313538
+ visibility hidden
+
+ .body
+ height 100%
+ overflow hidden
+
+ .content
+ min-height 100%
+
+ .me
+ display block
+ margin 0
+ padding 16px
+
+ .avatar
+ display inline
+ max-width 64px
+ border-radius 32px
+ vertical-align middle
+
+ .name
+ display block
+ margin 0 16px
+ position absolute
+ top 0
+ left 80px
+ padding 0
+ width calc(100% - 112px)
+ color #fff
+ line-height 96px
+ overflow hidden
+ text-overflow ellipsis
+ white-space nowrap
+
+ ul
+ display block
+ margin 16px 0
+ padding 0
+ list-style none
+
+ &:first-child
+ margin-top 0
+
+ li
+ display block
+ font-size 1em
+ line-height 1em
+ border-top solid 1px rgba(0, 0, 0, 0.2)
+ background #353A3E
+ background-clip content-box
+
+ &:last-child
+ border-bottom solid 1px rgba(0, 0, 0, 0.2)
+
+ a
+ display block
+ padding 0 20px
+ line-height 3rem
+ line-height calc(1rem + 30px)
+ color #eee
+ text-decoration none
+
+ > .icon
+ margin-right 0.5em
+
+ > .angle
+ position absolute
+ top 0
+ right 0
+ padding 0 20px
+ font-size 1.2em
+ line-height calc(1rem + 30px)
+ color #ccc
+
+ > .unread-count
+ position absolute
+ height calc(0.9em + 10px)
+ line-height calc(0.9em + 10px)
+ top 0
+ bottom 0
+ right 38px
+ margin auto 0
+ padding 0px 8px
+ min-width 2em
+ font-size 0.9em
+ text-align center
+ color #fff
+ background rgba(255, 255, 255, 0.1)
+ border-radius 1em
+
+ .about
+ margin 1em 1em 2em 1em
+ text-align center
+ font-size 0.6em
+ opacity 0.3
+
+ a
+ color #fff
+
+script.
+ @mixin \i
+ @mixin \page
+
+ @on \mount ~>
+ @opts.ready!
+
+ @search = ~>
+ query = window.prompt \検索
+ if query? and query != ''
+ @page '/search:' + query
diff --git a/src/web/app/mobile/tags/ui.tag b/src/web/app/mobile/tags/ui.tag
new file mode 100644
index 0000000000..81dfac80ca
--- /dev/null
+++ b/src/web/app/mobile/tags/ui.tag
@@ -0,0 +1,50 @@
+mk-ui
+ div.global@global
+ mk-ui-header@header(ready={ ready })
+ mk-ui-nav@nav(ready={ ready })
+
+ div.content@main
+ <yield />
+
+ mk-stream-indicator
+
+style.
+ display block
+
+ > .global
+ > .content
+ background #fff
+
+script.
+ @mixin \stream
+
+ @ready-count = 0
+
+ #@ui.on \notification (text) ~>
+ # alert text
+
+ @on \mount ~>
+ @stream.on \notification @on-stream-notification
+ @ready!
+
+ @on \unmount ~>
+ @stream.off \notification @on-stream-notification
+ @slide.slide-close!
+
+ @ready = ~>
+ @ready-count++
+
+ if @ready-count == 2
+ @slide = SpSlidemenu @refs.main, @refs.nav.root, \#hamburger {direction: \left}
+ @init-view-position!
+
+ @init-view-position = ~>
+ top = @refs.header.root.offset-height
+ @refs.main.style.padding-top = top + \px
+ @refs.nav.root.style.margin-top = top + \px
+ @refs.nav.root.query-selector '.body > .content' .style.padding-bottom = top + \px
+
+ @on-stream-notification = (notification) ~>
+ el = document.body.append-child document.create-element \mk-notify
+ riot.mount el, do
+ notification: notification
diff --git a/src/web/app/mobile/tags/user-followers.tag b/src/web/app/mobile/tags/user-followers.tag
new file mode 100644
index 0000000000..7004398268
--- /dev/null
+++ b/src/web/app/mobile/tags/user-followers.tag
@@ -0,0 +1,22 @@
+mk-user-followers
+ mk-users-list@list(fetch={ fetch }, count={ user.followers_count }, you-know-count={ user.followers_you_know_count }, no-users={ 'フォロワーはいないようです。' })
+
+style.
+ display block
+
+script.
+ @mixin \api
+
+ @user = @opts.user
+
+ @fetch = (iknow, limit, cursor, cb) ~>
+ @api \users/followers do
+ user_id: @user.id
+ iknow: iknow
+ limit: limit
+ cursor: if cursor? then cursor else undefined
+ .then cb
+
+ @on \mount ~>
+ @refs.list.on \loaded ~>
+ @trigger \loaded
diff --git a/src/web/app/mobile/tags/user-following.tag b/src/web/app/mobile/tags/user-following.tag
new file mode 100644
index 0000000000..c122acd607
--- /dev/null
+++ b/src/web/app/mobile/tags/user-following.tag
@@ -0,0 +1,22 @@
+mk-user-following
+ mk-users-list@list(fetch={ fetch }, count={ user.following_count }, you-know-count={ user.following_you_know_count }, no-users={ 'フォロー中のユーザーはいないようです。' })
+
+style.
+ display block
+
+script.
+ @mixin \api
+
+ @user = @opts.user
+
+ @fetch = (iknow, limit, cursor, cb) ~>
+ @api \users/following do
+ user_id: @user.id
+ iknow: iknow
+ limit: limit
+ cursor: if cursor? then cursor else undefined
+ .then cb
+
+ @on \mount ~>
+ @refs.list.on \loaded ~>
+ @trigger \loaded
diff --git a/src/web/app/mobile/tags/user-preview.tag b/src/web/app/mobile/tags/user-preview.tag
new file mode 100644
index 0000000000..4f5fbc1520
--- /dev/null
+++ b/src/web/app/mobile/tags/user-preview.tag
@@ -0,0 +1,103 @@
+mk-user-preview
+ a.avatar-anchor(href={ CONFIG.url + '/' + user.username })
+ img.avatar(src={ user.avatar_url + '?thumbnail&size=64' }, alt='avatar')
+ div.main
+ header
+ div.left
+ a.name(href={ CONFIG.url + '/' + user.username })
+ | { user.name }
+ span.username
+ | @{ user.username }
+ div.body
+ div.bio { user.bio }
+
+style.
+ display block
+ margin 0
+ padding 16px
+ font-size 12px
+
+ @media (min-width 350px)
+ font-size 14px
+
+ @media (min-width 500px)
+ font-size 16px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .avatar-anchor
+ display block
+ float left
+ margin 0 10px 0 0
+
+ @media (min-width 500px)
+ margin-right 16px
+
+ > .avatar
+ display block
+ width 48px
+ height 48px
+ margin 0
+ border-radius 6px
+ vertical-align bottom
+
+ @media (min-width 500px)
+ width 58px
+ height 58px
+ border-radius 8px
+
+ > .main
+ float left
+ width calc(100% - 58px)
+
+ @media (min-width 500px)
+ width calc(100% - 74px)
+
+ > header
+ white-space nowrap
+
+ @media (min-width 500px)
+ margin-bottom 2px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .left
+ float left
+
+ > .name
+ display inline
+ margin 0
+ padding 0
+ color #777
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+
+ &:hover
+ text-decoration underline
+
+ > .username
+ text-align left
+ margin 0 0 0 8px
+ color #ccc
+
+ > .body
+
+ > .bio
+ cursor default
+ display block
+ margin 0
+ padding 0
+ word-wrap break-word
+ font-size 1.1em
+ color #717171
+
+script.
+ @user = @opts.user
diff --git a/src/web/app/mobile/tags/user-timeline.tag b/src/web/app/mobile/tags/user-timeline.tag
new file mode 100644
index 0000000000..7aa23d2150
--- /dev/null
+++ b/src/web/app/mobile/tags/user-timeline.tag
@@ -0,0 +1,28 @@
+mk-user-timeline
+ mk-timeline(init={ init }, more={ more }, empty={ with-media ? 'メディア付き投稿はありません。' : 'このユーザーはまだ投稿していないようです。' })
+
+style.
+ display block
+ max-width 600px
+ margin 0 auto
+ background #fff
+
+script.
+ @mixin \api
+
+ @user = @opts.user
+ @with-media = @opts.with-media
+
+ @init = new Promise (res, rej) ~>
+ @api \users/posts do
+ user_id: @user.id
+ with_media: @with-media
+ .then (posts) ~>
+ res posts
+ @trigger \loaded
+
+ @more = ~>
+ @api \users/posts do
+ user_id: @user.id
+ with_media: @with-media
+ max_id: @refs.timeline.tail!.id
diff --git a/src/web/app/mobile/tags/user.tag b/src/web/app/mobile/tags/user.tag
new file mode 100644
index 0000000000..8f4c04cf9c
--- /dev/null
+++ b/src/web/app/mobile/tags/user.tag
@@ -0,0 +1,198 @@
+mk-user
+ div.user(if={ !fetching })
+ header
+ div.banner(style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=1024)' : '' })
+ div.body
+ div.top
+ a.avatar: img(src={ user.avatar_url + '?thumbnail&size=160' }, alt='avatar')
+ mk-follow-button(if={ SIGNIN && I.id != user.id }, user={ user })
+
+ div.title
+ h1 { user.name }
+ span.username @{ user.username }
+ span.followed(if={ user.is_followed }) フォローされています
+
+ div.bio { user.bio }
+
+ div.info
+ p.location(if={ user.location })
+ i.fa.fa-map-marker
+ | { user.location }
+
+ div.friends
+ a(href='{ user.username }/following')
+ b { user.following_count }
+ i フォロー
+ a(href='{ user.username }/followers')
+ b { user.followers_count }
+ i フォロワー
+ nav
+ a(data-is-active={ page == 'posts' }, onclick={ go-posts }) 投稿
+ a(data-is-active={ page == 'media' }, onclick={ go-media }) メディア
+ a(data-is-active={ page == 'graphs' }, onclick={ go-graphs }) グラフ
+ a(data-is-active={ page == 'likes' }, onclick={ go-likes }) いいね
+
+ div.body
+ mk-user-timeline(if={ page == 'posts' }, user={ user })
+ mk-user-timeline(if={ page == 'media' }, user={ user }, with-media={ true })
+ mk-user-graphs(if={ page == 'graphs' }, user={ user })
+
+style.
+ display block
+
+ > .user
+ > header
+ > .banner
+ padding-bottom 33.3%
+ background-color #f5f5f5
+ background-size cover
+ background-position center
+
+ > .body
+ padding 8px
+ margin 0 auto
+ max-width 600px
+
+ > .top
+ &:after
+ content ''
+ display block
+ clear both
+
+ > .avatar
+ display block
+ float left
+ width 25%
+ height 40px
+
+ > img
+ display block
+ position absolute
+ left -2px
+ bottom -2px
+ width 100%
+ border 2px solid #fff
+ border-radius 6px
+
+ @media (min-width 500px)
+ left -4px
+ bottom -4px
+ border 4px solid #fff
+ border-radius 12px
+
+ > mk-follow-button
+ float right
+ height 40px
+
+ > .title
+ margin 8px 0
+
+ > h1
+ margin 0
+ line-height 22px
+ font-size 20px
+ color #222
+
+ > .username
+ display inline-block
+ line-height 20px
+ font-size 16px
+ font-weight bold
+ color #657786
+
+ > .followed
+ margin-left 8px
+ padding 2px 4px
+ font-size 12px
+ color #657786
+ background #f8f8f8
+ border-radius 4px
+
+ > .bio
+ margin 8px 0
+ color #333
+
+ > .info
+ margin 8px 0
+
+ > .location
+ display inline
+ margin 0
+ color #555
+
+ > i
+ margin-right 4px
+
+ > .friends
+ > a
+ color #657786
+
+ &:first-child
+ margin-right 16px
+
+ > b
+ margin-right 4px
+ font-size 16px
+ color #14171a
+
+ > i
+ font-size 14px
+
+ > nav
+ display flex
+ justify-content center
+ margin 0 auto
+ max-width 600px
+ border-bottom solid 1px #ddd
+
+ > a
+ display block
+ flex 1 1
+ text-align center
+ line-height 52px
+ font-size 14px
+ text-decoration none
+ color #657786
+ border-bottom solid 2px transparent
+
+ &[data-is-active]
+ font-weight bold
+ color $theme-color
+ border-color $theme-color
+
+ > .body
+ @media (min-width 500px)
+ padding 16px 0 0 0
+
+script.
+ @mixin \i
+ @mixin \api
+
+ @username = @opts.user
+ @page = if @opts.page? then @opts.page else \posts
+ @fetching = true
+
+ @on \mount ~>
+ @api \users/show do
+ username: @username
+ .then (user) ~>
+ @fetching = false
+ @user = user
+ @trigger \loaded user
+ @update!
+
+ @go-posts = ~>
+ @page = \posts
+ @update!
+
+ @go-media = ~>
+ @page = \media
+ @update!
+
+ @go-graphs = ~>
+ @page = \graphs
+ @update!
+
+ @go-likes = ~>
+ @page = \likes
+ @update!
diff --git a/src/web/app/mobile/tags/users-list.tag b/src/web/app/mobile/tags/users-list.tag
new file mode 100644
index 0000000000..3e29a0a4cc
--- /dev/null
+++ b/src/web/app/mobile/tags/users-list.tag
@@ -0,0 +1,125 @@
+mk-users-list
+ nav
+ span(data-is-active={ mode == 'all' }, onclick={ set-mode.bind(this, 'all') })
+ | すべて
+ span { opts.count }
+ // ↓ https://github.com/riot/riot/issues/2080
+ span(if={ SIGNIN && opts.you-know-count != '' }, data-is-active={ mode == 'iknow' }, onclick={ set-mode.bind(this, 'iknow') })
+ | 知り合い
+ span { opts.you-know-count }
+
+ div.users(if={ !fetching && users.length != 0 })
+ mk-user-preview(each={ users }, user={ this })
+
+ button.more(if={ !fetching && next != null }, onclick={ more }, disabled={ more-fetching })
+ span(if={ !more-fetching }) もっと
+ span(if={ more-fetching })
+ | 読み込み中
+ mk-ellipsis
+
+ p.no(if={ !fetching && users.length == 0 })
+ | { opts.no-users }
+ p.fetching(if={ fetching })
+ i.fa.fa-spinner.fa-pulse.fa-fw
+ | 読み込んでいます
+ mk-ellipsis
+
+style.
+ display block
+ background #fff
+
+ > nav
+ display flex
+ justify-content center
+ margin 0 auto
+ max-width 600px
+ border-bottom solid 1px #ddd
+
+ > span
+ display block
+ flex 1 1
+ text-align center
+ line-height 52px
+ font-size 14px
+ color #657786
+ border-bottom solid 2px transparent
+
+ &[data-is-active]
+ font-weight bold
+ color $theme-color
+ border-color $theme-color
+
+ > span
+ display inline-block
+ margin-left 4px
+ padding 2px 5px
+ font-size 12px
+ line-height 1
+ color #888
+ background #eee
+ border-radius 20px
+
+ > .users
+ > *
+ max-width 600px
+ margin 0 auto
+ border-bottom solid 1px rgba(0, 0, 0, 0.05)
+
+ > .no
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > .fetching
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+script.
+ @mixin \i
+
+ @limit = 30users
+ @mode = \all
+
+ @fetching = true
+ @more-fetching = false
+
+ @on \mount ~>
+ @fetch ~>
+ @trigger \loaded
+
+ @fetch = (cb) ~>
+ @fetching = true
+ @update!
+ obj <~ @opts.fetch do
+ @mode == \iknow
+ @limit
+ null
+ @users = obj.users
+ @next = obj.next
+ @fetching = false
+ @update!
+ if cb? then cb!
+
+ @more = ~>
+ @more-fetching = true
+ @update!
+ obj <~ @opts.fetch do
+ @mode == \iknow
+ @limit
+ @cursor
+ @users = @users.concat obj.users
+ @next = obj.next
+ @more-fetching = false
+ @update!
+
+ @set-mode = (mode) ~>
+ @update do
+ mode: mode
+
+ @fetch!