dmitri.shuralyov.com/app/changes/...

Remove code related to write behavior.

Keep only read behavior. This greatly simplifies the code and allows
faster development of changesapp-specific features.

Write behavior will be worked in the future, after read behavior is
fully complete.
dmitshur committed 6 years ago commit 510fbe7977beb4f59ca20598b357a06c4474acd0
Collapse all
_data/issue.html.tmpl → _data/change.html.tmpl
@@ -3,27 +3,25 @@
		{{template "head" .}}
	</head>
	<body>
		{{template "body-pre" .}}
		{{.BodyTop}}
		{{template "issue" .}}
		{{template "change" .}}
	</body>
</html>

{{define "issue"}}
{{define "change"}}
	<h1>{{.Change.Title}} <span class="gray">#{{.Change.ID}}</span></h1>
	<div id="change-state-badge" style="margin-bottom: 20px;">{{render (changeStateBadge .Change)}}</div>
	{{.Tabnav "Discussion"}}
	{{range .Items}}
		{{template "issue-item" .}}
		{{template "timeline-item" .}}
	{{end}}
	<div id="new-item-marker"></div>
	{{template "new-comment" .}}
{{end}}

{{define "issue-item"}}
{{define "timeline-item"}}
	{{if eq .TemplateName "comment"}}
		{{template "comment" .IssueItem}}
		{{template "comment" .TimelineItem}}
	{{else if eq .TemplateName "event"}}
		{{render (event .IssueItem)}}
		{{render (event .TimelineItem)}}
	{{end}}
{{end}}
_data/issues.html.tmpl → _data/changes.html.tmpl
@@ -3,11 +3,10 @@
		{{template "scriptless-head" .}}
	</head>
	<body>
		{{template "body-pre" .}}
		{{.BodyTop}}
		{{template "create-issue" .}}
		{{render .Changes}}
	</body>
</html>

{{define "scriptless-head"}}
@@ -17,17 +16,7 @@
	{{.HeadPost}}
{{end}}

{{define "head"}}
	{{template "scriptless-head" .}}
	<script type="text/javascript">
		var State = {{.State | jsonfmt}};
		{{/*var State = {{.State}};*/}}
	</script>
	<script src="{{.BaseURI}}/assets/script.js" type="text/javascript"></script>
{{end}}

{{define "create-issue"}}
	{{if not .DisableUsers}}
		<div style="text-align: right;"><button class="btn btn-success btn-small" onclick="window.location = '{{.BaseURI}}/new';">Create Issue</button></div>
	{{end}}
{{end}}
_data/comment.html.tmpl
@@ -1,8 +1,8 @@
{{/* Dot is an issues.Comment. */}}
{{define "comment"}}
<div class="comment-edit-container">
<div>
	<div>
		<div style="float: left; margin-right: 10px;">{{render (avatar .User)}}</div>
		<div id="comment-{{.ID}}" style="display: flex;" class="list-entry">
			<div class="list-entry-container list-entry-border">
				<header class="list-entry-header" style="display: flex;">
@@ -24,13 +24,10 @@
					</div>
				</div>
			</div>
		</div>
	</div>
	<div style="display: none;">
		{{template "edit-comment" .}}
	</div>
	{{if (not state.DisableReactions)}}
		{{render (reactionsBar .Reactions (reactableID .ID))}}
	{{end}}
</div>
{{end}}
_data/edit-comment.html.tmpl
@@ -1,23 +0,0 @@
{{/* TODO: Dedup with new-comment, only buttons differ, so factor them out. */}}
{{define "edit-comment"}}
<div style="display: flex;" class="edit-container list-entry">
	<div style="margin-right: 10px;">{{render (avatar .User)}}</div>
	<div class="list-entry-border" style="flex-grow: 1;">
		<header class="list-entry-header tabs" style="display: flex;">
			<span style="flex-grow: 1; font-size: 14px;">
				<a class="write-tab-link black tab-link active" tabindex=-1 href="javascript:" onclick="SwitchWriteTab(this);">Write</a>
				<a class="preview-tab-link black tab-link" tabindex=-1 href="javascript:" onclick="MarkdownPreview(this);">Preview</a>
			</span>
			<span class="gray"><span style="margin-right: 6px;">{{octicon "markdown"}}</span>Markdown</span>
		</header>
		<div class="list-entry-body">
			<textarea class="comment-editor" placeholder="Leave a comment." onpaste="PasteHandler(event);" onkeydown="TabSupportKeyDownHandler(this, event);" data-id="{{.ID}}" data-raw="{{.Body}}" tabindex=1></textarea>
			<div class="comment-preview markdown-body" style="padding: 11px 11px 10px 11px; min-height: 120px; box-sizing: border-box; border-bottom: 1px solid #eee; display: none;"></div>
			<div style="text-align: right; margin-top: 10px;">
				<button class="btn btn-success btn-small" onclick="EditComment({{`update` | json}}, this);" tabindex=1>Update comment</button>
				<button class="btn btn-danger btn-small" onclick="EditComment({{`cancel` | json}}, this);" tabindex=1>Cancel</button>
			</div>
		</div>
	</div>
</div>
{{end}}
_data/icon-text.html.tmpl
@@ -1,18 +0,0 @@
{{/* TODO: Try to use issues.OpenState and issues.ClosedState constants. */}}
{{define "toggle-button"}}
	{{if eq . "open"}}
		{{template "close-button"}}
	{{else if eq . "closed"}}
		{{template "reopen-button"}}
	{{else}}
		{{.}}
	{{end}}
{{end}}

{{define "close-button"}}
<button id="issue-toggle-button" class="btn btn-neutral btn-small" data-1-action="Close Issue" data-2-actions="Comment and close" onclick="ToggleIssueState('closed');" tabindex=1>Close Issue</button>
{{end}}

