Lazy Chat: Implement editing of messages.

Change-Id: I291201da1fbc7c2b6563f0837f7ce3e2f7f8555c
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/Role.java b/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/Role.java
index 6298245..349322b 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/Role.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/Role.java
@@ -5,6 +5,7 @@
 import eu.mulk.mulkcms2.benki.users.UserRole;
 import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
 import java.util.Collection;
+import java.util.Objects;
 import java.util.Set;
 import javax.persistence.CollectionTable;
 import javax.persistence.Column;
@@ -78,4 +79,21 @@
   public static Role getWorld() {
     return find("from Role r join r.tags tag where tag = 'world'").singleResult();
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof Role)) {
+      return false;
+    }
+    Role role = (Role) o;
+    return Objects.equals(id, role.id);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(id);
+  }
 }
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
index f2e3067..cb4c20f 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
@@ -3,10 +3,9 @@
 import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
 import static javax.ws.rs.core.MediaType.TEXT_HTML;
 
-import eu.mulk.mulkcms2.benki.accesscontrol.Role;
+import eu.mulk.mulkcms2.benki.posts.Post;
 import eu.mulk.mulkcms2.benki.posts.PostFilter;
 import eu.mulk.mulkcms2.benki.posts.PostResource;
-import eu.mulk.mulkcms2.benki.users.User;
 import io.quarkus.qute.Template;
 import io.quarkus.qute.TemplateInstance;
 import io.quarkus.qute.api.ResourcePath;
@@ -21,9 +20,6 @@
 import javax.json.JsonObject;
 import javax.transaction.Transactional;
 import javax.validation.constraints.NotEmpty;
-import javax.validation.constraints.NotNull;
-import javax.validation.constraints.Pattern;
-import javax.ws.rs.BadRequestException;
 import javax.ws.rs.FormParam;
 import javax.ws.rs.GET;
 import javax.ws.rs.POST;
@@ -51,12 +47,10 @@
       @FormParam("uri") URI uri,
       @FormParam("title") @NotEmpty String title,
       @FormParam("description") String description,
