~sirn/fanboi2

d97a4444c3209cbeafaf064edddd2cfb1e8d3601 — Kridsada Thanabulpong 4 years ago 0103a85
Cleanup components and README.

* Update README for current development process.
* Enable strictNullChecks in TypeScript and update accordingly.
* Use new naming convention for element variables in TypeScript.
37 files changed, 740 insertions(+), 835 deletions(-)

M README.rst
M assets/app/javascripts/components/anchor_popover.ts
M assets/app/javascripts/components/base.ts
M assets/app/javascripts/components/board_selector.ts
M assets/app/javascripts/components/theme_selector.ts
M assets/app/javascripts/components/topic/topic_load_posts.ts
M assets/app/javascripts/components/topic/topic_new_post.ts
M assets/app/javascripts/components/topic_inline_reply.ts
M assets/app/javascripts/components/topic_manager.ts
M assets/app/javascripts/components/topic_quick_reply.ts
M assets/app/javascripts/components/topic_reloader.ts
M assets/app/javascripts/components/topic_state_tracker.ts
M assets/app/javascripts/models/board.ts
M assets/app/javascripts/models/post.ts
M assets/app/javascripts/models/task.ts
M assets/app/javascripts/models/topic.ts
M assets/app/javascripts/utils/cancellable.ts
M assets/app/javascripts/utils/elements.ts
M assets/app/javascripts/utils/errors.ts
M assets/app/javascripts/utils/formatters.ts
M assets/app/javascripts/utils/forms.ts
M assets/app/javascripts/utils/loading.ts
M assets/app/javascripts/utils/request.ts
M assets/app/javascripts/views/board_selector_view.ts
M assets/app/javascripts/views/board_view.ts
M assets/app/javascripts/views/popover_view.ts
M assets/app/javascripts/views/post_collection_view.ts
M assets/app/javascripts/views/post_form.ts
M assets/app/javascripts/views/theme_selector_view.ts
M assets/app/javascripts/views/topic_view.ts
M assets/app/stylesheets/js.scss
M assets/app/stylesheets/post.scss
M gulpfile.js
M tsconfig.json
M typings.json
D typings/vendor/dom4.d.ts
D typings/vendor/lodash.merge.d.ts
M README.rst => README.rst +2 -6
@@ 84,14 84,10 @@ Celery worker is required to be run if you want to enable posting features.
Contributing
------------

We use `git-flow <https://github.com/nvie/gitflow>`_ as primary branching model. All developments are done in the **develop** branch; **master** branch is the most stable and will be deployed immediately to the live site. You can install ``git-flow`` by following `git-flow installation instructions <https://github.com/nvie/gitflow/wiki/Installation>`_ (use the default values). Although using `git-flow` is not a requirement for pull request, it is recommended to do so:

1. Fork this repo.
2. Start a new feature with ``git flow feature start feature-name``.
3. After you've done, open a pull request against **develop** branch of this repo.

Please make sure that test coverage is 100% and everything passed. It's also a good idea to open a bug ticket for feature you want to implement before starting. We have development IRC channel at `irc.fanboi.ch#fanboi <irc://irc.fanboi.ch/#fanboi>`_. Although if you want to submit patch anonymously you can also create git patch and post it to `support board <https://fanboi.ch/meta/>`_ as well.

After you have done, simply open a pull request against **master** branch.

License
-------


M assets/app/javascripts/components/anchor_popover.ts => assets/app/javascripts/components/anchor_popover.ts +132 -181
@@ 12,211 12,162 @@ import {TopicView} from '../views/topic_view';
import {CancellableToken, CancelToken} from '../utils/cancellable';


class AnchorPopoverHandler {
    targetElement: Element;
    quoteElement: Element;
    parentElement: Element;
    cancellableToken: CancellableToken;