{{define "reopen-button"}}
<button id="issue-toggle-button" class="btn btn-neutral btn-small" data-1-action="Reopen Issue" data-2-actions="Reopen and comment" onclick="ToggleIssueState('open');" tabindex=1>Reopen Issue</button>
{{end}}
_data/new-comment.html.tmpl
@@ -1,28 +0,0 @@
{{define "new-comment"}}
{{if .CurrentUser.ID}}
	<div id="new-comment-container" class="edit-container list-entry" style="display: flex;">
		<div style="margin-right: 10px;">{{render (avatar .CurrentUser)}}</div>
		<div class="list-entry-border" style="flex-grow: 1;">
			<header class="list-entry-header tabs" style="display: flex;">
				<span style="flex-grow: 1; font-size: 14px;">
					<a class="write-tab-link black tab-link active" tabindex=-1 href="javascript:" onclick="SwitchWriteTab(this);">Write</a>
					<a class="preview-tab-link black tab-link" tabindex=-1 href="javascript:" onclick="MarkdownPreview(this);">Preview</a>
				</span>
				<span class="gray"><span style="margin-right: 6px;">{{octicon "markdown"}}</span>Markdown</span>
			</header>
			<div class="list-entry-body">
				<textarea class="comment-editor" placeholder="Leave a comment." onpaste="PasteHandler(event);" onkeydown="TabSupportKeyDownHandler(this, event);" tabindex=1></textarea>
				<div class="comment-preview markdown-body" style="padding: 11px 11px 10px 11px; min-height: 120px; box-sizing: border-box; border-bottom: 1px solid #eee; display: none;"></div>
				<div style="text-align: right; margin-top: 10px;">
					<button class="btn btn-success btn-small" onclick="PostComment();" tabindex=1>Comment</button>
					{{if .Issue.Editable}}{{template "toggle-button" (print .Issue.State)}}{{end}}
				</div>
			</div>
		</div>
	</div>
{{else}}
	<div class="event" style="margin-top: 20px; margin-bottom: 20px;">
		<form method="post" action="/login/github" style="display: inline-block; margin-bottom: 0;"><input class="btn" type="submit" value="Sign in via GitHub"><input type="hidden" name="return" value="{{.BaseURI}}{{.ReqPath}}"></form> to comment.
	</div>
{{end}}
{{end}}
_data/new-issue.html.tmpl
@@ -1,35 +0,0 @@
<html>
	<head>
		{{template "head" .}}
	</head>
	<body>
		{{template "body-pre" .}}
		{{.BodyTop}}
		{{template "new-issue" .}}
	</body>
</html>

{{define "new-issue"}}
<div style="display: flex; margin-top: 20px;" class="edit-container list-entry">
	<div style="margin-right: 10px;">{{render (avatar .CurrentUser)}}</div>
	<div class="list-entry-border" style="flex-grow: 1;">
		<header class="list-entry-header tabs-title">
			<div><input id="title-editor" type="text" placeholder="Title" autofocus></div>
			<div style="display: flex;">
				<span style="flex-grow: 1; font-size: 14px;">
					<a class="write-tab-link black tab-link active" tabindex=-1 href="javascript:" onclick="SwitchWriteTab(this);">Write</a>
					<a class="preview-tab-link black tab-link" tabindex=-1 href="javascript:" onclick="MarkdownPreview(this);">Preview</a>
				</span>
				<span class="gray"><span style="margin-right: 6px;">{{octicon "markdown"}}</span>Markdown</span>
			</div>
		</header>
		<div class="list-entry-body">
			<textarea class="comment-editor" style="min-height: 200px;" placeholder="Leave a comment." onpaste="PasteHandler(event);" onkeydown="TabSupportKeyDownHandler(this, event);"></textarea>
			<div class="comment-preview markdown-body" style="padding: 10px; min-height: 200px; display: none;"></div>
			<div style="text-align: right; margin-top: 10px;">
				<button id="create-issue-button" class="btn btn-success btn-small" disabled="disabled" onclick="CreateNewIssue();">Create Issue</button>
			</div>
		</div>
	</div>