-      @FormParam("visibility") @NotNull @Pattern(regexp = "public|semiprivate|private")
-          String visibility)
+      @FormParam("visibility") Post.Visibility visibility)
       throws URISyntaxException {
 
-    var userName = identity.getPrincipal().getName();
-    var user = User.findByNickname(userName);
+    var user = getCurrentUser();
 
     var bookmark = new Bookmark();
     bookmark.uri = uri.toString();
@@ -66,14 +60,7 @@
     bookmark.owner = user;
     bookmark.date = OffsetDateTime.now();
 
-    if (visibility.equals("public")) {
-      Role world = Role.find("from Role r join r.tags tag where tag = 'world'").singleResult();
-      bookmark.targets = Set.of(world);
-    } else if (visibility.equals("semiprivate")) {
-      bookmark.targets = Set.copyOf(user.defaultTargets);
-    } else if (!visibility.equals("private")) {
-      throw new BadRequestException(String.format("invalid visibility “%s”", visibility));
-    }
+    assignPostTargets(visibility, user, bookmark);
 
     bookmark.persistAndFlush();
 
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java
index 918cad7..1e92c38 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java
@@ -4,6 +4,7 @@
 import eu.mulk.mulkcms2.common.markdown.MarkdownConverter;
 import java.util.Collection;
 import javax.annotation.CheckForNull;
+import javax.json.bind.annotation.JsonbTransient;
 import javax.persistence.Column;
 import javax.persistence.Entity;
 import javax.persistence.FetchType;
@@ -22,9 +23,11 @@
   public String format;
 
   @OneToMany(mappedBy = "referrer", fetch = FetchType.LAZY)
+  @JsonbTransient
   public Collection<LazychatReference> references;
 
   @Transient
+  @JsonbTransient
   public String getContentHtml() {
     return new MarkdownConverter().htmlify(content);
   }
@@ -43,6 +46,7 @@
 
   @CheckForNull
   @Override
+  @JsonbTransient
   public String getDescriptionHtml() {
     return getContentHtml();
   }
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java
index 8a4d2a3..fd672f8 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java
@@ -1,21 +1,24 @@
 package eu.mulk.mulkcms2.benki.lazychat;
 
-import eu.mulk.mulkcms2.benki.accesscontrol.Role;
+import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
+
+import eu.mulk.mulkcms2.benki.posts.Post;
 import eu.mulk.mulkcms2.benki.posts.PostFilter;
 import eu.mulk.mulkcms2.benki.posts.PostResource;
-import eu.mulk.mulkcms2.benki.users.User;
 import io.quarkus.security.Authenticated;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.time.OffsetDateTime;
-import java.util.Set;
+import java.util.Objects;
 import javax.transaction.Transactional;
-import javax.validation.constraints.NotNull;
-import javax.validation.constraints.Pattern;
-import javax.ws.rs.BadRequestException;
+import javax.ws.rs.ForbiddenException;
 import javax.ws.rs.FormParam;
+import javax.ws.rs.GET;
+import javax.ws.rs.NotFoundException;
 import javax.ws.rs.POST;
 import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
 import javax.ws.rs.core.Response;
 
 @Path("/lazychat")
@@ -29,13 +32,10 @@
   @Transactional
   @Authenticated
   public Response postMessage(
-      @FormParam("text") String text,
-      @FormParam("visibility") @NotNull @Pattern(regexp = "public|semiprivate|private")
-          String visibility)
+      @FormParam("text") String text, @FormParam("visibility") Post.Visibility visibility)
       throws URISyntaxException {
 
-    var userName = identity.getPrincipal().getName();
-    var user = User.findByNickname(userName);
+    var user = getCurrentUser();
 
     var message = new LazychatMessage();
     message.content = text;
@@ -43,17 +43,57 @@
     message.owner = user;
     message.date = OffsetDateTime.now();
 
-    if (visibility.equals("public")) {
-      Role world = Role.find("from Role r join r.tags tag where tag = 'world'").singleResult();
-      message.targets = Set.of(world);
-    } else if (visibility.equals("semiprivate")) {
-      message.targets = Set.copyOf(user.defaultTargets);
-    } else if (!visibility.equals("private")) {
-      throw new BadRequestException(String.format("invalid visibility “%s”", visibility));
-    }
+    assignPostTargets(visibility, user, message);
 
     message.persistAndFlush();
 
     return Response.seeOther(new URI("/lazychat")).build();
   }
+
+  @POST
+  @Transactional
+  @Authenticated
+  @Path("/p/{id}/edit")
+  public Response patchMessage(
+      @PathParam("id") int id,
+      @FormParam("text") String text,
+      @FormParam("visibility") Post.Visibility visibility)
+      throws URISyntaxException {
+
+    var user = getCurrentUser();
+
+    var message = getSession().byId(LazychatMessage.class).load(id);
+
+    if (message == null) {
+      throw new NotFoundException();
+    }
+
+    if (!Objects.equals(message.owner.id, user.id)) {
+      throw new ForbiddenException();
+    }
+
+    message.content = text;
+    message.format = "markdown";
+
+    assignPostTargets(visibility, user, message);
+
+    return Response.seeOther(new URI("/lazychat")).build();
+  }
+
+  @GET
+  @Transactional
+  @Produces(APPLICATION_JSON)
+  @Path("/p/{id}")
+  public LazychatMessage getMessage(@PathParam("id") int id) {
+
+    var user = getCurrentUser();
+
+    var message = getSession().byId(LazychatMessage.class).load(id);
+
+    if (!user.canSee(message)) {
+      throw new ForbiddenException();
+    }
+
+    return message;
+  }
 }
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
index 20aec05..356461c 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
@@ -13,6 +13,7 @@
 import java.util.Objects;
 import java.util.Set;
 import javax.annotation.CheckForNull;
+import javax.json.bind.annotation.JsonbTransient;
 import javax.persistence.Column;
 import javax.persistence.Entity;
 import javax.persistence.FetchType;
@@ -57,6 +58,7 @@
 
   @ManyToOne(fetch = FetchType.LAZY)
   @JoinColumn(name = "owner", referencedColumnName = "id")
+  @JsonbTransient
   public User owner;
 
   @ManyToMany(fetch = FetchType.LAZY)