    constructor(element: Element, parentElement?: Element) {
        this.targetElement = element;
        this.quoteElement = null;
        this.cancellableToken = null;
        this.parentElement = parentElement;
        if (!this.parentElement) {
            this.parentElement = element.closest('[data-topic]');
        }
export class AnchorPopover extends DelegationComponent {
    public targetSelector = '[data-anchor]';

    protected bindGlobal(): void {
        document.body.addEventListener('mouseover', (e: Event): void => {
            if (e.target instanceof Element) {
                if (e.target.matches(this.targetSelector)) {
                    e.preventDefault();
                    AnchorPopover.attach(e.target);
                }
            }
        });
    }

    attach(): Promise<void> {
        let self = this;

        let target = this.targetElement;
        let boardSlug = target.getAttribute('data-anchor-board');
        let topicId = parseInt(target.getAttribute('data-anchor-topic'), 10);
        let postNumber = target.getAttribute('data-anchor');

        return this.render(boardSlug, topicId, postNumber).then(
            function(node: VNode | void) {
                if (node) {
                    let rebindTopic: boolean;
                    let popoverNode: VNode;
                    let popoverView = new PopoverView(self.targetElement, node);

                    // Rebind [data-topic] when anchor is pointing to post from
                    // another topic so that quick reply binds to correct
                    // context.
                    if (topicId && postNumber) {
                        let localTopicElement = target.closest('[data-topic]');
                        let localTopicId = parseInt(
                            localTopicElement.getAttribute('data-topic'),
                            10
                        );
    private static attach($target: Element): void {
        let $parent = $target.closest('.js-popover');

                        if (topicId != localTopicId) {
                            rebindTopic = true;
                            popoverNode = popoverView.render({
                                dataset: {
                                    topic: topicId,
                                }
                            });
                        }
                    }
        if (!$parent) {
            $parent = $target.closest('[data-topic]');
        }

                    if (!popoverNode) {
                        rebindTopic = false;
                        popoverNode = popoverView.render();
                    }
        if ($parent) {
            let cancelToken = new CancelToken();
            let boardSlug = $target.getAttribute('data-anchor-board') || '';
            let postNumber = $target.getAttribute('data-anchor') || '';
            let anchorAttr = $target.getAttribute('data-anchor-topic');
            let topicId: number = 0;

                    self.quoteElement = create(popoverNode);
                    self.parentElement.insertBefore(self.quoteElement, null);
            if (anchorAttr) {
                topicId = parseInt(anchorAttr, 10);
            }

                    if (rebindTopic) {
                        new TopicManager(self.parentElement);
                    }
            let _render = () => {
                if (topicId && postNumber) {
                    return Post.queryAll(topicId, postNumber, cancelToken).then(
                        (posts: Post[]): VNode => {
                            return new PostCollectionView(posts).render();
                        }
                    );
                } else if (topicId && !postNumber) {
                    return Topic.queryId(topicId, cancelToken).then(
                        (topic: Topic): VNode => {
                            return new TopicView(topic).render();
                        }
                    );
                } else {
                    return Board.querySlug(boardSlug, cancelToken).then(
                        (board: Board): VNode => {
                            return new BoardView(board).render();
                        }
                    );
                }
            }
        );
    }

    detach(): void {
        if (this.cancellableToken) {
            this.cancellableToken.cancel();
        }

        if (this.quoteElement) {
            this.parentElement.removeChild(this.quoteElement);
            this.quoteElement = null;
        }
    }
            let _finalizeRequest = () => {
                cancelToken.cancel();
                $target.removeEventListener('mouseout', _finalizeRequest);
            }

    private render(
        boardSlug: string,
        topicId?: number,
        postNumber?: string,
    ): Promise<VNode | void> {
        this.cancellableToken = new CancelToken();

        if (boardSlug && !topicId && !postNumber) {
            return Board.querySlug(
                boardSlug,
                this.cancellableToken
            ).then(function(board: Board): VNode {
                if (board) {
                    return new BoardView(board).render();
            $target.addEventListener('mouseout', _finalizeRequest);

            _render().then((node) => {
                let $popover: Element;
                let popoverView = new PopoverView(node);
                let popoverNode = popoverView.render($target);
                let rebindTopic: boolean = false;
                let dismissTimer: number = 0;

                let _detach = (): void => {
                    if ($parent && $popover && $popover.parentNode) {
                        $parent.removeChild($popover);
                        $target.removeEventListener('mouseout', _detachOnTarget);
                        document.body.removeEventListener(
                            'mouseover',
                             _detachOnPopover
                        );
                    }
                }
            });
        } else if (topicId && !postNumber) {
            return Topic.queryId(
                topicId,
                this.cancellableToken
            ).then(function(topic: Topic): VNode {
                if (topic) {
                    return new TopicView(topic).render();

                let _switchDetachMode = (e: Event): void => {
                    e.preventDefault();
                    clearTimeout(dismissTimer);

                    $popover.removeEventListener('mouseover', _switchDetachMode);
                    document.body.addEventListener(
                        'mouseover',
                        _detachOnPopover
                    );
                }
            });
        } else if (topicId && postNumber) {
            return Post.queryAll(
                topicId,
                postNumber,
                this.cancellableToken
            ).then(function(posts: Array<Post>) {
                    if (posts && posts.length) {
                        return new PostCollectionView(posts).render();

                let _detachOnPopover = (e: Event): void => {
                    e.preventDefault();

                    let _n = e.target;
                    while (_n && _n instanceof Node && _n != $popover) {
                        _n = _n.parentNode;
                    }

                    if (_n != $popover) {
                        _detach();
                    }
                }
            );
        }
    }
}

                let _detachOnTarget = (e: Event): void => {
                    e.preventDefault();

                    // If $popover is already rendered then wait and
                    // see if user will move mouse into the quote; otherwise
                    // immediately detach.
                    if ($popover.parentNode) {
                        dismissTimer = setTimeout((): void => {
                            _detach();
                        }, 100);
                    } else {
                        _detach();
                    }
                }

export class AnchorPopover extends DelegationComponent {
    public targetSelector = '[data-anchor]';
                _finalizeRequest();

    dismissTimer: number;
                // Rebind [data-topic] when anchor is pointing to post from
                // another topic so that quick reply and etc. binds to the
                // correct context.
                if (topicId && postNumber) {
                    let $localTopic = $target.closest('[data-topic]');
                    let localTopicId: number = 0;

    protected bindGlobal(): void {
        let self = this;
        document.body.addEventListener('mouseover', function(e: Event): void {
            let target = <Element>e.target;
            if (target.matches(self.targetSelector)) {
                e.preventDefault();

                let parent = target.closest('.js-popover');
                let handler = new AnchorPopoverHandler(target, parent);

                handler.attach().then(function(): void {
                    let quoteElement = handler.quoteElement;
                    if (quoteElement) {
                        self.bindQuoteElement(handler, quoteElement);
                    }
                });

                target.addEventListener('mouseout',
                    function(e: Event): void {
                        e.preventDefault();

                        // If quoteElement is already rendered then wait and
                        // see if user will move mouse into the quote; otherwise
                        // immediately detach.
                        if (handler.quoteElement) {
                            self.dismissTimer = setTimeout(function(): void {
                                handler.detach();
                            }, 100);
                        } else {
                            handler.detach();
                    if ($localTopic) {
                        let topicAttr = $localTopic.getAttribute('data-topic');
                        if (topicAttr) {
                            localTopicId = parseInt(topicAttr, 10);
                        }
                    }
                );
            }
        });
    }

    private bindQuoteElement(
        handler: AnchorPopoverHandler,
        quoteElement: Element
    ): void {
        let self = this;
        quoteElement.addEventListener('mouseover',
            function _bindQuote(e: Event): void {
                e.preventDefault();

                if (self.dismissTimer) {
                    clearTimeout(self.dismissTimer);
                    self.dismissTimer = null;
                    self.bindQuoteElementDocument(handler, quoteElement);
                    quoteElement.removeEventListener(
                        'mouseover',
                        <EventListener>_bindQuote
                    );
                    if (topicId != localTopicId) {
                        rebindTopic = true;
                        popoverNode = popoverView.render($target, {
                            dataset: {
                                topic: topicId,
                            }
                        });
                    }
                }
            }
        );
    }

    private bindQuoteElementDocument(
        handler: AnchorPopoverHandler,
        quoteElement: Element
    ): void {
        let self = this;
        document.body.addEventListener('mouseover',
            function _bindQuoteDocument(e: Event): void {
                e.preventDefault();

                let _n = <Node>e.target;
                while (_n && _n != quoteElement) {
                    _n = _n.parentNode;
                }
                if ($parent) {
                    $popover = create(popoverNode);
                    $parent.appendChild($popover);

                if (_n != quoteElement) {
                    handler.detach();
                    document.body.removeEventListener(
                        'mouseover',
                        <EventListener>_bindQuoteDocument
                    );
                    if (rebindTopic) {
                        new TopicManager($parent);
                    }

                    $target.addEventListener('mouseout', _detachOnTarget);
                    $popover.addEventListener('mouseover', _switchDetachMode);
                }
            }
        );
            });
        }
    }
}

M assets/app/javascripts/components/base.ts => assets/app/javascripts/components/base.ts +23 -24
@@ 10,9 10,8 @@ export class DelegationComponent implements IComponent {
    public targetSelector: string;

    constructor() {
        let self = this;
        setTimeout(function(): void {
            self.bindGlobal();
        setTimeout((): void => {
            this.bindGlobal();
        }, 1);
    }



@@ 25,17 24,17 @@ export class DelegationComponent implements IComponent {
export class SingletonComponent implements IComponent {
    public targetSelector: string;

    constructor(context?: Element | Document) {
        let self = this;

        if (context == null) {
            context = document;
    constructor($context?: Element | Document) {
        if (!$context) {
            $context = document;
        }

        setTimeout(function(): void {
            let targetElement = context.querySelector(self.targetSelector);
            if (targetElement) {
                self.bindOne(targetElement);
        setTimeout((): void => {
            if ($context) {
                let $element = $context.querySelector(this.targetSelector);
                if ($element) {
                    this.bindOne($element);
                }
            }
        }, 1);
    }


@@ 49,26 48,26 @@ export class SingletonComponent implements IComponent {
export class CollectionComponent implements IComponent {
    public targetSelector: string;

    constructor(context?: Element | Document) {
        let self = this;

        if (context == null) {
            context = document;
    constructor($context?: Element | Document) {
        if (!$context) {
            $context = document;
        }

        setTimeout(function(): void {
            let targetElements = context.querySelectorAll(self.targetSelector);
            self.bindAll(targetElements);
        setTimeout((): void => {
            if ($context) {
                let $elements = $context.querySelectorAll(this.targetSelector);
                this.bindAll($elements);
            }
        }, 1);
    }

    protected bindAll(targetElements: NodeListOf<Element>): void {
        for (let i = 0, len = targetElements.length; i < len; i++) {
            this.bindOne(targetElements[i]);
    protected bindAll($targets: NodeListOf<Element>): void {
        for (let i = 0, len = $targets.length; i < len; i++) {
            this.bindOne($targets[i]);
        }
    }

    protected bindOne(element: Element): void {
    protected bindOne($target: Element): void {
        throw new NotImplementedError;
    }
}

M assets/app/javascripts/components/board_selector.ts => assets/app/javascripts/components/board_selector.ts +84 -134
@@ 12,168 12,118 @@ const animationDuration = 200;
export class BoardSelector extends SingletonComponent {
    public targetSelector = '[data-board-selector]';

    boards: Board[];
    targetElement: Element;
    buttonElement: Element;
    selectorView: BoardSelectorView;
    selectorNode: VNode;
    selectorElement: Element;
    selectorHeight: number;
    selectorState: boolean;

    protected bindOne(element: Element): void {
        let self = this;
    protected bindOne($element: Element): void {
        let $button: Element;
        let $selector: Element | undefined;
        let selectorView: BoardSelectorView | undefined;
        let selectorNode: VNode | undefined;
        let selectorState: boolean = false;
        let throttleTimer: number;
        let buttonNode = h('div',
            {className: 'js-board-selector-button'},
            [h('a', {'href': '#'}, ['Boards'])]
        );

        addClass(element, 'js-board-selector-wrapper');

        this.targetElement = element;
        this.buttonElement = create(buttonNode);
        this.buttonElement.addEventListener('click', function(e) {
            e.preventDefault();
            self.eventButtonClicked();
        });

        let containerElement = this.targetElement.querySelector('.container');
        containerElement.appendChild(this.buttonElement);
        let _render = (height: number = 0): Promise<any> => {
            if (!$selector) {
                return Board.queryAll().then((boards: Board[]) => {
                    selectorView = new BoardSelectorView(boards);
                    selectorNode = selectorView.render();
                    $selector = create(selectorNode);
                    document.body.insertBefore($selector, $element.nextSibling);
                });
            } else {
                return new Promise((resolve) => {
                    resolve();
                });
            }
        }

        // Attempt to restore height on resize. Since the resize may cause
        // clientHeight to change (and will cause the board selector to be
        // clipped or has extra whitespace).
        //
        // Do nothing if resize was called before board selector was attached.
        window.addEventListener('resize', function(e: Event) {
            clearTimeout(throttleTimer);
            throttleTimer = setTimeout(function(){
                if (self.selectorElement) {
                    let selectorHeight = self.getSelectorHeight();
                    if (self.selectorHeight != selectorHeight) {
                        self.selectorHeight = selectorHeight;
                        if (self.selectorState) {
                            self.showBoardSelector(false);
                            this.selectorState = true;
                        }
        let _update = (height: number): void => {
            if ($selector && selectorView && selectorNode) {
                let newSelectorNode = selectorView.render({
                    style: {
                        height: `${height}px`,
                    }
                }
            }, 100);
        });
    }
                });

    private eventButtonClicked(): void {
        let self = this;

        if (this.boards) {
            this.toggleBoardSelectorState();
        } else {
            Board.queryAll().then(function(
                boards: Array<Board>
            ): void {
                self.boards = boards;
                self.attachBoardSelector();
                self.toggleBoardSelectorState();
            });
        }
    }

    private toggleBoardSelectorState(): void {
        if (this.selectorState) {
            this.hideBoardSelector(true);
            this.selectorState = false;
        } else {
            this.showBoardSelector(true);
            this.selectorState = true;
                let patches = diff(selectorNode, newSelectorNode);
                $selector = patch($selector, patches);
                selectorNode = newSelectorNode;
            }
        }
    }

    private attachBoardSelector(): void {
        this.selectorView = new BoardSelectorView(this.boards);
        this.selectorNode = this.selectorView.render();
        this.selectorElement = create(this.selectorNode);

        document.body.insertBefore(
            this.selectorElement,
            this.targetElement.nextSibling
        )

        this.selectorHeight = this.getSelectorHeight();
    }

    private hideBoardSelector(animate?: boolean): void {
        let self = this;

        if (animate) {
            let startTime: number;
            let animateStep = function(time: number) {
        let _animate = (updateFn: ((elapsedPercent: number) => void)) => {
            let startTime: number = 0;
            let _animateStep = (time: number) => {
                if (!startTime) {
                    startTime = time;
                }

                let elapsed = Math.min(time - startTime, animationDuration);
                let elapsedPercent = elapsed/animationDuration;
                self.updateSelectorElement(
                    self.selectorHeight * (1 - elapsedPercent)
                );

                updateFn(elapsedPercent);

                if (elapsed < animationDuration) {
                    requestAnimationFrame(animateStep);
                    requestAnimationFrame(_animateStep);
                }
            }

            requestAnimationFrame(animateStep);
        } else {
            this.updateSelectorElement(0, 'hidden');
            requestAnimationFrame(_animateStep);
        }
    }

    private showBoardSelector(animate?: boolean): void {
        let self = this;
        $button = create(h('div',
            {className: 'js-board-selector-button'},
            [h('a', {'href': '#'}, ['Boards'])]
        ));

        if (animate) {
            let startTime: number;
            let animateStep = function(time: number) {
                if (!startTime) {
                    startTime = time;
        $button.addEventListener('click', (e) => {
            e.preventDefault();
            _render().then(() => {
                if ($selector) {
                    let selectorHeight = BoardSelector.getSelectorHeight(
                        $selector
                    );

                    if (selectorState) {
                        selectorState = false;
                        _animate((elapsedPercent: number) => {
                            _update(selectorHeight * (1 - elapsedPercent));
                        });
                    } else {
                        selectorState = true;
                        _animate((elapsedPercent: number) => {
                            _update(selectorHeight * elapsedPercent);
                        });
                    }
                }
            });
        });

                let elapsed = Math.min(time - startTime, animationDuration);
                let elapsedPercent = elapsed/animationDuration;
                self.updateSelectorElement(self.selectorHeight * elapsedPercent);
        $element.querySelector('.container').appendChild($button);
        addClass($element, ['js-board-selector-wrapper']);

                if (elapsed < animationDuration) {
                    requestAnimationFrame(animateStep);
        // Attempt to restore height on resize. Since the resize may cause
        // clientHeight to change (and will cause the board selector to be
        // clipped or has extra whitespace).
        //
        // Do nothing if resize was called before board selector was attached
        // or if selector was attached but not displayed.
        window.addEventListener('resize', (e: Event) => {
            clearTimeout(throttleTimer);
            throttleTimer = setTimeout(() => {
                if ($selector && selectorState) {
                    _update(BoardSelector.getSelectorHeight($selector));
                    selectorState = true;
                }
            }

            requestAnimationFrame(animateStep);
        } else {
            this.updateSelectorElement(this.selectorHeight);
        }
            }, 100);
        });
    }

    private updateSelectorElement(height: number, visibility?: string) {
        if (!visibility) {
            visibility = 'visible';
        }
    private static getSelectorHeight($selector: Element): number {
        let $el = $selector.querySelector('.js-board-selector-inner');

        let viewNode = this.selectorView.render({
            style: {
                height: `${height}px`,
                visibility: visibility,
            }
        });

        let patches = diff(this.selectorNode, viewNode);
        this.selectorElement = patch(this.selectorElement, patches);
        this.selectorNode = viewNode;
    }
        if ($el) {
            return $el.clientHeight;
        }

    private getSelectorHeight(): number {
        return this.selectorElement.querySelector(
            '.js-board-selector-inner'
        ).clientHeight;
        throw new Error('Could not retrieve board selector height.');
    }
}

M assets/app/javascripts/components/theme_selector.ts => assets/app/javascripts/components/theme_selector.ts +26 -30
@@ 1,6 1,5 @@
import Cookies = require('js-cookie');
import {VNode, create, diff, patch, h} from 'virtual-dom';

import {SingletonComponent} from './base';
import {ThemeSelectorView, ITheme} from '../views/theme_selector_view';



@@ 21,42 20,39 @@ const themes = [
export class ThemeSelector extends SingletonComponent {
    public targetSelector = '[data-theme-selector]';

    targetElement: Element;
    selectorView: ThemeSelectorView;
    selectorNode: VNode;
    selectorElement: Element;

    protected bindOne(element: Element) {
        this.selectorView = new ThemeSelectorView(themes);
        this.selectorNode = this.selectorView.render(
    protected bindOne($target: Element) {
        let selectorView = new ThemeSelectorView(themes);
        let selectorNode = selectorView.render(
            document.body.className.replace(/theme-/, '')
        );

        this.targetElement = element;
        this.selectorElement = create(this.selectorNode);
        this.targetElement.appendChild(this.selectorElement);
        this.bindSelectorEvent();
    }
        let $selector = create(selectorNode);

    private bindSelectorEvent(): void {
        let self = this;
        $target.appendChild($selector);
        $target.addEventListener('click', (e: Event) => {
            let $click = e.target;

            if (
                $click instanceof Element &&
                $click.matches('[data-theme-selector-item]')
            ) {
                let themeId = $click.getAttribute('data-theme-selector-item');

        this.targetElement.addEventListener('click', function(e: Event) {
            let target = <Element>e.target;
            if (target.matches('[data-theme-selector-item]')) {
                e.preventDefault();

                let themeId = target.getAttribute('data-theme-selector-item');
                document.body.className = `theme-${themeId}`;
                Cookies.set('_theme', themeId, {
                    expires: 365,
                    path: '/'
                });

                let viewNode = self.selectorView.render(themeId);
                let patches = diff(self.selectorNode, viewNode);
                self.selectorElement = patch(self.selectorElement, patches);
                self.selectorNode = viewNode;
                if (themeId) {
                  let viewNode = selectorView.render(themeId);
                  let patches = diff(selectorNode, viewNode);

                  $selector = patch($selector, patches);
                  selectorNode = viewNode;

                  document.body.className = `theme-${themeId}`;
                  Cookies.set('_theme', themeId, {
                      expires: 365,
                      path: '/'
                  });
                }
            }
        });
    }

M assets/app/javascripts/components/topic/topic_load_posts.ts => assets/app/javascripts/components/topic/topic_load_posts.ts +13 -14
@@ 25,27 25,26 @@ export class TopicLoadPosts implements ITopicEventHandler {
    }

    bind(e: CustomEvent): void {
        let self = this;
        let callback: ((lastPostNumber: number) => void) = e.detail.callback;

        this.loadingState.bind(null, function(){
        this.loadingState.bind(() => {
            return Post.queryAll(
                self.topicId,
                `${self.lastPostNumber + 1}-`
            ).then(function(posts: Post[]) {
                this.topicId,
                `${this.lastPostNumber + 1}-`
            ).then((posts: Post[]) => {
                if (posts && posts.length) {
                    self.lastPostNumber = posts[posts.length - 1].number;
                    self.loadedPosts = self.loadedPosts.concat(posts);
                    self.appendPosts();
                    self.updateHistoryState();
                    this.lastPostNumber = posts[posts.length - 1].number;
                    this.loadedPosts = this.loadedPosts.concat(posts);
                    this.appendPosts();
                    this.updateHistoryState();
                }

                if (callback) {
                    callback(self.lastPostNumber);
                    callback(this.lastPostNumber);
                }

                dispatchCustomEvent(self.element, 'postsLoaded', {
                    lastPostNumber: self.lastPostNumber
                dispatchCustomEvent(this.element, 'postsLoaded', {
                    lastPostNumber: this.lastPostNumber
                });
            });
        });


@@ 75,7 74,7 @@ export class TopicLoadPosts implements ITopicEventHandler {
    private updateHistoryState(): void {
        if (window.history.replaceState) {
            let path = window.location.pathname;
            let newPath: string;
            let newPath: string = '';

            if (path.match(/\/\d+\/?$/)) {
                newPath = path.replace(


@@ 91,7 90,7 @@ export class TopicLoadPosts implements ITopicEventHandler {

            if (newPath) {
                window.history.replaceState(
                    null,
                    undefined,
                    `Posts up to ${this.lastPostNumber}`,
                    newPath
                );

M assets/app/javascripts/components/topic/topic_new_post.ts => assets/app/javascripts/components/topic/topic_new_post.ts +7 -8
@@ 13,27 13,26 @@ export class TopicNewPost implements ITopicEventHandler {
    }

    bind(e: CustomEvent): void {
        let self = this;
        let params: any = e.detail.params;
        let callback: ((lastPostNumber: number) => void) = e.detail.callback;
        let errCb: ((error: ResourceError) => void) = e.detail.errorCallback;

        if (!params) { throw new Error('newPost require a params'); }

        this.loadingState.bind(null, function() {
            return Post.createOne(self.topicId, params).then(function(){
                dispatchCustomEvent(self.element, 'postCreated');
                dispatchCustomEvent(self.element, 'loadPosts', {
                    callback: function(lastPostNumber: number) {
        this.loadingState.bind(() => {
            return Post.createOne(this.topicId, params).then(() => {
                dispatchCustomEvent(this.element, 'postCreated');
                dispatchCustomEvent(this.element, 'loadPosts', {
                    callback: (lastPostNumber: number) => {
                        if (callback) {
                            callback(lastPostNumber);
                        }
                    }
                });
            }).catch(function(error: ResourceError) {
            }).catch((error: ResourceError) => {
                if (errCb) {
                    errCb(error);
                    dispatchCustomEvent(self.element, 'postCreateError', {
                    dispatchCustomEvent(this.element, 'postCreateError', {
                        error: error
                    });
                }

M assets/app/javascripts/components/topic_inline_reply.ts => assets/app/javascripts/components/topic_inline_reply.ts +33 -36
@@ 10,42 10,39 @@ import {LoadingState} from '../utils/loading';
export class TopicInlineReply extends SingletonComponent {
    public targetSelector = '[data-topic-inline-reply]';

    formElement: HTMLFormElement;
    buttonElement: Element;
    loadingState: LoadingState;

    protected bindOne(element: Element) {
        let self = this;

        this.loadingState = new LoadingState();
        this.formElement = <HTMLFormElement>element;
        this.buttonElement = element.querySelector('button');

        this.formElement.addEventListener('submit', function(e: Event): void {
            e.preventDefault();
            self.eventFormSubmitted();
        });
    }

    private eventFormSubmitted(): void {
        let self = this;

        this.loadingState.bind(this.buttonElement, function() {
            return new Promise(function(resolve) {
                dispatchCustomEvent(self.formElement, 'newPost', {
                    params: serializeForm(self.formElement),
                    callback: function() {
                        detachErrors(self.formElement);
                        self.formElement.reset();
                        resolve();
                    },
                    errorCallback: function(error: ResourceError) {
                        detachErrors(self.formElement);
                        attachErrors(self.formElement, error);
                        resolve();
                    }
                });
    protected bindOne($target: Element) {
        let $form = $target;

        if ($form instanceof HTMLFormElement) {
            let $button = $target.querySelector('button');
            let loadingState = new LoadingState();

            $form.addEventListener('submit', (e: Event): void => {
                e.preventDefault();
                loadingState.bind(() => {
                    return new Promise((resolve) => {
                        if ($form instanceof HTMLFormElement) {
                            dispatchCustomEvent($form, 'newPost', {
                                params: serializeForm($form),
                                callback: () => {
                                    if ($form instanceof HTMLFormElement) {
                                        detachErrors($form);
                                        $form.reset();
                                        resolve();
                                    }
                                },
                                errorCallback: (error: ResourceError) => {
                                    if ($form instanceof HTMLFormElement) {
                                        detachErrors($form);
                                        attachErrors($form, error);
                                        resolve();
                                    }
                                }
                            });
                        }
                    });
                }, $button);
            });
        });
        }
    }
}

M assets/app/javascripts/components/topic_manager.ts => assets/app/javascripts/components/topic_manager.ts +15 -10
@@ 9,23 9,28 @@ import {TopicNewPost} from './topic/topic_new_post';
export class TopicManager extends CollectionComponent {
    public targetSelector = '[data-topic]';

    protected bindOne(element: Element): void {
        let topicId = parseInt(element.getAttribute('data-topic'), 10);
        this.bindEvent(topicId, element, 'updateState', TopicState);
        this.bindEvent(topicId, element, 'readState',   TopicState);
        this.bindEvent(topicId, element, 'loadPosts',   TopicLoadPosts);
        this.bindEvent(topicId, element, 'newPost',     TopicNewPost);
    protected bindOne($target: Element): void {
        let topicIdAttr = $target.getAttribute('data-topic');

        if (topicIdAttr) {
          let topicId = parseInt(topicIdAttr, 10);

          this.bindEvent($target, topicId, 'updateState', TopicState);
          this.bindEvent($target, topicId, 'readState',   TopicState);
          this.bindEvent($target, topicId, 'loadPosts',   TopicLoadPosts);
          this.bindEvent($target, topicId, 'newPost',     TopicNewPost);
        }
    }

    private bindEvent(
        $target: Element,
        topicId: number,
        element: Element,
        eventName: string,
        eventHandler: ITopicEventConstructor
    ): void {
        let self = this;
        let handler = new eventHandler(topicId, element);
        element.addEventListener(eventName, function(e: CustomEvent) {
        let handler = new eventHandler(topicId, $target);

        $target.addEventListener(eventName, (e: CustomEvent) => {
            e.stopPropagation();
            handler.bind(e);
        });

M assets/app/javascripts/components/topic_quick_reply.ts => assets/app/javascripts/components/topic_quick_reply.ts +81 -70
@@ 13,117 13,128 @@ export class TopicQuickReply extends DelegationComponent {
    public targetSelector = '[data-topic-quick-reply]';

    protected bindGlobal(): void {
        let self = this;
        document.body.addEventListener('click', (e: Event): void => {
            let $target = e.target;

            if (
                $target instanceof Element &&
                $target.matches(this.targetSelector)
            ) {
                let anchor = $target.getAttribute('data-topic-quick-reply');
                let $topic = $target.closest('[data-topic]');
                let $input: Element;

        document.body.addEventListener('click', function(e: Event): void {
            let element = <Element>e.target;
            if (element.matches(self.targetSelector)) {
                e.preventDefault();

                let anchor = element.getAttribute('data-topic-quick-reply');
                let topicElement = element.closest('[data-topic]');
                let inputElement = topicElement.querySelector(
                    '[data-topic-quick-reply-input]'
                );
                if (!anchor) {
                    throw new Error('Reply anchor is empty when it should not.');
                }

                if (inputElement) {
                    self.insertTextAtCursor(
                        <HTMLTextAreaElement>inputElement,
                        anchor
                    );
                } else {
                    self.attachForm(
                        element,
                        topicElement,
                        anchor
                if ($topic) {
                    $input = $topic.querySelector(
                        '[data-topic-quick-reply-input]'
                    );

                    if ($input instanceof HTMLTextAreaElement) {
                        this.insertTextAtCursor($input, anchor);
                    } else {
                        this.attachForm($target, $topic, anchor);
                    }
                }
            }
        });
    }

    private insertTextAtCursor(
        element: HTMLTextAreaElement,
        $input: HTMLTextAreaElement,
        anchor: string
    ): void {
        let anchorText = `>>${anchor} `;
        let startPos = element.selectionStart;
        let endPos = element.selectionEnd;
        let currentValue = element.value;
        let startPos = $input.selectionStart;
        let endPos = $input.selectionEnd;
        let currentValue = $input.value;

        element.value =
        $input.value =
            currentValue.substring(0, startPos) +
            anchorText +
            currentValue.substring(endPos, currentValue.length);

        element.focus();
        element.dispatchEvent(new Event('change'));
        element.selectionStart = startPos + anchorText.length;
        $input.focus();
        $input.dispatchEvent(new Event('change'));
        $input.selectionStart = startPos + anchorText.length;
    }

    private attachForm(
        element: Element,
        topicElement: Element,
        $target: Element,
        $topic: Element,
        anchor: string
    ): void {
        let $parent = $target.closest('.js-popover');
        let $popover: Element;
        let $textarea: Element;
        let popoverView: PopoverView;
        let popoverNode: VNode;
        let throttleTimer: number;
        let parentElement = element.closest('.js-popover');
        let title = "Quick Reply";

        if (!parentElement) {
            parentElement = topicElement;
        if (!$parent) {
            $parent = $topic;
        }

        let postFormView = new PostForm().render();
        let popoverView = new PopoverView(
            element,
            postFormView,
            title,
            _removePopover
        ).render();

        let popoverElement = create(popoverView);
        parentElement.insertBefore(popoverElement, null);
        let _removePopover = () => {
            $topic.removeEventListener('postCreated', _removePopover);
            document.body.removeEventListener('click', _clickRemovePopover);
            window.removeEventListener('resize', _repositionPopover);

        new TopicInlineReply(popoverElement);
        new TopicStateTracker(popoverElement);
            if ($parent) {
              $parent.removeChild($popover);
            }
        }

        let textareaElement = popoverElement.querySelector('textarea');
        this.insertTextAtCursor(textareaElement, anchor);
        popoverView = new PopoverView(
            new PostForm().render(),
            "Quick Reply",
            _removePopover
        );

        function _repositionPopover() {
        let _repositionPopover = () => {
            clearTimeout(throttleTimer);
            throttleTimer = setTimeout(function(){
                if (popoverElement) {
                    let newPopoverView = new PopoverView(
                        element,
                        postFormView,
                        title,
                        _removePopover
                    ).render();

                    let patches = diff(popoverView, newPopoverView);
                    popoverElement = patch(popoverElement, patches);
                    popoverView = newPopoverView;
            throttleTimer = setTimeout(() => {
                if ($popover) {
                    let newPopoverNode = popoverView.render($target);
                    let patches = diff(popoverNode, newPopoverNode);

                    $popover = patch($popover, patches);
                    popoverNode = newPopoverNode;
                }
            }, 100);
        }

        function _clickRemovePopover(e: Event) {
            let _n = <Node>e.target;
            while (_n && _n != popoverElement) { _n = _n.parentNode; }
            if (_n != popoverElement) { _removePopover(); }
        let _clickRemovePopover = (e: Event) => {
            let _n = e.target;

            while (_n instanceof Node && _n != $popover) {
                _n = _n.parentNode;
            }

            if (_n != $popover) {
                _removePopover();
            }
        }

        function _removePopover() {
            topicElement.removeEventListener('postCreated', _removePopover);
            document.body.removeEventListener('click', _clickRemovePopover);
            window.removeEventListener('resize', _repositionPopover);
            parentElement.removeChild(popoverElement);
        popoverNode = popoverView.render($target);
        $popover = create(popoverNode);
        $parent.appendChild($popover);

        new TopicInlineReply($popover);
        new TopicStateTracker($popover);

        $textarea = $popover.querySelector('textarea');
        if ($textarea instanceof HTMLTextAreaElement) {
            this.insertTextAtCursor($textarea, anchor);
        }

        document.body.addEventListener('click', _clickRemovePopover);
        topicElement.addEventListener('postCreated', _removePopover);
        $topic.addEventListener('postCreated', _removePopover);
        window.addEventListener('resize', _repositionPopover);
    }
}

M assets/app/javascripts/components/topic_reloader.ts => assets/app/javascripts/components/topic_reloader.ts +38 -27
@@ 6,45 6,56 @@ import {LoadingState} from '../utils/loading';
export class TopicReloader extends SingletonComponent {
    public targetSelector = '[data-topic-reloader]';

    protected bindOne(element: Element) {
        let self = this;
    protected bindOne($target: Element) {
        let $topic = $target.closest('[data-topic]');
        let loadingState = new LoadingState();

        element.addEventListener('click', function(e: Event) {
        $target.addEventListener('click', (e: Event) => {
            e.preventDefault();
            loadingState.bind(element, function() {
                return new Promise(function(resolve) {
                    dispatchCustomEvent(element, 'loadPosts', {
                        callback: function() {
            loadingState.bind(() => {
                return new Promise((resolve) => {
                    dispatchCustomEvent($target, 'loadPosts', {
                        callback: () => {
                            resolve();
                        }
                    });
                });
            });
            }, $target);
        });

        let topicElement = element.closest('[data-topic]');
        topicElement.addEventListener('postsLoaded', function(e: CustomEvent) {
            self.updateButtonAlt(element);
            self.refreshButtonState(element, e.detail.lastPostNumber);
            self.updateButtonAlt(element);
        });
        if ($topic) {
          $topic.addEventListener('postsLoaded', (e: CustomEvent) => {
              this.updateButtonAlt($target);
              this.refreshButtonState($target, e.detail.lastPostNumber);
              this.updateButtonAlt($target);
          });
        }
    }

    private updateButtonAlt(element: Element): void {
        let altLabel = element.getAttribute('data-topic-reloader-label');
        let altClass = element.getAttribute('data-topic-reloader-class');
        if (altLabel) { element.innerHTML = altLabel; }
        if (altClass) { element.className = altClass; }
    private updateButtonAlt($target: Element): void {
        let altLabel = $target.getAttribute('data-topic-reloader-label');
        let altClass = $target.getAttribute('data-topic-reloader-class');

        if (altLabel) {
            $target.innerHTML = altLabel;
        }

        if (altClass) {
            $target.className = altClass;
        }
    }

    private refreshButtonState(element: Element, lastPostNumber: number): void {
        element.setAttribute(
            'href',
            element.getAttribute('href').replace(
                /^(\/\w+\/\d+)\/\d+\-\/$/,
                `$1/${lastPostNumber}-/`
            )
        );
    private refreshButtonState($target: Element, lastPostNumber: number): void {
        let originalHref = $target.getAttribute('href');

        if (originalHref) {
          $target.setAttribute(
              'href',
              originalHref.replace(
                  /^(\/\w+\/\d+)\/\d+\-\/$/,
                  `$1/${lastPostNumber}-/`
              )
          );
        }
    }
}

M assets/app/javascripts/components/topic_state_tracker.ts => assets/app/javascripts/components/topic_state_tracker.ts +30 -29
@@ 5,67 5,68 @@ import {dispatchCustomEvent} from '../utils/elements';
export class TopicStateTracker extends CollectionComponent {
    public targetSelector = '[data-topic-state-tracker]';

    protected bindOne(element: Element): void {
        let trackerName = element.getAttribute('data-topic-state-tracker');
    protected bindOne($target: Element): void {
        let trackerName = $target.getAttribute('data-topic-state-tracker');

        if (element instanceof HTMLInputElement) {
            switch (element.type) {
        if (!trackerName) {
            throw new Error('State tracker name is empty when it should not.');
        }

        if ($target instanceof HTMLInputElement) {
            switch ($target.type) {
            case 'checkbox':
                this.bindCheckbox(trackerName, element);
                this.bindCheckbox(trackerName, $target);
                break;
            }
        } else if (element instanceof HTMLTextAreaElement) {
            this.bindTextarea(trackerName, element);
        } else if ($target instanceof HTMLTextAreaElement) {
            this.bindTextarea(trackerName, $target);
        }
    }

    private bindCheckbox(
        trackerName: string,
        element: HTMLInputElement
        $target: HTMLInputElement
    ): void {
        let self = this;

        dispatchCustomEvent(element, 'readState', {
        dispatchCustomEvent($target, 'readState', {
            name: trackerName,
            callback: function(name: string, value: boolean | null) {
                if (value != null) {
                    element.checked = value;
                    element.defaultChecked = element.checked;
            callback: (name: string, value?: boolean) => {
                if (value != undefined) {
                    $target.checked = value;
                    $target.defaultChecked = $target.checked;
                }
            }
        });

        element.addEventListener('change', function(e: Event): void {
            element.defaultChecked = element.checked;
            dispatchCustomEvent(element, 'updateState', {
        $target.addEventListener('change', (e: Event): void => {
            $target.defaultChecked = $target.checked;
            dispatchCustomEvent($target, 'updateState', {
                name: trackerName,
                value: element.checked
                value: $target.checked
            });
        });
    }

    private bindTextarea(
        trackerName: string,
        element: HTMLTextAreaElement
        $target: HTMLTextAreaElement
    ): void {
        let self = this;
        let throttleTimer: number;

        dispatchCustomEvent(element, 'readState', {
        dispatchCustomEvent($target, 'readState', {
            name: trackerName,
            callback: function(name: string, value: string | null) {
                if (value != null) {
                    element.value = value;
            callback: (name: string, value?: string) => {
                if (value != undefined) {
                    $target.value = value;
                }
            }
        });

        element.addEventListener('change', function(e: Event): void {
        $target.addEventListener('change', (e: Event): void => {
            clearTimeout(throttleTimer);
            throttleTimer = setTimeout(function(){
                dispatchCustomEvent(element, 'updateState', {
            throttleTimer = setTimeout(() => {
                dispatchCustomEvent($target, 'updateState', {
                    name: trackerName,
                    value: element.value
                    value: $target.value
                });
            }, 500);
        });

M assets/app/javascripts/models/board.ts => assets/app/javascripts/models/board.ts +5 -5
@@ 41,9 41,9 @@ export class Board extends Model {
    static queryAll(
        token?: CancellableToken
    ): Promise<Board[]> {
        return request('GET', '/api/1.0/boards/', null, token).
            then(function(resp: string): Array<Board> {
                return JSON.parse(resp).map(function(data: Object) {
        return request('GET', '/api/1.0/boards/', {}, token).
            then((resp: string): Board[] => {
                return JSON.parse(resp).map((data: Object) => {
                    return new Board(data);
                });
            });


@@ 53,8 53,8 @@ export class Board extends Model {
        slug: string,
        token?: CancellableToken
    ): Promise<Board> {
        return request('GET', `/api/1.0/boards/${slug}/`, null, token).
            then(function(resp: string): Board {
        return request('GET', `/api/1.0/boards/${slug}/`, {}, token).
            then((resp: string): Board => {
                return new Board(JSON.parse(resp));
            });
    }

M assets/app/javascripts/models/post.ts => assets/app/javascripts/models/post.ts +13 -13
@@ 38,16 38,14 @@ export class Post extends Model {
        query?: string,
        token?: CancellableToken
    ): Promise<Post[]> {
        let entryPoint: string;
        let entryPoint = `/api/1.0/topics/${topicId}/posts/`;

        if (query) {
            entryPoint = `/api/1.0/topics/${topicId}/posts/${query}/`;
        } else {
            entryPoint = `/api/1.0/topics/${topicId}/posts/`;
            entryPoint = `${entryPoint}${query}/`;
        }

        return request('GET', entryPoint, null, token).then(function(resp) {
            return JSON.parse(resp).map(function(data: Object): Post {
        return request('GET', entryPoint, {}, token).then((resp) => {
            return JSON.parse(resp).map((data: Object): Post => {
                return new Post(data);
            });
        });


@@ 58,15 56,17 @@ export class Post extends Model {
        params: IRequestBody,
        token?: CancellableToken
    ): Promise<Post> {
        let self = this;
        let entryPoint = `/api/1.0/topics/${topicId}/posts/`;

        return request('POST', entryPoint, params, token).then(
            function(resp: string) {
        return request(
            'POST',
            `/api/1.0/topics/${topicId}/posts/`,
            params,
            token
        ).then(
            (resp: string) => {
                let data: IModelData = JSON.parse(resp);

                if (data['type'] == 'task') {
                    let id = data['id'];
                    return Task.waitFor(id, token).then(function(task: Task) {
                    return Task.waitFor(data['id'], token).then((task: Task) => {
                        return new Post(task.data);
                    });
                } else {

M assets/app/javascripts/models/task.ts => assets/app/javascripts/models/task.ts +3 -3
@@ 43,8 43,8 @@ export class Task extends Model {
        id: string,
        token?: CancellableToken
    ): Promise<Task> {
        return request('GET', `/api/1.0/tasks/${id}/`, null, token).then(
            function(resp: string) {
        return request('GET', `/api/1.0/tasks/${id}/`, {}, token).then(
            (resp: string) => {
                return new Task(JSON.parse(resp));
            }
        );


@@ 54,7 54,7 @@ export class Task extends Model {
        id: string,
        token?: CancellableToken
    ): Promise<Task> {
        return Task.queryId(id, token).then(function(task: Task) {
        return Task.queryId(id, token).then((task: Task) => {
            if (task.status == Statuses.Success) {
                return task;
            } else if (task.status == Statuses.Failure) {

M assets/app/javascripts/models/topic.ts => assets/app/javascripts/models/topic.ts +5 -5
@@ 47,9 47,9 @@ export class Topic extends Model {
        slug: string,
        token?: CancellableToken
    ): Promise<Topic[]> {
        return request('GET', `/api/1.0/boards/${slug}/topics/`, null, token).
            then(function(resp: string): Array<Topic> {
                return JSON.parse(resp).map(function(data: Object) {
        return request('GET', `/api/1.0/boards/${slug}/topics/`, {}, token).
            then((resp: string): Topic[] => {
                return JSON.parse(resp).map((data: Object) => {
                    return new Topic(data);
                });
            });


@@ 59,8 59,8 @@ export class Topic extends Model {
        id: number,
        token?: CancellableToken
    ): Promise<Topic> {
        return request('GET', `/api/1.0/topics/${id}/`, null, token).
            then(function(resp: string) {
        return request('GET', `/api/1.0/topics/${id}/`, {}, token).
            then((resp: string) => {
                return new Topic(JSON.parse(resp));
            });
    }

M assets/app/javascripts/utils/cancellable.ts => assets/app/javascripts/utils/cancellable.ts +3 -5
@@ 9,11 9,9 @@ export interface CancellableToken {
export class Cancelled implements Error {
    public name = 'Cancelled';

    constructor(public message?: string) {
        this.message = message;
        if (!this.message) {
            this.message = 'Promise was explicitly aborted by user.';
        }
    constructor(
        public message: string = 'Promise was explicitly aborted by the user.'
    ) {
    }
}


M assets/app/javascripts/utils/elements.ts => assets/app/javascripts/utils/elements.ts +14 -19
@@ 1,14 1,12 @@
export function addClass(
export const addClass = (
    element: Element,
    newClasses: string | string[]
): void {
    if (typeof(newClasses) == 'string') {
        newClasses = newClasses.split(' ');
    }

    newClasses: string[]
): void => {
    let classNames = element.className.split(' ');

    for (let i = 0, len = newClasses.length; i < len; i++) {
        let newClass = newClasses[i];

        if (classNames.indexOf(newClass) == -1) {
            classNames.push(newClass);
        }


@@ 18,18 16,16 @@ export function addClass(
}


export function removeClass(
export const removeClass = (
    element: Element,
    removeClasses: string | string[]
): void {
    if (typeof(removeClasses) == 'string') {
        removeClasses = removeClasses.split(' ');
    }

    removeClasses: string[]
): void => {
    let classNames = element.className.split(' ');

    for (let i = 0, len = removeClasses.length; i < len; i++) {
        let removeClass = removeClasses[i];
        let removeClassIdx = classNames.indexOf(removeClass);

        if (removeClassIdx != -1) {
            classNames.splice(removeClassIdx, 1);
        }


@@ 39,14 35,13 @@ export function removeClass(
}


export function dispatchCustomEvent(
export const dispatchCustomEvent = (
    element: Element,
    eventName: string,
    opts?: any,
): void {
    if (opts == null) { opts = {}; }
    opts: any = {},
): void => {
    element.dispatchEvent(new CustomEvent(eventName, {
        bubbles: true,
        detail: opts
    }))
    }));
}

M assets/app/javascripts/utils/errors.ts => assets/app/javascripts/utils/errors.ts +9 -9
@@ 1,6 1,6 @@
export interface Error {
    name: string;
    message?: string;
    message: string;
    object?: any;
}



@@ 8,10 8,10 @@ export interface Error {
export class NotImplementedError implements Error {
    public name = 'NotImplementedError';

    constructor(public message?: string, public object?: any) {
        if (!this.message) {
            this.message = 'The method was called but not implemented.';
        }
    constructor(
        public message: string = 'The method was called but not implemented.',
        public object?: any,
    ) {
    }
}



@@ 19,9 19,9 @@ export class NotImplementedError implements Error {
export class ResourceError implements Error {
    public name = 'ResourceError';

    constructor(public message?: string, public object?: any) {
        if (!this.message) {
            this.message = 'The resource could not be retrieved from the API.';
        }
    constructor(
        public message: string = 'The resource could not be retrieved.',
        public object?: any,
    ) {
    }
}

M assets/app/javascripts/utils/formatters.ts => assets/app/javascripts/utils/formatters.ts +2 -1
@@ 14,7 14,7 @@ const monthNames = [
];


export function formatDate(date: Date): string {
export let formatDate = (date: Date): string => {
    let yyyy = date.getFullYear();
    let mmm = monthNames[date.getMonth()];
    let dd = `00${date.getDate()}`.slice(-2);


@@ 23,5 23,6 @@ export function formatDate(date: Date): string {
    let ss = `00${date.getSeconds()}`.slice(-2);
    let dateFormatted = `${mmm} ${dd}, ${yyyy}`;
    let timeFormatted = `${hh}:${nn}:${ss}`;

    return `${dateFormatted} at ${timeFormatted}`;
}

M assets/app/javascripts/utils/forms.ts => assets/app/javascripts/utils/forms.ts +31 -22
@@ 3,10 3,10 @@ import {ResourceError} from './errors';
import {addClass, removeClass} from './elements';


export function serializeForm(form: HTMLFormElement): any {
export let serializeForm = (form: HTMLFormElement): any => {
    let formData: {[key: string]: any} = {};

    function _insertField(name: string, value: any) {
    let _insertField = (name: string, value: any) => {
        if (name) {
            formData[name] = value;
        }


@@ 42,7 42,7 @@ export function serializeForm(form: HTMLFormElement): any {
                _insertField(field.name, fieldData);
                break;
            default:
                fieldData.push(field.name, field.value);
                _insertField(field.name, field.value);
                break;
            }
        } else if (field instanceof HTMLTextAreaElement) {


@@ 54,44 54,53 @@ export function serializeForm(form: HTMLFormElement): any {
}


export function attachErrors(form: HTMLFormElement, error: ResourceError) {
export let attachErrors = (form: HTMLFormElement, error: ResourceError) => {
    let data = error.object;

    function _attachError(fieldElement: Element, message: string): void {
    let _attachError = (fieldElement: Element, message: string): void => {
        let formItemElement = fieldElement.closest('.form-item');
        let err = h('span', {className: 'form-item-error'}, [String(message)]);
        addClass(formItemElement, 'error');
        fieldElement.parentElement.insertBefore(
            create(err),
            fieldElement.nextSibling
        );

        if (!!formItemElement) {
          let err = h('span', {className: 'form-item-error'}, [message]);

          addClass(formItemElement, ['error']);
          fieldElement.parentElement.insertBefore(
              create(err),
              fieldElement.nextSibling
          );
        }
    }

    if (data.status == 'params_invalid') {
        for (let field in <{[key: string]: string[]}>data.message) {
            if (data.message.hasOwnProperty(field)) {
                let fieldElement = form[field];
                let messages = data.message[field];
                _attachError(fieldElement, messages[0]);
                _attachError(
                    form[field],
                    data.message[field]
                );
            }
        }
    } else {
        let anchorElement = form.querySelector('[data-form-anchor]');
        _attachError(anchorElement, data.message);
        _attachError(
            form.querySelector('[data-form-anchor]'),
            data.message
        );
    }
}


export function detachErrors(form: HTMLFormElement) {
export let detachErrors = (form: HTMLFormElement) => {
    let errorElements = form.querySelectorAll('.error');
    let msgElements = form.querySelectorAll('.form-item-error');

    for (let i = 0, len = errorElements.length; i < len; i++) {
        let errorElement = errorElements[0];
        removeClass(errorElement, 'error');
        removeClass(
            errorElements[0],
            ['error']
        );
    }

    let msgElements = form.querySelectorAll('.form-item-error');
    for (let i = 0, len = msgElements.length; i < len; i++) {
        let msgElement = msgElements[0];
        msgElement.parentElement.removeChild(msgElement);
        msgElements[0].parentElement.removeChild(msgElements[0]);
    }
}

M assets/app/javascripts/utils/loading.ts => assets/app/javascripts/utils/loading.ts +9 -8
@@ 2,26 2,27 @@ import {addClass, removeClass} from './elements';


export class LoadingState {
    isLoading: boolean;
    isLoading: boolean = false;

    bind(buttonElement: Element | null, fn: (() => Promise<any>)): void {
        let self = this;
    bind(fn: (() => Promise<any>), buttonElement?: Element): void {
        if (!this.isLoading) {
            this.isLoading = true;

            if (buttonElement) {
                addClass(buttonElement, 'js-button-loading');
                addClass(buttonElement, ['js-button-loading']);
            }

            fn().
                then(function() { self.unbind(buttonElement); }).
                catch(function() { self.unbind(buttonElement); });
                then(() => { this.unbind(buttonElement); }).
                catch(() => { this.unbind(buttonElement); });
        }
    }

    private unbind(buttonElement: Element | null) {
    private unbind(buttonElement?: Element) {
        this.isLoading = false;

        if (buttonElement) {
            removeClass(buttonElement, 'js-button-loading');
            removeClass(buttonElement, ['js-button-loading']);
        }
    }
}

M assets/app/javascripts/utils/request.ts => assets/app/javascripts/utils/request.ts +11 -12
@@ 6,30 6,29 @@ export interface IRequestBody {
}


export function request(
export const request = (
    method: string,
    url: string,
    params?: IRequestBody,
    params: IRequestBody = {},
    token?: CancellableToken
): Promise<string> {
): Promise<string> => {
    let xhr = new XMLHttpRequest();
    xhr.open(method, url);
    let body = JSON.stringify(params);

    let body: string = null;
    if (params) {
        xhr.setRequestHeader('Content-Type', 'application/json');
        body = JSON.stringify(params);
    }
    xhr.open(method, url);
    xhr.setRequestHeader('Content-Type', 'application/json');

    return new Promise(function(resolve, reject) {
        xhr.onload = function() { resolve(xhr.responseText); }
    return new Promise((resolve, reject) => {
        xhr.onload = () => { resolve(xhr.responseText); }
        xhr.onerror = reject;

        if (token) {
            token.cancel = function(): void {
            token.cancel = (): void => {
                xhr.abort();
                reject(new Cancelled);
            }
        }

        xhr.send(body);
    });
}

M assets/app/javascripts/views/board_selector_view.ts => assets/app/javascripts/views/board_selector_view.ts +8 -14
@@ 4,34 4,28 @@ import {BoardView} from './board_view';


export class BoardSelectorView {
    boards: Board[];
    boardList: VNode[];
    boardListNode: VNode[];

    constructor(boards: Board[]) {
        this.boards = boards;
        this.boardList = this.renderBoards();
        this.boardListNode = BoardSelectorView.renderBoards(boards);
    }

    render(args?: any): VNode {
    render(args: any = {}): VNode {
        return h('div', BoardSelectorView.getViewClassName(args), [
            h('div', {className: 'js-board-selector-inner'},
                this.boardList
                this.boardListNode
            )
        ]);
    }

    private renderBoards(): VNode[] {
        return this.boards.map(function(board: Board): VNode {
    private static renderBoards(boards: Board[]): VNode[] {
        return boards.map((board: Board): VNode => {
            return new BoardView(board).render();
        })
    }

    private static getViewClassName(args?: any): any {
        let className = 'js-board-selector';

        if (!args) {
            args = {};
        }
    private static getViewClassName(args: any): any {
        const className = 'js-board-selector';

        if (args.className) {
            let classNames = args.className.split(' ');

M assets/app/javascripts/views/board_view.ts => assets/app/javascripts/views/board_view.ts +15 -16
@@ 3,24 3,25 @@ import {Board} from '../models/board';


export class BoardView {
    board: Board;
    boardNode: VNode;

    constructor(board: Board) {
        this.board = board;
        this.boardNode = BoardView.renderBoard(board);
    }

    render(): VNode {
        return h('div',
            {className: 'js-board'},
            [
                h('div', {className: 'cascade'}, [
                    h('div', {className: 'container'}, [
                        BoardView.renderTitle(this.board),
                        BoardView.renderDescription(this.board),
                    ])
        return this.boardNode;
    }

    private static renderBoard(board: Board): VNode {
        return h('div', {className: 'js-board'}, [
            h('div', {className: 'cascade'}, [
                h('div', {className: 'container'}, [
                    BoardView.renderTitle(board),
                    BoardView.renderDescription(board),
                ])
            ]
        );
            ])
        ]);
    }

    private static renderTitle(board: Board): VNode {


@@ 28,13 29,11 @@ export class BoardView {
            h('a', {
                className: 'cascade-header-link',
                href: `/${board.slug}/`
            }, String(board.title))
            }, [board.title])
        ]);
    }

    private static renderDescription(board: Board): VNode {
        return h('div', {className: 'cascade-body'}, [
            String(board.description)
        ]);
        return h('div', {className: 'cascade-body'}, [board.description]);
    }
}

M assets/app/javascripts/views/popover_view.ts => assets/app/javascripts/views/popover_view.ts +55 -41
@@ 2,39 2,18 @@ import {VNode, h} from 'virtual-dom';


export class PopoverView {
    constructor(
        public targetElement: Element,
        public childNode: VNode,
        public title?: string,
        public dismissFn?: (() => void),
    ) {
    }
    popoverChildNodes: VNode[];

    render(args?: any): VNode {
        let self = this;
        let pos = this.computePosition();
        let titleNode: VNode;
    constructor(childNode: VNode, title?: string, dismissFn?: (() => void)) {
        this.popoverChildNodes = PopoverView.renderChild(
            childNode,
            title,
            dismissFn,
        );
    }

        if (this.title != null) {
            let dismissNode: VNode;

            if (this.dismissFn != null) {
                dismissNode = h('a', {
                    onclick: function(): void { self.dismissFn(); },
                    className: 'js-popover-inner-title-dismiss',
                    href: '#',
                }, [String('Close')]);
            }

            titleNode = h('div', {
                className: 'js-popover-inner-title',
            }, [
                h('span', {
                    className: 'js-popover-inner-title-label'
                }, [this.title]),
                dismissNode,
            ]);
        }
    render(targetElement: Element, args: any = {}): VNode {
        let pos = PopoverView.computePosition(targetElement);

        return h('div', PopoverView.getViewClassName(args), [
            h('div', {


@@ 44,18 23,57 @@ export class PopoverView {
                    top: `${pos.posX}px`,
                    left: `${pos.posY}px`,
                }
            }, [titleNode, this.childNode])
            }, this.popoverChildNodes)
        ]);
    }

    private computePosition(): {posX: number, posY: number} {
    private static renderChild(
        childNode: VNode,
        title?: string,
        dismissFn?: (() => void)
    ): VNode[] {
        let popoverChildNodes: VNode[] = [];

        if (title) {
            let titleChildNodes = [
                h('span', {
                    className: 'js-popover-inner-title-label'
                }, [title])
            ];

            if (dismissFn) {
                titleChildNodes.push(h('a', {
                    className: 'js-popover-inner-title-dismiss',
                    href: '#',
                    onclick: (e: Event): void => {
                        e.preventDefault();
                        if (dismissFn) {
                            dismissFn();
                        }
                    },
                }, ['Close']));
            }

            popoverChildNodes.push(h('div', {
                className: 'js-popover-inner-title',
            }, titleChildNodes));
        }

        popoverChildNodes.push(childNode);
        return popoverChildNodes;
    }

    private static computePosition(targetElement: Element): {
        posX: number,
        posY: number
    } {
        let bodyRect = document.body.getBoundingClientRect();
        let elemRect = this.targetElement.getBoundingClientRect();
        let elemRect = targetElement.getBoundingClientRect();
        let yRefRect = elemRect;

        // Indent relative to container rather than element if there is
        // container in element ancestor.
        let containerElement = this.targetElement.closest('.container');
        let containerElement = targetElement.closest('.container');
        if (containerElement) {
            yRefRect = containerElement.getBoundingClientRect();
        }


@@ 66,12 84,8 @@ export class PopoverView {
        }
    }

    private static getViewClassName(args?: any): any {
        let className = 'js-popover';

        if (!args) {
            args = {};
        }
    private static getViewClassName(args: any): any {
        const className = 'js-popover';

        if (args.className) {
            let classNames = args.className.split(' ');

M assets/app/javascripts/views/post_collection_view.ts => assets/app/javascripts/views/post_collection_view.ts +24 -19
@@ 4,30 4,30 @@ import {Post} from '../models/post';


export class PostCollectionView {
    posts: Post[];
    postsNode: VNode;

    constructor(posts: Post[]) {
        this.posts = posts;
        this.postsNode = PostCollectionView.renderPosts(posts);
    }

    render(): VNode {
        return this.postsNode;
    }

    private static renderPosts(posts: Post[]): VNode {
        return h('div',
            {className: 'js-post-collection'},
            this.posts.map(function(post: Post): VNode {
                return PostCollectionView.renderPost(post);
            posts.map((post: Post): VNode => {
                return h('div', {className: 'post'}, [
                    h('div', {className: 'container'}, [
                        PostCollectionView.renderHeader(post),
                        PostCollectionView.renderBody(post),
                    ])
                ])
            })
        );
    }

    private static renderPost(post: Post): VNode {
        return h('div', {className: 'post'}, [
            h('div', {className: 'container'}, [
                PostCollectionView.renderHeader(post),
                PostCollectionView.renderBody(post),
            ])
        ]);
    }

    private static renderHeader(post: Post): VNode {
        return h('div', {className: 'post-header'}, [
            PostCollectionView.renderHeaderNumber(post),


@@ 39,36 39,41 @@ export class PostCollectionView {

    private static renderHeaderNumber(post: Post): VNode {
        let classList = ['post-header-item', 'number'];
        if (post.bumped) { classList.push('bumped'); }

        if (post.bumped) {
            classList.push('bumped');
        }

        return h('span', {
            className: classList.join(' '),
            dataset: {
                topicQuickReply: post.number
            }
        }, [String(post.number)]);
        }, [post.number.toString()]);
    }

    private static renderHeaderName(post: Post): VNode {
        return h('span', {
            className: 'post-header-item name'
        }, [String(post.name)]);
        }, [post.name]);
    }

    private static renderHeaderDate(post: Post): VNode {
        let createdAt = new Date(post.createdAt);
        let formatter = formatDate(createdAt);

        return h('span', {
            className: 'post-header-item date'
        }, [String(`Posted ${formatter}`)]);
        }, [`Posted ${formatter}`]);
    }

    private static renderHeaderIdent(post: Post): VNode | string {
        if (post.ident) {
            return h('span', {
                className: 'post-header-item ident'
            }, [String(`ID:${post.ident}`)]);
            }, [`ID:${post.ident}`]);
        } else {
            return String(null);
            return '';
        }
    }


M assets/app/javascripts/views/post_form.ts => assets/app/javascripts/views/post_form.ts +26 -24
@@ 2,7 2,17 @@ import {VNode, h} from 'virtual-dom';


export class PostForm {
    render(defaultText?: string): VNode {
    postFormNode: VNode;

    constructor(defaultText: string = '') {
        this.postFormNode = PostForm.renderForm(defaultText);
    }

    render(): VNode {
        return this.postFormNode;
    }

    private static renderForm(defaultText: string): VNode {
        return h('div', {className: 'js-post-form'}, [
            h('form', {
                className: 'form',


@@ 11,28 21,28 @@ export class PostForm {
                }
            }, [
                h('div', {className: 'container'}, [
                    PostForm.renderBodyFormItem(defaultText),
                    PostForm.renderPostFormItem()
                    h('div', {className: 'form-item'}, [
                        PostForm.renderBodyFormItemLabel(),
                        PostForm.renderBodyFormItemInput(defaultText),
                    ]),
                    h('div', {className: 'form-item'}, [
                        PostForm.renderPostFormItemButton(),
                        ' ',
                        PostForm.renderPostFormItemBump(),
                    ])
                ])
            ])
        ]);
    }

    private static renderBodyFormItem(defaultText?: string): VNode {
        return h('div', {className: 'form-item'}, [
            PostForm.renderBodyFormItemLabel(),
            PostForm.renderBodyFormItemInput(defaultText),
        ]);
    }

    private static renderBodyFormItemLabel(): VNode {
        return h('label', {
            className: 'form-item-label',
            htmlFor: 'js-body',
        }, [String('Reply')]);
        }, ['Reply']);
    }

    private static renderBodyFormItemInput(defaultText?: string): VNode {
    private static renderBodyFormItemInput(defaultText: string): VNode {
        return h('textarea', {
            id: 'js-body',
            className: 'input block content',


@@ 44,25 54,17 @@ export class PostForm {
        }, [defaultText]);
    }

    private static renderPostFormItem(): VNode {
        return h('div', {className: 'form-item'}, [
            PostForm.renderPostFormItemButton(),
            String(' '),
            PostForm.renderPostFormItemBump(),
        ]);
    }

    private static renderPostFormItemButton(): VNode {
        return h('button', {
            className: 'button green',
            type: 'submit'
        }, [String('Post Reply')]);
        }, ['Post Reply']);
    }

    private static renderPostFormItemBump(): VNode {
        return h('span', {className: 'form-item-inline'}, [
            PostForm.renderPostFormItemBumpInput(),
            String(' '),
            ' ',
            PostForm.renderPostFormItemBumpLabel(),
        ]);
    }


@@ 83,7 85,7 @@ export class PostForm {
    private static renderPostFormItemBumpLabel(): VNode {
        return h('label',
            {htmlFor: 'js-bumped'},
            [String('Bump this topic')
        ]);
            ['Bump this topic']
        );
    }
}

M assets/app/javascripts/views/theme_selector_view.ts => assets/app/javascripts/views/theme_selector_view.ts +3 -7
@@ 9,15 9,11 @@ export interface ITheme {


export class ThemeSelectorView {
    themeListNode: VNode[];

    constructor(public themes: ITheme[]) {}

    render(currentTheme?: string): VNode {
        return h('div', {className: 'js-theme-selector'}, [
            h('span', {
                className: 'js-theme-selector-title'
            }, [String('Theme')]),
            h('span', {className: 'js-theme-selector-title'}, ['Theme']),
            this.renderThemes(currentTheme),
        ]);
    }


@@ 25,7 21,7 @@ export class ThemeSelectorView {
    private renderThemes(currentTheme?: string): VNode {
        return h('ul',
            {className: 'js-theme-selector-list'},
            this.themes.map(function(theme: ITheme): VNode {
            this.themes.map((theme: ITheme): VNode => {
                return h('li', {className: 'js-theme-selector-list-item'}, [
                    h('a', {
                        href: '#',


@@ 34,7 30,7 @@ export class ThemeSelectorView {
                            theme.identifier,
                            currentTheme
                        ),
                    }, [String(theme.name)])
                    }, [theme.name])
                ]);
            })
        );

M assets/app/javascripts/views/topic_view.ts => assets/app/javascripts/views/topic_view.ts +14 -12
@@ 4,21 4,25 @@ import {Topic} from '../models/topic';


export class TopicView {
    topic: Topic;
    topicNode: VNode;

    constructor(topic: Topic) {
        this.topic = topic;
        this.topicNode = TopicView.renderTopic(topic);
    }

    render(): VNode {
        return this.topicNode;
    }

    private static renderTopic(topic: Topic): VNode {
        return h('div',
            {className: 'js-topic'},
            [
                h('div', {className: 'topic-header'}, [
                    h('div', {className: 'container'}, [
                        TopicView.renderTitle(this.topic),
                        TopicView.renderDate(this.topic),
                        TopicView.renderCount(this.topic),
                        TopicView.renderTitle(topic),
                        TopicView.renderDate(topic),
                        TopicView.renderCount(topic),
                    ])
                ])
            ]


@@ 26,24 30,22 @@ export class TopicView {
    }

    private static renderTitle(topic: Topic): VNode {
        return h('h3', {className: 'topic-header-title'}, [
            String(topic.title)
        ]);
        return h('h3', {className: 'topic-header-title'}, [topic.title]);
    }

    private static renderDate(topic: Topic): VNode {
        let postedAt = new Date(topic.postedAt);
        let formatter = formatDate(postedAt);
        return h('p', {className: 'topic-header-item'}, [
            String('Last posted '),
            h('strong', {}, [String(formatter)]),
            'Last posted ',
            h('strong', {}, [formatter]),
        ]);
    }

    private static renderCount(topic: Topic): VNode {
        return h('p', {className: 'topic-header-item'}, [
            String('Total of '),
            h('strong', {}, [String(`${topic.postCount} posts`)]),
            'Total of ',
            h('strong', {}, [`${topic.postCount} posts`]),
        ]);
    }
}

M assets/app/stylesheets/js.scss => assets/app/stylesheets/js.scss +0 -1
@@ 72,7 72,6 @@
.js-board-selector {
    height: 0;
    overflow: hidden;
    visibility: hidden;
}

/* JS Theme Selector

M assets/app/stylesheets/post.scss => assets/app/stylesheets/post.scss +1 -0
@@ 20,6 20,7 @@ $post-date-size: round($font-size-small);
    border-bottom: 1px solid;
    margin: 0;
    overflow-x: auto;
    overflow-y: hidden;
    position: relative;

    /* Extra padding values for date part. */

M gulpfile.js => gulpfile.js +1 -1
@@ 29,7 29,7 @@ var paths = {
        javascripts: {
            glob: 'assets/app/javascripts/**/*.ts',
            base: 'assets/app/javascripts/',
            typings: 'typings/browser.d.ts',
            typings: 'typings/index.d.ts',
            entry: 'assets/app/javascripts/app.ts'
        }
    },

M tsconfig.json => tsconfig.json +3 -2
@@ 1,10 1,11 @@
{
  "compilerOptions": {
    "module": "commonjs",
    "noImplicitAny": true
    "noImplicitAny": true,
    "strictNullChecks": true
  },
  "files": [
    "typings/browser.d.ts",
    "typings/index.d.ts",
    "assets/app/javascripts/app.ts"
  ]
}

M typings.json => typings.json +1 -2
@@ 4,8 4,7 @@
    "domready": "github:typed-typings/npm-domready#f46b2ea5466978ea3bdd703591d1c16d7ae3832c"
  },
  "devDependencies": {},
  "ambientDependencies": {
    "dom4": "file:typings/vendor/dom4.d.ts",
  "globalDependencies": {
    "es6-promise": "github:DefinitelyTyped/DefinitelyTyped/es6-promise/es6-promise.d.ts#830e8ebd9ef137d039d5c7ede24a421f08595f83",
    "js-cookie": "github:DefinitelyTyped/DefinitelyTyped/js-cookie/js-cookie.d.ts#bb370b898e6a1528c5959932b4e238a574f22869",
    "virtual-dom": "github:DefinitelyTyped/DefinitelyTyped/virtual-dom/virtual-dom.d.ts#df507c636cf0c799a8a20f35af89a3d0caae6c32"

D typings/vendor/dom4.d.ts => typings/vendor/dom4.d.ts +0 -20
@@ 1,20 0,0 @@
interface Element {
    query(relativeSelectors: string): Element;
    queryAll(relativeSelectors: string): Array<Element>;

    prepend(nodes: Node | string | Array<Node | string>): void;
    append(nodes: Node | string | Array<Node | string>): void;

    before(nodes: Node | string | Array<Node | string>): void;
    after(nodes: Node | string | Array<Node | string>): void;
    replaceWith(nodes: Node | string | Array<Node | string>): void;
    remove(): void;

    matches(selector: string): boolean;
    closest(selector: string): Element;
}

interface Document {
    query(relativeSelectors: string): Element;
    queryAll(relativeSelectors: string): Array<Element>;
}
\ No newline at end of file

D typings/vendor/lodash.merge.d.ts => typings/vendor/lodash.merge.d.ts +0 -5
@@ 1,5 0,0 @@
/// <reference path="../lodash/lodash.d.ts" />

declare module "lodash.merge" {
    export = _.merge;
}