</div>
{{end}}
common/common.go
@@ -7,10 +7,10 @@ import (

type State struct {
	BaseURI          string
	ReqPath          string
	RepoSpec         string
	IssueID          uint64 `json:",omitempty"` // IssueID is the current issue ID, or 0 if not applicable (e.g., current page is /new).
	ChangeID         uint64 `json:",omitempty"` // ChangeID is the current change ID, or 0 if not applicable (e.g., current page is /changes).
	CurrentUser      users.User
	DisableReactions bool
	DisableUsers     bool
}
component/issues.go → component/changes.go
@@ -8,23 +8,23 @@ import (
	"github.com/shurcooL/octiconssvg"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

// Issues is a component that displays a page of changes,
// Changes is a component that displays a page of changes,
// with a navigation bar on top.
type Issues struct {
type Changes struct {
	IssuesNav IssuesNav
	Filter    changes.StateFilter
	Entries   []ChangeEntry
}

func (i Issues) Render() []*html.Node {
func (i Changes) Render() []*html.Node {
	// TODO: Make this much nicer.
	// <div class="list-entry list-entry-border">
	// 	{{render .IssuesNav}}
	// 	{{with .Issues}}{{range .}}
	// 	{{with .Entries}}{{range .}}
	// 		{{render .}}
	// 	{{end}}{{else}}
	// 		<div style="text-align: center; margin-top: 80px; margin-bottom: 80px;">There are no {{.Filter}} changes.</div>
	// 	{{end}}
	// </div>
component/tabs.go
@@ -8,18 +8,18 @@ import (
	"github.com/shurcooL/octiconssvg"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

// IssuesNav is a navigation component for displaying a header for a list of issues.
// It contains tabs to switch between viewing open and closed issues.
// IssuesNav is a navigation component for displaying a header for a list of changes.
// It contains tabs to switch between viewing open and closed changes.
type IssuesNav struct {
	OpenCount     uint64     // Open issues count.
	ClosedCount   uint64     // Closed issues count.
	OpenCount     uint64     // Open changes count.
	ClosedCount   uint64     // Closed changes count.
	Path          string     // URL path of current page (needed to generate correct links).
	Query         url.Values // URL query of current page (needed to generate correct links).
	StateQueryKey string     // Name of query key for controlling issue state filter. Constant, but provided externally.
	StateQueryKey string     // Name of query key for controlling change state filter. Constant, but provided externally.
}

func (n IssuesNav) Render() []*html.Node {
	// TODO: Make this much nicer.
	// <header class="list-entry-header">
@@ -81,31 +81,31 @@ func (n IssuesNav) rawQuery(tabName string) string {
	return q.Encode()
}

// OpenIssuesTab is an "Open Issues Tab" component.
type OpenIssuesTab struct {
	Count uint64 // Count of open issues.
	Count uint64 // Count of open changes.
}

func (t OpenIssuesTab) Render() []*html.Node {
	// TODO: Make this much nicer.
	// <span style="margin-right: 4px;">{{octicon "issue-opened"}}</span>
	// <span style="margin-right: 4px;">{{octicon "git-pull-request"}}</span>
	// {{.Count}} Open
	icon := &html.Node{
		Type: html.ElementNode, Data: atom.Span.String(),
		Attr: []html.Attribute{
			{Key: atom.Style.String(), Val: "margin-right: 4px;"},
		},
		FirstChild: octiconssvg.IssueOpened(),
		FirstChild: octiconssvg.GitPullRequest(),
	}
	text := htmlg.Text(fmt.Sprintf("%d Open", t.Count))
	return []*html.Node{icon, text}
}

// ClosedIssuesTab is a "Closed Issues Tab" component.
type ClosedIssuesTab struct {
	Count uint64 // Count of closed issues.
	Count uint64 // Count of closed changes.
}

func (t ClosedIssuesTab) Render() []*html.Node {
	// TODO: Make this much nicer.
	// <span style="margin-right: 4px;">{{octicon "check"}}</span>
display.go
@@ -5,51 +5,51 @@ import (
	"time"

	"github.com/shurcooL/issues"
)

// issueItem represents an issue item for display purposes.
type issueItem struct {
	// IssueItem can be one of issues.Comment, issues.Event.
	IssueItem interface{}
// timelineItem represents a timeline item for display purposes.
type timelineItem struct {
	// TimelineItem can be one of issues.Comment, issues.Event.
	TimelineItem interface{}
}

func (i issueItem) TemplateName() string {
	switch i.IssueItem.(type) {
func (i timelineItem) TemplateName() string {
	switch i.TimelineItem.(type) {
	case issues.Comment:
		return "comment"
	case issues.Event:
		return "event"
	default:
		panic(fmt.Errorf("unknown item type %T", i.IssueItem))
		panic(fmt.Errorf("unknown item type %T", i.TimelineItem))
	}
}

func (i issueItem) CreatedAt() time.Time {
	switch i := i.IssueItem.(type) {
func (i timelineItem) CreatedAt() time.Time {
	switch i := i.TimelineItem.(type) {
	case issues.Comment:
		return i.CreatedAt
	case issues.Event:
		return i.CreatedAt
	default:
		panic(fmt.Errorf("unknown item type %T", i))
	}
}

func (i issueItem) ID() uint64 {
	switch i := i.IssueItem.(type) {
func (i timelineItem) ID() uint64 {
	switch i := i.TimelineItem.(type) {
	case issues.Comment:
		return i.ID
	case issues.Event:
		return i.ID
	default:
		panic(fmt.Errorf("unknown item type %T", i))
	}
}

// byCreatedAtID implements sort.Interface.
type byCreatedAtID []issueItem
type byCreatedAtID []timelineItem

func (s byCreatedAtID) Len() int { return len(s) }
func (s byCreatedAtID) Less(i, j int) bool {
	if s[i].CreatedAt().Equal(s[j].CreatedAt()) {
		// If CreatedAt time is equal, fall back to ID as a tiebreaker.
frontend/edit.go
@@ -1,86 +0,0 @@
package main

import (
	"bytes"
	"context"
	"log"
	"strconv"

	"github.com/shurcooL/github_flavored_markdown"
	"github.com/shurcooL/issues"
	"github.com/shurcooL/markdownfmt/markdown"
	"honnef.co/go/js/dom"
)

func (f *frontend) EditComment(action string, this dom.HTMLElement) {
	container := getAncestorByClassName(this, "comment-edit-container")
	// HACK: Currently the child nodes are [text, div, text, div, text], but that isn't reliable.
	commentView := container.ChildNodes()[1].(dom.HTMLElement)
	editView := container.ChildNodes()[3].(dom.HTMLElement)
	commentEditor := editView.QuerySelector(".comment-editor").(*dom.HTMLTextAreaElement)

	switch action {
	case "edit":
		commentEditor.Value = commentEditor.GetAttribute("data-raw")

		commentView.Style().SetProperty("display", "none", "")
		editView.Style().SetProperty("display", "block", "")

		commentEditor.Focus()
	case "cancel", "update":
		switch action {
		case "cancel":
			if commentEditor.Value != commentEditor.GetAttribute("data-raw") {
				if !dom.GetWindow().Confirm("Are you sure you want to discard your unsaved changes?") {
					return
				}
			}
			commentEditor.Value = commentEditor.GetAttribute("data-raw")
		case "update":
			if commentEditor.Value != commentEditor.GetAttribute("data-raw") {
				fmted, _ := markdown.Process("", []byte(commentEditor.Value), nil)
				fmted = bytes.TrimSpace(fmted)
				if len(fmted) == 0 {
					// Empty body isn't allowed.
					// TODO: Unless it's an issue description (initial comment).
					// TODO: Display error? Disable "Update comment" button?
					return
				}
				commentID, err := strconv.ParseUint(commentEditor.GetAttribute("data-id"), 10, 64)
				if err != nil {
					panic(err)
				}

				go func() {
					body := string(fmted)
					cr := issues.CommentRequest{
						ID:   commentID,
						Body: &body,
					}
					_, err := f.is.EditComment(context.Background(), issues.RepoSpec{URI: state.RepoSpec}, state.IssueID, cr)
					if err != nil {
						// TODO: Handle failure more visibly in the UI.
						log.Println("EditComment:", err)
					}
				}()

				commentEditor.SetAttribute("data-raw", string(fmted))
				markdownBody := commentView.QuerySelector(".markdown-body").(*dom.HTMLDivElement)
				markdownBody.SetInnerHTML(string(github_flavored_markdown.Markdown(fmted)))
			}
		}

		commentView.Style().SetProperty("display", "block", "")
		editView.Style().SetProperty("display", "none", "")

		// TODO: switchWriteTab() (maybe without commentEditor.Focus() part).
		// TODO: Maybe without commentEditor.Focus() part?
		switchWriteTab(container, commentEditor)
	}
}

func getAncestorByClassName(el dom.Element, class string) dom.Element {
	for ; el != nil && !el.Class().Contains(class); el = el.ParentElement() {
	}
	return el
}
frontend/main.go
@@ -3,336 +3,19 @@
// It's a Go package meant to be compiled with GOARCH=js
// and executed in a browser, where the DOM is available.
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"net/url"
	"strings"

	"dmitri.shuralyov.com/changes/app/common"
	"github.com/gopherjs/gopherjs/js"
	"github.com/shurcooL/frontend/reactionsmenu"
	"github.com/shurcooL/frontend/tabsupport"
	"github.com/shurcooL/github_flavored_markdown"
	"github.com/shurcooL/go/gopherjs_http/jsutil"
	"github.com/shurcooL/issues"
	"github.com/shurcooL/issuesapp/httpclient"
	"github.com/shurcooL/markdownfmt/markdown"
	"golang.org/x/oauth2"
	"honnef.co/go/js/dom"
)

var document = dom.GetWindow().Document().(dom.HTMLDocument)

var state common.State

func main() {
	stateJSON := js.Global.Get("State").String()
	err := json.Unmarshal([]byte(stateJSON), &state)
	if err != nil {
		panic(err)
	}

	httpClient := httpClient()

	f := &frontend{is: httpclient.NewIssues(httpClient, "", "")}

	js.Global.Set("MarkdownPreview", jsutil.Wrap(MarkdownPreview))
	js.Global.Set("SwitchWriteTab", jsutil.Wrap(SwitchWriteTab))
	js.Global.Set("PasteHandler", jsutil.Wrap(PasteHandler))
	js.Global.Set("CreateNewIssue", CreateNewIssue)
	js.Global.Set("ToggleIssueState", ToggleIssueState)
	js.Global.Set("PostComment", PostComment)
	js.Global.Set("EditComment", jsutil.Wrap(f.EditComment))
	js.Global.Set("TabSupportKeyDownHandler", jsutil.Wrap(tabsupport.KeyDownHandler))
	js.Global.Set("ToggleDetails", jsutil.Wrap(ToggleDetails))

	switch readyState := document.ReadyState(); readyState {
	case "loading":
		document.AddEventListener("DOMContentLoaded", false, func(dom.Event) {
			go setup(f)
		})
	case "interactive", "complete":
		setup(f)
	default:
		panic(fmt.Errorf("internal error: unexpected document.ReadyState value: %v", readyState))
	}
}

func setup(f *frontend) {
	setupIssueToggleButton()

	if createIssueButton, ok := document.GetElementByID("create-issue-button").(dom.HTMLElement); ok {
		titleEditor := document.GetElementByID("title-editor").(*dom.HTMLInputElement)
		titleEditor.AddEventListener("input", false, func(_ dom.Event) {
			if strings.TrimSpace(titleEditor.Value) == "" {
				createIssueButton.SetAttribute("disabled", "disabled")
			} else {
				createIssueButton.RemoveAttribute("disabled")
			}
		})
	}

	if !state.DisableReactions {
		reactionsService := IssuesReactions{Issues: f.is}
		reactionsmenu.Setup(state.RepoSpec, reactionsService, state.CurrentUser)
	}
}

// httpClient gives an *http.Client for making API requests.
func httpClient() *http.Client {
	cookies := &http.Request{Header: http.Header{"Cookie": {document.Cookie()}}}
	if accessToken, err := cookies.Cookie("accessToken"); err == nil {
		// Authenticated client.
		src := oauth2.StaticTokenSource(
			&oauth2.Token{AccessToken: accessToken.Value},
		)
		return oauth2.NewClient(context.Background(), src)
	}
	// Not authenticated client.
	return http.DefaultClient
}

type frontend struct {
	is issues.Service
}

func setupIssueToggleButton() {
	if issueToggleButton := document.GetElementByID("issue-toggle-button"); issueToggleButton != nil {
		commentEditor := document.QuerySelector("#new-comment-container .comment-editor").(*dom.HTMLTextAreaElement)
		commentEditor.AddEventListener("input", false, func(_ dom.Event) {
			if strings.TrimSpace(commentEditor.Value) == "" {
				issueToggleButton.SetTextContent(issueToggleButton.GetAttribute("data-1-action"))
			} else {
				issueToggleButton.SetTextContent(issueToggleButton.GetAttribute("data-2-actions"))
			}
		})
	}
}

func postJSON(url string, v interface{}) (*http.Response, error) {
	b, err := json.Marshal(v)
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequest("POST", url, bytes.NewReader(b))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	return http.DefaultClient.Do(req)
}

func CreateNewIssue() {
	titleEditor := document.GetElementByID("title-editor").(*dom.HTMLInputElement)
	commentEditor := document.QuerySelector(".comment-editor").(*dom.HTMLTextAreaElement)

	title := strings.TrimSpace(titleEditor.Value)
	if title == "" {
		log.Println("cannot create issue with empty title")
		return
	}
	fmted, _ := markdown.Process("", []byte(commentEditor.Value), nil)
	newIssue := issues.Issue{
		Title: title,
		Comment: issues.Comment{
			Body: string(bytes.TrimSpace(fmted)),
		},
	}

	go func() {
		resp, err := postJSON("new", newIssue)
		if err != nil {
			log.Println(err)
			return
		}
		defer resp.Body.Close()
		body, err := ioutil.ReadAll(resp.Body)
		if err != nil {
			log.Println(err)
			return
		}

		fmt.Printf("got reply: %v\n%q\n", resp.Status, string(body))

		switch resp.StatusCode {
		case http.StatusOK:
			// Redirect.
			dom.GetWindow().Location().Href = string(body)
		}
	}()
}

func ToggleIssueState(issueState issues.State) {
	go func() {
		// Post comment first if there's text entered, and we're closing.
		if strings.TrimSpace(document.QuerySelector("#new-comment-container .comment-editor").(*dom.HTMLTextAreaElement).Value) != "" &&
			issueState == issues.ClosedState {
			err := postComment()
			if err != nil {
				log.Println(err)
				return
			}
		}

		ir := issues.IssueRequest{
			State: &issueState,
		}
		value, err := json.Marshal(ir)
		if err != nil {
			panic(err)
		}

		resp, err := http.PostForm(state.BaseURI+state.ReqPath+"/edit", url.Values{"value": {string(value)}})
		if err != nil {
			log.Println(err)
			return
		}
		defer resp.Body.Close()
		body, err := ioutil.ReadAll(resp.Body)
		if err != nil {
			log.Println(err)
			return
		}

		data, err := url.ParseQuery(string(body))
		if err != nil {
			log.Println(err)
			return
		}

		switch resp.StatusCode {
		case http.StatusOK:
			changeStateBadge := document.GetElementByID("change-state-badge")
			changeStateBadge.SetInnerHTML(data.Get("change-state-badge"))

			issueToggleButton := document.GetElementByID("issue-toggle-button")
			issueToggleButton.SetOuterHTML(data.Get("issue-toggle-button"))
			setupIssueToggleButton()

			for _, newEventData := range data["new-event"] {
				// Create event.
				newEvent := document.CreateElement("div").(*dom.HTMLDivElement)
				newItemMarker := document.GetElementByID("new-item-marker")
				newItemMarker.ParentNode().InsertBefore(newEvent, newItemMarker)
				newEvent.SetOuterHTML(newEventData)
			}
		}

		// Post comment after if there's text entered, and we're reopening.
		if strings.TrimSpace(document.QuerySelector("#new-comment-container .comment-editor").(*dom.HTMLTextAreaElement).Value) != "" &&
			issueState == issues.OpenState {
			err := postComment()
			if err != nil {
				log.Println(err)
				return
			}
		}
	}()
}

func PostComment() {
	go func() {
		err := postComment()
		if err != nil {
			log.Println(err)
		}
	}()
}

// postComment posts the comment to the remote API.
func postComment() error {
	commentEditor := document.QuerySelector("#new-comment-container .comment-editor").(*dom.HTMLTextAreaElement)

	fmted, _ := markdown.Process("", []byte(commentEditor.Value), nil)
	if len(fmted) == 0 {
		return fmt.Errorf("cannot post empty comment")
	}
	value := string(bytes.TrimSpace(fmted))

	resp, err := http.PostForm(state.BaseURI+state.ReqPath+"/comment", url.Values{"value": {value}})
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return err
	}

	fmt.Printf("got reply: %v\n", resp.Status)

	switch resp.StatusCode {
	case http.StatusOK:
		// Create comment.
		newComment := document.CreateElement("div").(*dom.HTMLDivElement)

		newItemMarker := document.GetElementByID("new-item-marker")
		newItemMarker.ParentNode().InsertBefore(newComment, newItemMarker)

		newComment.SetOuterHTML(string(body))

		// Reset new-comment component.
		commentEditor.Value = ""
		commentEditor.Underlying().Call("dispatchEvent", js.Global.Get("CustomEvent").New("input")) // Trigger "input" event listeners.
		switchWriteTab(document.GetElementByID("new-comment-container"), commentEditor)

		return nil
	default:
		return fmt.Errorf("did not get acceptable status code: %v", resp.Status)
	}
}

func MarkdownPreview(this dom.HTMLElement) {
	container := getAncestorByClassName(this, "edit-container")

	if container.QuerySelector(".preview-tab-link").(dom.Element).Class().Contains("active") {
		return
	}

	commentEditor := container.QuerySelector(".comment-editor").(*dom.HTMLTextAreaElement)
	commentPreview := container.QuerySelector(".comment-preview").(*dom.HTMLDivElement)

	fmted, _ := markdown.Process("", []byte(commentEditor.Value), nil)
	value := bytes.TrimSpace(fmted)

	if len(value) != 0 {
		commentPreview.SetInnerHTML(string(github_flavored_markdown.Markdown(value)))
	} else {
		commentPreview.SetInnerHTML(`<i class="gray">Nothing to preview.</i>`)
	}

	container.QuerySelector(".write-tab-link").(dom.Element).Class().Remove("active")
	container.QuerySelector(".preview-tab-link").(dom.Element).Class().Add("active")
	commentEditor.Style().SetProperty("display", "none", "")
	commentPreview.Style().SetProperty("display", "block", "")
}

func SwitchWriteTab(this dom.HTMLElement) {
	container := getAncestorByClassName(this, "edit-container")
	commentEditor := container.QuerySelector(".comment-editor").(*dom.HTMLTextAreaElement)
	switchWriteTab(container, commentEditor)
}

func switchWriteTab(container dom.Element, commentEditor *dom.HTMLTextAreaElement) {
	if container.QuerySelector(".preview-tab-link").(dom.Element).Class().Contains("active") {
		commentPreview := container.QuerySelector(".comment-preview").(*dom.HTMLDivElement)

		container.QuerySelector(".write-tab-link").(dom.Element).Class().Add("active")
		container.QuerySelector(".preview-tab-link").(dom.Element).Class().Remove("active")
		commentEditor.Style().SetProperty("display", "block", "")
		commentPreview.Style().SetProperty("display", "none", "")
	}

	commentEditor.Focus()
}

func ToggleDetails(el dom.HTMLElement) {
	container := getAncestorByClassName(el, "commit-container").(dom.HTMLElement)
	details := container.QuerySelector("pre.commit-details").(dom.HTMLElement)
@@ -342,5 +25,11 @@ func ToggleDetails(el dom.HTMLElement) {
		details.Style().SetProperty("display", "none", "")
	case "none":
		details.Style().SetProperty("display", "block", "")
	}
}

func getAncestorByClassName(el dom.Element, class string) dom.Element {
	for ; el != nil && !el.Class().Contains(class); el = el.ParentElement() {
	}
	return el
}
frontend/reactions.go
@@ -1,44 +0,0 @@
package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/shurcooL/issues"
	"github.com/shurcooL/reactions"
)

// IssuesReactions implements reactions.Service on top of issues.Service,
// specifically for use by issuesapp.
//
// The format of ID is "{{.issueID}}/{{.commentID}}".
type IssuesReactions struct {
	Issues issues.Service
}

// Toggle toggles an issue.
// id is "{{.issueID}}/{{.commentID}}".
func (ir IssuesReactions) Toggle(ctx context.Context, uri string, id string, tr reactions.ToggleRequest) ([]reactions.Reaction, error) {
	var issueID, commentID uint64
	_, err := fmt.Sscanf(id, "%d/%d", &issueID, &commentID)
	if err != nil {
		return nil, err
	}
	comment, err := ir.Issues.EditComment(ctx, issues.RepoSpec{URI: uri}, issueID, issues.CommentRequest{
		ID:       commentID,
		Reaction: &tr.Reaction,
	})
	if err != nil {
		return nil, err
	}
	return comment.Reactions, nil
}

func (ir IssuesReactions) Get(_ context.Context, uri string, id string) ([]reactions.Reaction, error) {
	return nil, errors.New("IssuesReactions.Get: not implemented")
}

func (ir IssuesReactions) List(_ context.Context, uri string) (map[string][]reactions.Reaction, error) {
	return nil, errors.New("IssuesReactions.List: not implemented")
}
frontend/upload.go
@@ -1,104 +0,0 @@
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"net/http"

	"github.com/gopherjs/gopherjs/js"
	"honnef.co/go/js/dom"
)

func PasteHandler(e dom.Event) {
	ce := e.(*dom.ClipboardEvent)

	items := ce.Get("clipboardData").Get("items")
	file, err := imagePNGFile(items)
	if err != nil {
		// No image to paste.
		return
	}

	// From this point, we're taking on the responsibility to handle this clipboard event.
	ce.PreventDefault()

	nameFunc, err := plainTextString(items)
	if err != nil {
		nameFunc = func() string { return "Image" }
	}

	go func() {
		b := blobToBytes(file)
		name := nameFunc()

		resp, err := http.Post("/api/usercontent", "image/png", bytes.NewReader(b))
		if err != nil {
			log.Println(err)
			return
		}
		defer resp.Body.Close()
		var uploadResponse struct {
			URL   string
			Error string
		}
		err = json.NewDecoder(resp.Body).Decode(&uploadResponse)
		if err != nil {
			log.Println(err)
			return
		}
		if uploadResponse.Error != "" {
			log.Println(uploadResponse.Error)
			return
		}

		insertText(ce.Target().(*dom.HTMLTextAreaElement), fmt.Sprintf("![%s](%s)\n\n", name, uploadResponse.URL))
	}()
}

func insertText(t *dom.HTMLTextAreaElement, inserted string) {
	value, start, end := t.Value, t.SelectionStart, t.SelectionEnd
	t.Value = value[:start] + inserted + value[end:]
	t.SelectionStart, t.SelectionEnd = start+len(inserted), start+len(inserted)
}

// imagePNGFile tries to get an "image/png" file from items.
func imagePNGFile(items *js.Object) (file *js.Object, err error) {
	for i := 0; i < items.Length(); i++ {
		item := items.Index(i)
		if item.Get("kind").String() != "file" || item.Get("type").String() != "image/png" {
			continue
		}
		return item.Call("getAsFile"), nil
	}
	return nil, fmt.Errorf("not found")
}

// plainTextString tries to get a "text/plain" string from items.
// The returned func blocks until the string is available.
func plainTextString(items *js.Object) (func() string, error) {
	for i := 0; i < items.Length(); i++ {
		item := items.Index(i)
		if item.Get("kind").String() != "string" || item.Get("type").String() != "text/plain" {
			continue
		}
		s := make(chan string)
		item.Call("getAsString", func(o *js.Object) {
			go func() { s <- o.String() }()
		})
		return func() string { return <-s }, nil
	}
	return nil, fmt.Errorf("not found")
}

// blobToBytes converts a Blob to []byte.
func blobToBytes(blob *js.Object) []byte {
	b := make(chan []byte)
	fileReader := js.Global.Get("FileReader").New()
	fileReader.Set("onload", func() {
		b <- js.Global.Get("Uint8Array").New(fileReader.Get("result")).Interface().([]byte)
	})
	fileReader.Call("readAsArrayBuffer", blob)
	return <-b
}
main.go
@@ -77,23 +77,23 @@ func New(service changes.Service, users users.Service, opt Options) http.Handler
		handler: h.ServeHTTP,
		users:   users,
	}
}

// RepoSpecContextKey is a context key for the request's issues.RepoSpec.
// That value specifies which repo the issues are to be displayed for.
// RepoSpecContextKey is a context key for the request's repo spec.
// That value specifies which repo the changes are to be displayed for.
// The associated value will be of type string.
var RepoSpecContextKey = &contextKey{"RepoSpec"}

// BaseURIContextKey is a context key for the request's base URI.
// That value specifies the base URI prefix to use for all absolute URLs.
// The associated value will be of type string.
var BaseURIContextKey = &contextKey{"BaseURI"}

// Options for configuring issues app.
// Options for configuring changes app.
type Options struct {
	Notifications    notifications.Service // If not nil, issues containing unread notifications are highlighted.
	Notifications    notifications.Service // If not nil, changes containing unread notifications are highlighted.
	DisableReactions bool                  // Disable all support for displaying and toggling reactions.

	HeadPre, HeadPost template.HTML
	BodyPre           string // An html/template definition of "body-pre" template.

@@ -144,18 +144,18 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) error {
		return nil
	}

	// Handle "/".
	if req.URL.Path == "/" {
		return h.IssuesHandler(w, req)
		return h.ChangesHandler(w, req)
	}

	// Handle "/{changeID}" and "/{changeID}/...".
	elems := strings.SplitN(req.URL.Path[1:], "/", 3)
	changeID, err := strconv.ParseUint(elems[0], 10, 64)
	if err != nil {
		return httperror.HTTP{Code: http.StatusNotFound, Err: fmt.Errorf("invalid issue ID %q: %v", elems[0], err)}
		return httperror.HTTP{Code: http.StatusNotFound, Err: fmt.Errorf("invalid change ID %q: %v", elems[0], err)}
	}
	switch {
	// "/{changeID}".
	case len(elems) == 1:
		return h.ChangeHandler(w, req, changeID)
@@ -176,11 +176,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) error {
	default:
		return httperror.HTTP{Code: http.StatusNotFound, Err: errors.New("no route")}
	}
}

func (h *handler) IssuesHandler(w http.ResponseWriter, req *http.Request) error {
func (h *handler) ChangesHandler(w http.ResponseWriter, req *http.Request) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}
	state, err := h.state(req, 0)
	if err != nil {
@@ -194,22 +194,22 @@ func (h *handler) IssuesHandler(w http.ResponseWriter, req *http.Request) error
	if err != nil {
		return err
	}
	openCount, err := h.is.Count(req.Context(), state.RepoSpec, changes.ListOptions{State: changes.StateFilter(changes.OpenState)})
	if err != nil {
		return fmt.Errorf("issues.Count(open): %v", err)
		return fmt.Errorf("changes.Count(open): %v", err)
	}
	closedCount, err := h.is.Count(req.Context(), state.RepoSpec, changes.ListOptions{State: changes.StateFilter(changes.ClosedState)})
	if err != nil {
		return fmt.Errorf("issues.Count(closed): %v", err)
		return fmt.Errorf("changes.Count(closed): %v", err)
	}
	var es []component.ChangeEntry
	for _, i := range is {
		es = append(es, component.ChangeEntry{Change: i, BaseURI: state.BaseURI})
	}
	es = state.augmentUnread(req.Context(), es, h.is, h.Notifications)
	state.Changes = component.Issues{
	state.Changes = component.Changes{
		IssuesNav: component.IssuesNav{
			OpenCount:     openCount,
			ClosedCount:   closedCount,
			Path:          state.BaseURI + state.ReqPath,
			Query:         req.URL.Query(),
@@ -217,23 +217,23 @@ func (h *handler) IssuesHandler(w http.ResponseWriter, req *http.Request) error
		},
		Filter:  filter,
		Entries: es,
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = h.static.ExecuteTemplate(w, "issues.html.tmpl", &state)
	err = h.static.ExecuteTemplate(w, "changes.html.tmpl", &state)
	if err != nil {
		return fmt.Errorf("h.static.ExecuteTemplate: %v", err)
	}
	return nil
}

const (
	// stateQueryKey is name of query key for controlling issue state filter.
	// stateQueryKey is name of query key for controlling change state filter.
	stateQueryKey = "state"
)

// stateFilter parses the issue state filter from query,
// stateFilter parses the change state filter from query,
// returning an error if the value is unsupported.
func stateFilter(query url.Values) (changes.StateFilter, error) {
	selectedTabName := query.Get(stateQueryKey)
	switch selectedTabName {
	case "":
@@ -296,38 +296,38 @@ func (h *handler) ChangeHandler(w http.ResponseWriter, req *http.Request, change
	}
	state, err := h.state(req, changeID)
	if err != nil {
		return err
	}
	state.Change, err = h.is.Get(req.Context(), state.RepoSpec, state.IssueID)
	state.Change, err = h.is.Get(req.Context(), state.RepoSpec, state.ChangeID)
	if err != nil {
		return err
	}
	cs, err := h.is.ListComments(req.Context(), state.RepoSpec, state.IssueID, nil)
	cs, err := h.is.ListComments(req.Context(), state.RepoSpec, state.ChangeID, nil)
	if err != nil {
		return fmt.Errorf("changes.ListComments: %v", err)
	}
	es, err := h.is.ListEvents(req.Context(), state.RepoSpec, state.IssueID, nil)
	es, err := h.is.ListEvents(req.Context(), state.RepoSpec, state.ChangeID, nil)
	if err != nil {
		return fmt.Errorf("changes.ListEvents: %v", err)
	}
	var items []issueItem
	var items []timelineItem
	for _, comment := range cs {
		items = append(items, issueItem{comment})
		items = append(items, timelineItem{comment})
	}
	for _, event := range es {
		items = append(items, issueItem{event})
		items = append(items, timelineItem{event})
	}
	sort.Sort(byCreatedAtID(items))
	state.Items = items
	// Call loadTemplates to set updated reactionsBar, reactableID, etc., template functions.
	t, err := loadTemplates(state.State, h.Options.BodyPre)
	if err != nil {
		return fmt.Errorf("loadTemplates: %v", err)
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = t.ExecuteTemplate(w, "issue.html.tmpl", &state)
	err = t.ExecuteTemplate(w, "change.html.tmpl", &state)
	if err != nil {
		return fmt.Errorf("t.ExecuteTemplate: %v", err)
	}
	return nil
}
@@ -338,15 +338,15 @@ func (h *handler) ChangeCommitsHandler(w http.ResponseWriter, req *http.Request,
	}
	state, err := h.state(req, changeID)
	if err != nil {
		return err
	}
	state.Change, err = h.is.Get(req.Context(), state.RepoSpec, state.IssueID)
	state.Change, err = h.is.Get(req.Context(), state.RepoSpec, state.ChangeID)
	if err != nil {
		return err
	}
	cs, err := h.is.ListCommits(req.Context(), state.RepoSpec, state.IssueID)
	cs, err := h.is.ListCommits(req.Context(), state.RepoSpec, state.ChangeID)
	if err != nil {
		return err
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = h.static.ExecuteTemplate(w, "change-commits.html.tmpl", &state)
@@ -373,17 +373,17 @@ func (h *handler) ChangeFilesHandler(w http.ResponseWriter, req *http.Request, c
	}
	state, err := h.state(req, changeID)
	if err != nil {
		return err
	}
	state.Change, err = h.is.Get(req.Context(), state.RepoSpec, state.IssueID)
	state.Change, err = h.is.Get(req.Context(), state.RepoSpec, state.ChangeID)
	if err != nil {
		return err
	}
	var commit commitMessage
	if commitID != "" {
		cs, err := h.is.ListCommits(req.Context(), state.RepoSpec, state.IssueID)
		cs, err := h.is.ListCommits(req.Context(), state.RepoSpec, state.ChangeID)
		if err != nil {
			return err
		}
		i := commitIndex(cs, commitID)
		if i == -1 {
@@ -406,11 +406,11 @@ func (h *handler) ChangeFilesHandler(w http.ResponseWriter, req *http.Request, c
	}
	var opt *changes.GetDiffOptions
	if commitID != "" {
		opt = &changes.GetDiffOptions{Commit: commitID}
	}
	rawDiff, err := h.is.GetDiff(req.Context(), state.RepoSpec, state.IssueID, opt)
	rawDiff, err := h.is.GetDiff(req.Context(), state.RepoSpec, state.ChangeID, opt)
	if err != nil {
		return err
	}
	fileDiffs, err := diff.ParseMultiFileDiff(rawDiff)
	if err != nil {
@@ -452,18 +452,18 @@ func (h *handler) state(req *http.Request, changeID uint64) (state, error) {
	// TODO: Caller still does a lot of work outside to calculate req.URL.Path by
	//       subtracting BaseURI from full original req.URL.Path. We should be able
	//       to compute it here internally by using req.RequestURI and BaseURI.
	reqPath := req.URL.Path
	if reqPath == "/" {
		reqPath = "" // This is needed so that absolute URL for root view, i.e., /issues, is "/issues" and not "/issues/" because of "/issues" + "/".
		reqPath = "" // This is needed so that absolute URL for root view, i.e., /changes, is "/changes" and not "/changes/" because of "/changes" + "/".
	}
	b := state{
		State: common.State{
			BaseURI:  req.Context().Value(BaseURIContextKey).(string),
			ReqPath:  reqPath,
			RepoSpec: req.Context().Value(RepoSpecContextKey).(string),
			IssueID:  changeID,
			ChangeID: changeID,
		},
	}
	b.HeadPre = h.HeadPre
	b.HeadPost = h.HeadPost
	if h.BodyTop != nil {
@@ -498,35 +498,35 @@ type state struct {
	HeadPre, HeadPost template.HTML
	BodyTop           template.HTML

	common.State

	Changes component.Issues
	Changes component.Changes
	Change  changes.Change
	Items   []issueItem
	Items   []timelineItem
}

func (s state) Tabnav(selected string) template.HTML {
	// Render the tabnav.
	return template.HTML(htmlg.RenderComponentsString(tabnav{
		Tabs: []tab{
			{
				Content:  iconText{Icon: octiconssvg.CommentDiscussion, Text: "Discussion"},
				URL:      fmt.Sprintf("%s/%d", s.BaseURI, s.IssueID),
				URL:      fmt.Sprintf("%s/%d", s.BaseURI, s.ChangeID),
				Selected: selected == "Discussion",
			},
			{
				Content: contentCounter{
					Content: iconText{Icon: octiconssvg.GitCommit, Text: "Commits"},
					Count:   s.Change.Commits,
				},
				URL:      fmt.Sprintf("%s/%d/commits", s.BaseURI, s.IssueID),
				URL:      fmt.Sprintf("%s/%d/commits", s.BaseURI, s.ChangeID),
				Selected: selected == "Commits",
			},
			{
				Content:  iconText{Icon: octiconssvg.Diff, Text: "Files"},
				URL:      fmt.Sprintf("%s/%d/files", s.BaseURI, s.IssueID),
				URL:      fmt.Sprintf("%s/%d/files", s.BaseURI, s.ChangeID),
				Selected: selected == "Files",
			},
		},
	}))
}
@@ -546,11 +546,11 @@ func loadTemplates(state common.State, bodyPre string) (*template.Template, erro
		"reactionPosition": func(emojiID reactions.EmojiID) string { return reactions.Position(":" + string(emojiID) + ":") },
		"equalUsers": func(a, b users.User) bool {
			return a.UserSpec == b.UserSpec
		},
		"reactableID": func(commentID uint64) string {
			return fmt.Sprintf("%d/%d", state.IssueID, commentID)
			return fmt.Sprintf("%d/%d", state.ChangeID, commentID)
		},
		"reactionsBar": func(reactions []reactions.Reaction, reactableID string) htmlg.Component {
			return reactionscomponent.ReactionsBar{
				Reactions:   reactions,
				CurrentUser: state.CurrentUser,