@@ -65,6 +67,7 @@
       schema = "benki",
       joinColumns = @JoinColumn(name = "message"),
       inverseJoinColumns = @JoinColumn(name = "user"))
+  @JsonbTransient
   public Set<User> visibleTo;
 
   @ManyToMany(fetch = FetchType.LAZY)
@@ -73,6 +76,7 @@
       schema = "benki",
       joinColumns = @JoinColumn(name = "message"),
       inverseJoinColumns = @JoinColumn(name = "target"))
+  @JsonbTransient
   public Set<Role> targets;
 
   public abstract boolean isBookmark();
@@ -88,6 +92,18 @@
   @CheckForNull
   public abstract String getUri();
 
+  public Visibility getVisibility() {
+    if (targets.isEmpty()) {
+      return Visibility.PRIVATE;
+    } else if (targets.contains(Role.getWorld())) {
+      return Visibility.PUBLIC;
+    } else {
+      // FIXME: There should really be a check whether targets.equals(owner.defaultTargets) here.
+      // Otherwise the actual visibility is DISCRETIONARY.
+      return Visibility.SEMIPRIVATE;
+    }
+  }
+
   protected static <T extends Post> CriteriaQuery<T> queryViewable(
       Class<T> entityClass,
       SecurityIdentity readerIdentity,
@@ -236,4 +252,28 @@
 
     return new PostPage<T>(prevCursor, cursor, nextCursor, forwardResults);
   }
+
+  public enum Visibility {
+    PUBLIC,
+    SEMIPRIVATE,
+    DISCRETIONARY,
+    PRIVATE,
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof Post)) {
+      return false;
+    }
+    Post post = (Post) o;
+    return Objects.equals(id, post.id);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(id);
+  }
 }
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
index fbe6bf7..a691490 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
@@ -10,6 +10,7 @@
 import com.rometools.rome.feed.synd.SyndPersonImpl;
 import com.rometools.rome.io.FeedException;
 import com.rometools.rome.io.WireFeedOutput;
+import eu.mulk.mulkcms2.benki.accesscontrol.Role;
 import eu.mulk.mulkcms2.benki.users.User;
 import io.quarkus.qute.Template;
 import io.quarkus.qute.TemplateExtension;
@@ -26,6 +27,7 @@
 import java.util.Comparator;
 import java.util.Date;
 import java.util.List;
+import java.util.Set;
 import java.util.stream.Collectors;
 import javax.annotation.CheckForNull;
 import javax.annotation.Nullable;
@@ -33,6 +35,7 @@
 import javax.json.spi.JsonProvider;
 import javax.persistence.EntityManager;
 import javax.persistence.PersistenceContext;
+import javax.ws.rs.BadRequestException;
 import javax.ws.rs.GET;
 import javax.ws.rs.Path;
 import javax.ws.rs.PathParam;
@@ -70,7 +73,7 @@
   @ConfigProperty(name = "mulkcms.tag-base")
   String tagBase;
 
-  @PersistenceContext EntityManager entityManager;
+  @PersistenceContext protected EntityManager entityManager;
 
   private final PostFilter postFilter;
   private final String pageTitle;
@@ -256,4 +259,29 @@
         throw new IllegalStateException();
     }
   }
+
+  protected Session getSession() {
+    return entityManager.unwrap(Session.class);
+  }
+
+  protected static void assignPostTargets(Post.Visibility visibility, User user, Post post) {
+    switch (visibility) {
+      case PUBLIC:
+        post.targets = Set.of(Role.getWorld());
+        break;
+      case SEMIPRIVATE:
+        post.targets = Set.copyOf(user.defaultTargets);
+        break;
+      case PRIVATE:
+        post.targets = Set.of();
+        break;
+      default:
+        throw new BadRequestException(String.format("invalid visibility “%s”", visibility));
+    }
+  }
+
+  protected User getCurrentUser() {
+    var userName = identity.getPrincipal().getName();
+    return User.findByNickname(userName);
+  }
 }
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/users/User.java b/src/main/java/eu/mulk/mulkcms2/benki/users/User.java
index 5879046..ab89baa 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/users/User.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/users/User.java
@@ -5,6 +5,7 @@
 import eu.mulk.mulkcms2.benki.bookmarks.Bookmark;
 import eu.mulk.mulkcms2.benki.lazychat.LazychatMessage;
 import eu.mulk.mulkcms2.benki.posts.Post;
