From 5353a4535a036af7b4b2f4e756240486366574b6 Mon Sep 17 00:00:00 2001
From: Lim Chee Aun <cheeaun@gmail.com>
Date: Mon, 12 Dec 2022 21:54:31 +0800
Subject: [PATCH] New feature: Edit status!

Get's a bit hacky now
---
 src/app.css                | 125 +++++++++++++++++++++++++++++--------
 src/app.jsx                |   1 +
 src/components/compose.jsx |  64 +++++++++++++++----
 src/components/icon.jsx    |   1 +
 src/components/status.css  |  10 +--
 src/components/status.jsx  |  44 +++++++++++--
 src/index.css              |  54 +++++++++++-----
 7 files changed, 232 insertions(+), 67 deletions(-)

diff --git a/src/app.css b/src/app.css
index dcdab09d..d8557bd6 100644
--- a/src/app.css
+++ b/src/app.css
@@ -1,4 +1,5 @@
-html, body {
+html,
+body {
   margin: 0;
   padding: 0;
   background-color: var(--bg-color);
@@ -23,10 +24,10 @@ a.mention span {
   text-decoration-line: underline;
   text-decoration-color: inherit;
 }
-a.hashtag {
+a.mention:has(span).hashtag {
   color: var(--link-light-color);
 }
-:is(a.hashtag, a.u-url) span{
+a.mention span {
   color: var(--text-color);
 }
 
@@ -36,7 +37,7 @@ a.hashtag {
   height: 100dvh;
   overflow: auto;
   overflow-x: hidden;
-  transition: opacity .1s ease-in-out;
+  transition: opacity 0.1s ease-in-out;
 }
 .deck-container[hidden] {
   display: block;
@@ -103,7 +104,7 @@ a.hashtag {
 .deck h2 {
   font-size: 1.45em;
 }
-.deck.padded-bottom .timeline li:last-child {
+.deck.padded-bottom .timeline > li:last-child {
   padding-bottom: 80vh;
 }
 
@@ -111,13 +112,13 @@ a.hashtag {
   margin: 0 auto;
   padding: 0;
 }
-.timeline li {
+.timeline > li {
   list-style: none;
   margin: 0;
   padding: 0;
   border-bottom: 1px solid var(--divider-color);
 }
-.timeline.flat li {
+.timeline.flat > li {
   border-bottom: none;
 }
 /* .timeline li.insignificant {
@@ -131,23 +132,31 @@ a.hashtag {
   opacity: 1;
 } */
 
-.timeline.contextual li {
+.timeline.contextual > li {
   --width: 3px;
   --left: 40px;
   --right: calc(var(--left) + var(--width));
-  background-image: linear-gradient(to right, transparent, transparent var(--left), var(--comment-line-color) var(--left), var(--comment-line-color) var(--right), transparent var(--right), transparent);
+  background-image: linear-gradient(
+    to right,
+    transparent,
+    transparent var(--left),
+    var(--comment-line-color) var(--left),
+    var(--comment-line-color) var(--right),
+    transparent var(--right),
+    transparent
+  );
   background-repeat: no-repeat;
 }
-.timeline.contextual li:first-child {
+.timeline.contextual > li:first-child {
   background-position: 0 16px;
 }
-.timeline.contextual li:last-child {
+.timeline.contextual > li:last-child {
   background-size: 100% 20px;
 }
-.timeline.contextual li.descendant {
+.timeline.contextual > li.descendant {
   position: relative;
 }
-.timeline.contextual li.descendant.indirect:before {
+.timeline.contextual > li.descendant.indirect:before {
   --radius: 10px;
   --diameter: calc(var(--radius) * 2);
   content: '';
@@ -162,7 +171,7 @@ a.hashtag {
   border-color: transparent transparent var(--comment-line-color) transparent;
   transform: rotate(45deg);
 }
-.timeline.contextual li.descendant.indirect .status-link {
+.timeline.contextual > li.descendant.indirect .status-link {
   position: relative;
 }
 
@@ -172,7 +181,11 @@ a.hashtag {
 .timeline-deck.compact .status {
   max-height: max(25vh, 160px);
   overflow: hidden;
-  mask-image: linear-gradient(rgba(0, 0, 0, 1), rgba(0, 0, 0, 1) 80%, transparent 95%);
+  mask-image: linear-gradient(
+    rgba(0, 0, 0, 1),
+    rgba(0, 0, 0, 1) 80%,
+    transparent 95%
+  );
 }
 .timeline-deck.compact .status .meta ~ * {
   pointer-events: none;
@@ -221,7 +234,7 @@ a.hashtag {
 }
 .deck-backdrop > a {
   flex-grow: 1;
-  backdrop-filter: saturate(.75);
+  backdrop-filter: saturate(0.75);
 }
 @keyframes slide-in {
   0% {
@@ -258,7 +271,7 @@ a.hashtag {
 }
 
 :is(button, .button).plain.has-badge:after {
-  content: "";
+  content: '';
   display: inline-block;
   position: absolute;
   right: 10px;
@@ -266,7 +279,7 @@ a.hashtag {
   height: 4px;
   border-radius: 50%;
   background-color: var(--link-color);
-  opacity: .5;
+  opacity: 0.5;
 }
 
 @keyframes fade-from-top {
@@ -311,7 +324,11 @@ a.hashtag {
 }
 
 .box-shadow {
-  box-shadow: 0px 36px 89px rgb(0 0 0 / 4%), 0px 23.3333px 52.1227px rgb(0 0 0 / 3%), 0px 13.8667px 28.3481px rgb(0 0 0 / 2%), 0px 7.2px 14.4625px rgb(0 0 0 / 2%), 0px 2.93333px 7.25185px rgb(0 0 0 / 2%), 0px 0.666667px 3.50231px rgb(0 0 0 / 1%);
+  box-shadow: 0px 36px 89px rgb(0 0 0 / 4%),
+    0px 23.3333px 52.1227px rgb(0 0 0 / 3%),
+    0px 13.8667px 28.3481px rgb(0 0 0 / 2%), 0px 7.2px 14.4625px rgb(0 0 0 / 2%),
+    0px 2.93333px 7.25185px rgb(0 0 0 / 2%),
+    0px 0.666667px 3.50231px rgb(0 0 0 / 1%);
 }
 
 /* CAROUSEL */
@@ -375,11 +392,11 @@ button.carousel-button[hidden] {
 }
 .carousel-dots {
   border-radius: 999px;
-  backdrop-filter: blur(12px) invert(.25) brightness(1.5);
+  backdrop-filter: blur(12px) invert(0.25) brightness(1.5);
 }
 @media (prefers-color-scheme: dark) {
   .carousel-dots {
-    backdrop-filter: blur(12px) brightness(.5);
+    backdrop-filter: blur(12px) brightness(0.5);
   }
 }
 button.carousel-dot {
@@ -395,7 +412,7 @@ button.carousel-dot[disabled].active {
 button.carousel-dot.active,
 button.carousel-dot[disabled].active {
   opacity: 1;
-  transform: scale(2) translateY(-.5px);
+  transform: scale(2) translateY(-0.5px);
 }
 @media (hover: hover) {
   .carousel-top-controls {
@@ -432,7 +449,7 @@ button.carousel-dot[disabled].active {
   box-shadow: 0 0 32px var(--bg-color);
   z-index: 1;
   border: 1px solid var(--bg-color);
-  opacity: .75;
+  opacity: 0.75;
 }
 
 /* SHEET */
@@ -475,8 +492,59 @@ button.carousel-dot[disabled].active {
   align-self: center;
 }
 
+/* MENU POPUP */
+
+.menu-container {
+  position: relative;
+}
+.menu-container button {
+  color: inherit !important;
+}
+.menu-container button:is(:hover, :active, :focus) {
+  background-color: var(--button-plain-bg-hover-color);
+}
+.menu-container menu {
+  position: absolute;
+  right: 0;
+  top: 0;
+  transform: translateY(-100%);
+  opacity: 0;
+  pointer-events: none;
+  padding: 8px 0;
+  margin: 0;
+  font-size: 16px;
+  background-color: var(--bg-color);
+  width: 10em;
+  list-style: none;
+  z-index: 100;
+  border: 1px solid var(--outline-color);
+  border-radius: 8px;
+  transition: all 0.2s ease-in-out;
+}
+.menu-container menu li {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+.menu-container > button:is(:active, :focus) + menu,
+.menu-container menu:is(:hover, :active) {
+  opacity: 1;
+  pointer-events: auto;
+}
+.menu-container menu button {
+  width: 100%;
+  text-align: left;
+  color: var(--text-color) !important;
+  border-radius: 0;
+}
+.menu-container menu button:hover {
+  color: var(--bg-color) !important;
+  background-color: var(--link-color);
+}
+
 @media (min-width: 40em) {
-  html, body {
+  html,
+  body {
     background-color: var(--bg-faded-color);
   }
   #app {
@@ -498,7 +566,12 @@ button.carousel-dot[disabled].active {
     border-bottom: 0;
     background-color: var(--bg-faded-blur-color);
     border-bottom: 0;
-    mask-image: linear-gradient(rgba(0, 0, 0, 1) 50%, rgba(0, 0, 0, .7) 80%, rgba(0, 0, 0, .5) 90%, transparent);
+    mask-image: linear-gradient(
+      rgba(0, 0, 0, 1) 50%,
+      rgba(0, 0, 0, 0.7) 80%,
+      rgba(0, 0, 0, 0.5) 90%,
+      transparent
+    );
   }
   .deck header h1 {
     font-size: 1.5em;
@@ -517,4 +590,4 @@ button.carousel-dot[disabled].active {
   :is(.carousel-top-controls, .carousel-controls) {
     padding: 32px;
   }
-}
\ No newline at end of file
+}
diff --git a/src/app.jsx b/src/app.jsx
index c3688cad..a8a17a04 100644
--- a/src/app.jsx
+++ b/src/app.jsx
@@ -266,6 +266,7 @@ export function App() {
                 ? snapStates.showCompose.replyToStatus
                 : null
             }
+            editStatus={snapStates.showCompose?.editStatus || null}
             onClose={(result) => {
               states.showCompose = false;
               if (result) {
diff --git a/src/components/compose.jsx b/src/components/compose.jsx
index cfd2bd43..b9e8678b 100644
--- a/src/components/compose.jsx
+++ b/src/components/compose.jsx
@@ -17,7 +17,7 @@ import Status from './status';
   - Max character limit includes BOTH status text and Content Warning text
 */
 
-export default ({ onClose, replyToStatus }) => {
+export default ({ onClose, replyToStatus, editStatus }) => {
   const [uiState, setUIState] = useState('default');
 
   const accounts = store.local.getJSON('accounts');
@@ -70,6 +70,32 @@ export default ({ onClose, replyToStatus }) => {
     return () => clearTimeout(timer);
   }, []);
 
+  useEffect(() => {
+    console.log({ editStatus });
+    if (editStatus) {
+      const { visibility, sensitive, mediaAttachments } = editStatus;
+      setUIState('loading');
+      (async () => {
+        try {
+          const statusSource = await masto.statuses.fetchSource(editStatus.id);
+          console.log({ statusSource });
+          const { text, spoilerText } = statusSource;
+          textareaRef.current.value = text;
+          textareaRef.current.dataset.source = text;
+          spoilerTextRef.current.value = spoilerText;
+          setVisibility(visibility);
+          setSensitive(sensitive);
+          setMediaAttachments(mediaAttachments);
+          setUIState('default');
+        } catch (e) {
+          console.error(e);
+          alert(e?.reason || e);
+          setUIState('error');
+        }
+      })();
+    }
+  }, [editStatus]);
+
   const textExpanderRef = useRef();
   const textExpanderTextRef = useRef('');
   useEffect(() => {
@@ -168,7 +194,12 @@ export default ({ onClose, replyToStatus }) => {
     'You have unsaved changes. Are you sure you want to discard this post?';
   const canClose = () => {
     // check for status or mediaAttachments
-    if (textareaRef.current.value || mediaAttachments.length > 0) {
+    const { value, dataset } = textareaRef.current;
+    const containNonIDMediaAttachments =
+      mediaAttachments.length > 0 &&
+      mediaAttachments.some((media) => !media.id);
+
+    if (value !== dataset?.source || containNonIDMediaAttachments) {
       const yes = confirm(beforeUnloadCopy);
       return yes;
     }
@@ -275,7 +306,6 @@ export default ({ onClose, replyToStatus }) => {
                       description,
                     };
                     return masto.mediaAttachments.create(params).then((res) => {
-                      // Update media attachment with ID
                       if (res.id) {
                         attachment.id = res.id;
                       }
@@ -306,14 +336,22 @@ export default ({ onClose, replyToStatus }) => {
 
               const params = {
                 status,
-                visibility,
-                sensitive,
                 spoilerText,
-                inReplyToId: replyToStatus?.id || undefined,
+                sensitive,
                 mediaIds: mediaAttachments.map((attachment) => attachment.id),
               };
+              if (!editStatus) {
+                params.visibility = visibility;
+                params.inReplyToId = replyToStatus?.id || undefined;
+              }
               console.log('POST', params);
-              const newStatus = await masto.statuses.create(params);
+
+              let newStatus;
+              if (editStatus) {
+                newStatus = await masto.statuses.update(editStatus.id, params);
+              } else {
+                newStatus = await masto.statuses.create(params);
+              }
               setUIState('default');
 
               // Close
@@ -348,7 +386,7 @@ export default ({ onClose, replyToStatus }) => {
             <input
               name="sensitive"
               type="checkbox"
-              disabled={uiState === 'loading'}
+              disabled={uiState === 'loading' || !!editStatus}
               onChange={(e) => {
                 const sensitive = e.target.checked;
                 setSensitive(sensitive);
@@ -374,7 +412,7 @@ export default ({ onClose, replyToStatus }) => {
               onChange={(e) => {
                 setVisibility(e.target.value);
               }}
-              disabled={uiState === 'loading'}
+              disabled={uiState === 'loading' || !!editStatus}
             >
               <option value="public">
                 Public <Icon icon="earth" />
@@ -485,7 +523,7 @@ export default ({ onClose, replyToStatus }) => {
             </label>
           </div>
           <div>
-            {uiState === 'loading' && <Loader />}{' '}
+            {uiState === 'loading' && <Loader abrupt />}{' '}
             <button
               type="submit"
               class="large"
@@ -506,7 +544,7 @@ function MediaAttachment({
   onDescriptionChange = () => {},
   onRemove = () => {},
 }) {
-  const { url, type, id } = attachment;
+  const { url, type, id, description } = attachment;
   const suffixType = type.split('/')[0];
   return (
     <div class="media-attachment">
@@ -522,11 +560,11 @@ function MediaAttachment({
       {!!id ? (
         <div class="media-desc">
           <span class="tag">Uploaded</span>
-          <p>{attachment.description || <i>No description</i>}</p>
+          <p title={description}>{description || <i>No description</i>}</p>
         </div>
       ) : (
         <textarea
-          value={attachment.description || ''}
+          value={description || ''}
           placeholder={
             {
               image: 'Image description',
diff --git a/src/components/icon.jsx b/src/components/icon.jsx
index e4858a49..2955ae4b 100644
--- a/src/components/icon.jsx
+++ b/src/components/icon.jsx
@@ -34,6 +34,7 @@ const ICONS = {
   attachment: 'mingcute:attachment-line',
   upload: 'mingcute:upload-3-line',
   gear: 'mingcute:settings-3-line',
+  more: 'mingcute:more-1-line',
 };
 
 export default ({ icon, size = 'm', alt, title, class: className = '' }) => {
diff --git a/src/components/status.css b/src/components/status.css
index 6f1934e0..f4855ca6 100644
--- a/src/components/status.css
+++ b/src/components/status.css
@@ -433,13 +433,7 @@ a.card:hover {
   padding-top: 8px;
   padding-bottom: 16px;
   margin-left: calc(-50px - 16px);
-}
-.status .actions > * {
-  opacity: 0.5;
-  transition: opacity 0.2s ease-in-out;
-}
-.status:hover .actions > * {
-  opacity: 1;
+  color: var(--text-insignificant-color);
 }
 .status .actions > button {
   min-height: 40px;
@@ -447,7 +441,7 @@ a.card:hover {
   padding: 0 8px;
 }
 .status .actions > button.plain {
-  color: var(--text-insignificant-color);
+  color: inherit;
 }
 .status .actions > button.plain:hover {
   color: var(--link-color);
diff --git a/src/components/status.jsx b/src/components/status.jsx
index 07984ee4..16a738c7 100644
--- a/src/components/status.jsx
+++ b/src/components/status.jsx
@@ -2,7 +2,7 @@ import './status.css';
 
 import { getBlurHashAverageColor } from 'fast-blurhash';
 import mem from 'mem';
-import { useEffect, useRef, useState } from 'preact/hooks';
+import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
 import { InView } from 'react-intersection-observer';
 import { useSnapshot } from 'valtio';
 
@@ -12,6 +12,7 @@ import NameText from '../components/name-text';
 import enhanceContent from '../utils/enhance-content';
 import shortenNumber from '../utils/shorten-number';
 import states from '../utils/states';
+import store from '../utils/store';
 import visibilityIconsMap from '../utils/visibility-icons-map';
 
 import Avatar from './avatar';
@@ -81,7 +82,8 @@ function Media({ media, showOriginal, onClick }) {
           height={height}
           style={
             !showOriginal && {
-              backgroundColor: `rgb(${rgbAverageColor.join(',')})`,
+              backgroundColor:
+                rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
               backgroundPosition: focalBackgroundPosition || 'center',
             }
           }
@@ -95,7 +97,8 @@ function Media({ media, showOriginal, onClick }) {
       <div
         class={`media media-${isGIF ? 'gif' : 'video'}`}
         style={{
-          backgroundColor: `rgb(${rgbAverageColor.join(',')})`,
+          backgroundColor:
+            rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
         }}
         onClick={(e) => {
           if (isGIF) {
@@ -527,6 +530,11 @@ function Status({
   const createdAtDate = new Date(createdAt);
   const editedAtDate = new Date(editedAt);
 
+  const isSelf = useMemo(() => {
+    const currentAccount = store.session.get('currentAccount');
+    return currentAccount && currentAccount === accountId;
+  }, [accountId]);
+
   let inReplyToAccountRef = mentions?.find(
     (mention) => mention.id === inReplyToAccountId,
   );
@@ -933,6 +941,32 @@ function Status({
                   alt={bookmarked ? 'Bookmarked' : 'Bookmark'}
                 />
               </button>
+              {isSelf && (
+                <span class="menu-container">
+                  <button type="button" title="More" class="plain more-button">
+                    <Icon icon="more" size="l" alt="More" />
+                  </button>
+                  <menu>
+                    {isSelf && (
+                      <li>
+                        <button
+                          type="button"
+                          class="plain"
+                          onClick={(e) => {
+                            e.preventDefault();
+                            e.stopPropagation();
+                            states.showCompose = {
+                              editStatus: status,
+                            };
+                          }}
+                        >
+                          Edit&hellip;
+                        </button>
+                      </li>
+                    )}
+                  </menu>
+                </span>
+              )}
             </div>
           </>
         )}
@@ -961,7 +995,9 @@ function Status({
                 <InView
                   class="carousel-item"
                   style={{
-                    backgroundColor: `rgba(${rgbAverageColor.join(',')}, .5)`,
+                    backgroundColor:
+                      rgbAverageColor &&
+                      `rgba(${rgbAverageColor.join(',')}, .5)`,
                   }}
                   tabindex="0"
                   key={media.id}
diff --git a/src/index.css b/src/index.css
index 6eda4ae7..d1f676ef 100644
--- a/src/index.css
+++ b/src/index.css
@@ -23,8 +23,8 @@
   --reply-to-color: var(--orange-color);
   --favourite-color: var(--red-color);
   --reply-to-faded-color: #ffa6001a;
-  --outline-color: rgba(128, 128, 128, .2);
-  --outline-hover-color: rgba(128, 128, 128, .7);
+  --outline-color: rgba(128, 128, 128, 0.2);
+  --outline-hover-color: rgba(128, 128, 128, 0.7);
   --divider-color: rgba(0, 0, 0, 0.1);
   --backdrop-color: rgba(255, 255, 255, 0.5);
   --img-bg-color: rgba(128, 128, 128, 0.2);
@@ -43,6 +43,8 @@
     --bg-faded-blur-color: #18191a99;
     --text-color: #f0f2f5;
     --text-insignificant-color: #f0f2f599;
+    --link-light-color: #6494ed99;
+    --link-faded-color: #6494ed44;
     --link-bg-hover-color: #34353799;
     --divider-color: rgba(255, 255, 255, 0.1);
     --bg-blur-color: #24252699;
@@ -52,12 +54,15 @@
   }
 }
 
-*, *::before, *::after {
+*,
+*::before,
+*::after {
   box-sizing: border-box;
 }
 
 body {
-  font-family: system-ui, -apple-system, BlinkMacSystemFont, '.SFNSText-Regular', sans-serif;
+  font-family: system-ui, -apple-system, BlinkMacSystemFont, '.SFNSText-Regular',
+    sans-serif;
   font-size: 16px;
   word-wrap: break-word;
   overflow-wrap: break-word;
@@ -83,19 +88,27 @@ hr {
   border: 0;
   padding: 0;
   margin: 16px 0;
-  background-image: linear-gradient(to right, transparent, var(--divider-color), transparent);
+  background-image: linear-gradient(
+    to right,
+    transparent,
+    var(--divider-color),
+    transparent
+  );
 }
 
-button, input, select, textarea {
+button,
+input,
+select,
+textarea {
   font-family: inherit;
   font-size: inherit;
   line-height: inherit;
   max-width: 100%;
 }
 
-button, .button {
+button,
+.button {
   display: inline-block;
-  margin: 2px;
   padding: 8px 12px;
   border-radius: 2.5em;
   border: 0;
@@ -128,7 +141,7 @@ button > * {
 :is(button, .button).plain2 {
   background-color: transparent;
   color: var(--link-color);
-  backdrop-filter: blur(12px) invert(.25) brightness(1.5);
+  backdrop-filter: blur(12px) invert(0.25) brightness(1.5);
 }
 :is(button, .button).light {
   background-color: var(--bg-faded-color);
@@ -142,17 +155,24 @@ button > * {
   padding: 12px;
 }
 
-input[type="text"], textarea, select {
+input[type='text'],
+textarea,
+select {
   color: var(--text-color);
   background-color: var(--bg-color);
   border: 2px solid var(--divider-color);
   padding: 8px;
   border-radius: 4px;
 }
-input[type="text"]:focus, textarea:focus, select:focus {
+input[type='text']:focus,
+textarea:focus,
+select:focus {
   border-color: var(--outline-color);
 }
-input[type="text"].large, textarea.large, select.large, button.large {
+input[type='text'].large,
+textarea.large,
+select.large,
+button.large {
   font-size: 125%;
   padding: 12px;
 }
@@ -168,15 +188,17 @@ select.plain {
 }
 
 @media (prefers-color-scheme: dark) {
-  img, video {
+  img,
+  video {
     filter: brightness(0.7);
     transition: filter 0.3s ease-out;
   }
-  img:hover, video:hover {
+  img:hover,
+  video:hover {
     filter: brightness(1);
   }
   :is(button, .button).plain2 {
-    backdrop-filter: blur(12px) brightness(.5);
+    backdrop-filter: blur(12px) brightness(0.5);
   }
 }
 
@@ -197,4 +219,4 @@ select.plain {
     opacity: 1;
     transform: translateY(0);
   }
-}
\ No newline at end of file
+}