+import eu.mulk.mulkcms2.benki.posts.Post.Visibility;
 import eu.mulk.mulkcms2.benki.wiki.WikiPageRevision;
 import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
 import java.util.Collection;
@@ -141,4 +142,9 @@
   public static User findByNickname(String nickname) {
     return User.find("from BenkiUser u join u.nicknames n where ?1 = n", nickname).singleResult();
   }
+
+  public boolean canSee(Post message) {
+    // FIXME: Make this more efficient.
+    return message.getVisibility() == Visibility.PUBLIC || visiblePosts.contains(message);
+  }
 }
diff --git a/src/main/resources/META-INF/resources/bookmarks/MlkBookmarkSubmissionForm.js b/src/main/resources/META-INF/resources/bookmarks/MlkBookmarkSubmissionForm.js
index 0a8fad7..3dd3754 100644
--- a/src/main/resources/META-INF/resources/bookmarks/MlkBookmarkSubmissionForm.js
+++ b/src/main/resources/META-INF/resources/bookmarks/MlkBookmarkSubmissionForm.js
@@ -10,7 +10,7 @@
 
   <form class="pure-form" method="post" action="/bookmarks">
     <fieldset>
-      <legend>New Bookmark</legend>
+      <legend>Edit Bookmark</legend>
 
       <label for="uri-input">URI:</label>
       <input name="uri" id="uri-input" type="text" placeholder="URI" required />
@@ -24,9 +24,9 @@
 
       <label for="visibility-input">Visibility:</label>
       <select id="visibility-input" name="visibility" required>
-        <option value="public" selected>Public</option>
-        <option value="semiprivate">Semiprivate</option>
-        <option value="private">Private</option>
+        <option value="PUBLIC" selected>Public</option>
+        <option value="SEMIPRIVATE">Semiprivate</option>
+        <option value="PRIVATE">Private</option>
       </select>
 
       <div class="controls">
diff --git a/src/main/resources/META-INF/resources/cms2/base.css b/src/main/resources/META-INF/resources/cms2/base.css
index 61f447c..c455ce8 100644
--- a/src/main/resources/META-INF/resources/cms2/base.css
+++ b/src/main/resources/META-INF/resources/cms2/base.css
@@ -187,6 +187,15 @@
   min-width: calc(100% - 12em);
 }
 
+elix-expandable-section.editor-pane::part(toggle) {
+  margin: 0;
+  display: inline;
+}
+
+elix-expandable-section.editor-pane::part(header) {
+  display: inline-block;
+}
+
 .paging {
   display: flex;
   flex-direction: row;
diff --git a/src/main/resources/META-INF/resources/lazychat/MlkLazychatSubmissionForm.js b/src/main/resources/META-INF/resources/lazychat/MlkLazychatSubmissionForm.js
index a2bef8c..6cb3059 100644
--- a/src/main/resources/META-INF/resources/lazychat/MlkLazychatSubmissionForm.js
+++ b/src/main/resources/META-INF/resources/lazychat/MlkLazychatSubmissionForm.js
@@ -8,18 +8,18 @@
   <link rel="stylesheet" type="text/css" href="/cms2/base.css" />
   <link rel="stylesheet" type="text/css" href="/lazychat/MlkLazychatSubmissionForm.css" />
 
-  <form class="pure-form" method="post" action="/lazychat">
+  <form id="main-form" class="pure-form" method="post" action="/lazychat">
     <fieldset>
-      <legend>New Message</legend>
+      <legend>Edit Message</legend>
 
       <label for="text-input">Text:</label>
       <textarea name="text" id="text-input" placeholder="Text"></textarea>
 
       <label for="visibility-input">Visibility:</label>
       <select id="visibility-input" name="visibility" required>
-        <option value="public">Public</option>
-        <option value="semiprivate" selected>Semiprivate</option>
-        <option value="private">Private</option>
+        <option value="PUBLIC">Public</option>
+        <option value="SEMIPRIVATE" selected>Semiprivate</option>
+        <option value="PRIVATE">Private</option>
       </select>
 
       <div class="controls">
@@ -31,6 +31,8 @@
 export class MlkLazychatSubmissionForm extends HTMLElement {
   /*::
   textInput: HTMLTextAreaElement;
+  visibilityInput: HTMLInputElement;
+  loaded: boolean;
   */
 
   constructor() {
@@ -41,20 +43,57 @@
 
     this.textInput =
         cast(shadow.getElementById('text-input'));
+    this.visibilityInput =
+        cast(shadow.getElementById('visibility-input'));
+    this.loaded = false;
   }
 
   static get observedAttributes() {
     return [];
   }
 
-  connectedCallback () {}
+  get editedId() {
+    return this.getAttribute("edited-id");
+  }
 
-  disconnectedCallback () {}
+  get isEditor() {
+    return this.editedId !== null;
+  }
+
+  connectedCallback() {
+    if (this.isEditor) {
+      let form = this.shadowRoot.getElementById("main-form");
+      form.method = "post";
+      form.action = `/lazychat/p/${this.editedId}/edit`;
+    }
+  }
+
+  disconnectedCallback() {}
 
   attributeChangedCallback(name /*:string*/, oldValue /*:string*/, newValue /*:string*/) {}
 
   focus() {
     this.textInput.focus();
+    this.load();
+  }
+
+  async load() {
+    if (!this.editedId || this.loaded) {
+      return;
+    }
+
+    let fetchUrl = new URL(`/lazychat/p/${this.editedId}`, document.URL);
+    let r = await fetch(fetchUrl);
+
+    if (!r.ok) {
+      return;
+    }
+
+    let post = await r.json();
+    this.textInput.value = post.content;
+    this.visibilityInput.value = post.visibility;
+
+    this.loaded = true;
   }
 }
 
diff --git a/src/main/resources/META-INF/resources/posts/postList.js b/src/main/resources/META-INF/resources/posts/postList.js
index 0578d7b..3eb23ce 100644
--- a/src/main/resources/META-INF/resources/posts/postList.js
+++ b/src/main/resources/META-INF/resources/posts/postList.js
@@ -10,4 +10,10 @@
     let lazychatSubmissionForm = document.getElementById('lazychat-submission-form');
     lazychatSubmissionPane.addEventListener('opened',() => lazychatSubmissionForm.focus());
   }
+
+  let lazychatEditorPanes = document.getElementsByClassName('lazychat-editor-pane');
+  for (let pane of lazychatEditorPanes) {
+    let form = pane.getElementsByTagName('mlk-lazychat-submission-form')[0];
+    pane.addEventListener('opened', () => form.focus());
+  }
 });
diff --git a/src/main/resources/templates/benki/posts/postList.html b/src/main/resources/templates/benki/posts/postList.html
index a29d886..8dd5210 100644
--- a/src/main/resources/templates/benki/posts/postList.html
+++ b/src/main/resources/templates/benki/posts/postList.html
@@ -1,4 +1,4 @@
-{@java.util.List<eu.mulk.mulkcms2.benki.bookmarks.Bookmark> posts}
+{@java.util.List<eu.mulk.mulkcms2.benki.posts.Post> posts}
 {@java.lang.String pageTitle}
 {@java.lang.Boolean showBookmarkForm}
 {@java.lang.Boolean hasPreviousPage}
@@ -69,15 +69,24 @@
       {#else}
         <article class="lazychat-message">
           <header>
-            <div class="lazychat-message-info">
+            <div class="lazychat-message-info" style="display: inline-block">
               <time datetime="{date.htmlDateTime}">{date.humanDateTime}</time>
               <span class="lazychat-message-owner">{owner.firstName} {owner.lastName}</span>
             </div>
+  
+            {#if showLazychatForm}
+              <elix-expandable-section class="lazychat-editor-pane editor-pane">
+                <mlk-lazychat-submission-form edited-id="{post.id}"></mlk-lazychat-submission-form>
+              </elix-expandable-section>
+            {/if}
           </header>
 
           <section class="lazychat-message-content">
             {contentHtml.raw}
           </section>
+
+          <section class="lazychat-editor">
+          </section>
         </article>
       {/if}
     {/with}