mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-03 18:50:30 +08:00
Add editing to issue/patch descriptions, comments and replies
Also does some refactoring for `action`, `editable` and other variables we were using which weren't very clear Signed-off-by: Sebastian Martinez <me@sebastinez.dev>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type { Embed } from "@app/lib/file.js";
|
||||
import type { Commit, Commits } from "./project/commit.js";
|
||||
import type { Fetcher, RequestOptions } from "./fetcher.js";
|
||||
import type {
|
||||
@@ -365,7 +366,7 @@ export class Client {
|
||||
title: string;
|
||||
description: string;
|
||||
assignees: string[];
|
||||
embeds: { name: string; content: string }[];
|
||||
embeds: Embed[];
|
||||
labels: string[];
|
||||
},
|
||||
authToken: string,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { z } from "zod";
|
||||
import { array, number, object, string, tuple } from "zod";
|
||||
|
||||
export type Comment = z.infer<typeof commentSchema>;
|
||||
export type Embed = z.infer<typeof commentSchema>["embeds"][0];
|
||||
|
||||
export const commentSchema = object({
|
||||
id: string(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Embed } from "@app/lib/file.js";
|
||||
import type { Comment } from "./comment.js";
|
||||
import type { ZodSchema } from "zod";
|
||||
import { array, boolean, literal, object, string, union } from "zod";
|
||||
@@ -59,10 +60,15 @@ export type IssueUpdateAction =
|
||||
| {
|
||||
type: "comment";
|
||||
body: string;
|
||||
embeds: { name: string; content: string }[];
|
||||
replyTo: string;
|
||||
embeds?: Embed[];
|
||||
replyTo?: string;
|
||||
}
|
||||
| {
|
||||
type: "comment.edit";
|
||||
id: string;
|
||||
body: string;
|
||||
embeds: Embed[];
|
||||
}
|
||||
| { type: "comment.edit"; id: string; body: string }
|
||||
| { type: "comment.redact"; id: string }
|
||||
| {
|
||||
type: "comment.react";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Embed } from "@app/lib/file.js";
|
||||
import type { Comment } from "./comment.js";
|
||||
import type { ZodSchema, z } from "zod";
|
||||
|
||||
@@ -162,12 +163,15 @@ export type PatchUpdateAction =
|
||||
review: string;
|
||||
body: string;
|
||||
location: CodeLocation;
|
||||
replyTo?: string;
|
||||
embeds?: Embed[];
|
||||
}
|
||||
| {
|
||||
type: "review.comment.edit";
|
||||
review: string;
|
||||
comment: string;
|
||||
body: string;
|
||||
embeds: Embed[];
|
||||
}
|
||||
| {
|
||||
type: "review.comment.redact";
|
||||
@@ -188,6 +192,8 @@ export type PatchUpdateAction =
|
||||
type: "revision.comment";
|
||||
revision: string;
|
||||
body: string;
|
||||
embeds?: Embed[];
|
||||
location?: CodeLocation;
|
||||
replyTo?: string;
|
||||
}
|
||||
| {
|
||||
@@ -195,6 +201,7 @@ export type PatchUpdateAction =
|
||||
revision: string;
|
||||
comment: string;
|
||||
body: string;
|
||||
embeds: Embed[];
|
||||
}
|
||||
| {
|
||||
type: "revision.comment.redact";
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
<script lang="ts" strictEvents>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { createEventDispatcher, tick } from "svelte";
|
||||
|
||||
import { httpdStore } from "@app/lib/httpd";
|
||||
import { authenticated } from "@app/lib/httpd";
|
||||
import * as utils from "@app/lib/utils";
|
||||
|
||||
import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
|
||||
import IconButton from "@app/components/IconButton.svelte";
|
||||
import IconSmall from "@app/components/IconSmall.svelte";
|
||||
import Markdown from "@app/components/Markdown.svelte";
|
||||
import NodeId from "./NodeId.svelte";
|
||||
import NodeId from "@app/components/NodeId.svelte";
|
||||
import ReactionSelector from "@app/components/ReactionSelector.svelte";
|
||||
import Reactions from "@app/components/Reactions.svelte";
|
||||
import Textarea from "@app/components/Textarea.svelte";
|
||||
|
||||
export let id: string | undefined = undefined;
|
||||
export let authorId: string;
|
||||
export let authorAlias: string | undefined = undefined;
|
||||
export let body: string;
|
||||
export let reactions: [string, string][];
|
||||
export let action: "create" | "view" = "view";
|
||||
export let caption = "commented";
|
||||
export let rawPath: string;
|
||||
export let timestamp: number;
|
||||
export let isReply: boolean = false;
|
||||
export let isLastReply: boolean = false;
|
||||
// TODO: Remove flag once `radicle-httpd` fixes embed editing
|
||||
export let disableEdit: boolean = false;
|
||||
|
||||
let editInProgress = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
react: { nids: string[]; id: string; reaction: string };
|
||||
edit: string;
|
||||
}>();
|
||||
|
||||
$: groupedReactions = reactions?.reduce(
|
||||
@@ -64,13 +70,18 @@
|
||||
}
|
||||
.timestamp {
|
||||
color: var(--color-fill-gray);
|
||||
margin-left: auto;
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
.header-right {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.card-body {
|
||||
word-wrap: break-word;
|
||||
font-size: var(--font-size-small);
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
@@ -80,6 +91,10 @@
|
||||
padding-left: 2rem;
|
||||
height: 22px;
|
||||
}
|
||||
.edit-buttons {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.reply .card-body,
|
||||
.reply .actions {
|
||||
padding-left: 1rem;
|
||||
@@ -108,30 +123,50 @@
|
||||
</div>
|
||||
<NodeId nodeId={authorId} alias={authorAlias} />
|
||||
{caption}
|
||||
<div class="timestamp" title={utils.absoluteTimestamp(timestamp)}>
|
||||
{utils.formatTimestamp(timestamp)}
|
||||
<div class="header-right">
|
||||
{#if id && $authenticated && !editInProgress && !disableEdit}
|
||||
<div class="edit-buttons">
|
||||
<IconButton
|
||||
title="edit comment"
|
||||
on:click={() => (editInProgress = true)}>
|
||||
<IconSmall name={"edit"} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="timestamp" title={utils.absoluteTimestamp(timestamp)}>
|
||||
{utils.formatTimestamp(timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
{#if action === "create"}
|
||||
<Textarea
|
||||
resizable
|
||||
bind:value={body}
|
||||
on:submit
|
||||
placeholder="Leave a comment" />
|
||||
{#if editInProgress}
|
||||
<ExtendedTextarea
|
||||
{body}
|
||||
enableAttachments
|
||||
submitCaption="Save"
|
||||
placeholder="Leave your description"
|
||||
on:submit={({ detail: { comment } }) => {
|
||||
editInProgress = false;
|
||||
dispatch("edit", comment);
|
||||
}}
|
||||
on:close={async () => {
|
||||
body = body;
|
||||
await tick();
|
||||
editInProgress = false;
|
||||
}} />
|
||||
{:else if body.trim() === ""}
|
||||
<span class="txt-missing">No description</span>
|
||||
{:else}
|
||||
<Markdown {rawPath} content={body} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if (id && $httpdStore.state === "authenticated") || (id && groupedReactions.size > 0)}
|
||||
{#if (id && $authenticated) || (id && groupedReactions.size > 0)}
|
||||
<div class="actions">
|
||||
{#if id && $httpdStore.state === "authenticated"}
|
||||
{#if id && $authenticated}
|
||||
<ReactionSelector
|
||||
nid={$httpdStore.session.publicKey}
|
||||
nid={$authenticated.session.publicKey}
|
||||
reactions={groupedReactions}
|
||||
on:select={event => {
|
||||
if (id) {
|
||||
@@ -141,7 +176,7 @@
|
||||
{/if}
|
||||
{#if id && groupedReactions.size > 0}
|
||||
<Reactions
|
||||
clickable={$httpdStore.state === "authenticated"}
|
||||
clickable={Boolean($authenticated)}
|
||||
reactions={groupedReactions}
|
||||
on:remove={event => {
|
||||
if (id) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import type { Embed } from "@app/lib/file";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
submit: {
|
||||
comment: string;
|
||||
embeds: Embed[];
|
||||
};
|
||||
}>();
|
||||
|
||||
export let body: string | undefined = undefined;
|
||||
export let placeholder: string | undefined = undefined;
|
||||
export let submitCaption: string | undefined = undefined;
|
||||
export let enableAttachments: boolean = false;
|
||||
export let inline: boolean = false;
|
||||
export let focus: boolean = false;
|
||||
export let submitInProgress: boolean = false;
|
||||
|
||||
let active: boolean = false;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.inactive {
|
||||
box-shadow: 0 0 0 1px var(--color-border-hint);
|
||||
border-radius: var(--border-radius-small);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: var(--color-background-dip);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-fill-gray);
|
||||
cursor: text;
|
||||
}
|
||||
.inactive:hover {
|
||||
box-shadow: 0 0 0 1px var(--color-border-default);
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if active}
|
||||
<ExtendedTextarea
|
||||
{inline}
|
||||
{placeholder}
|
||||
{submitCaption}
|
||||
{submitInProgress}
|
||||
{focus}
|
||||
{body}
|
||||
{enableAttachments}
|
||||
on:close={() => (active = false)}
|
||||
on:submit={event => {
|
||||
active = false;
|
||||
dispatch("submit", event.detail);
|
||||
}} />
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="inactive"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={() => (active = true)}>
|
||||
{placeholder}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" strictEvents>
|
||||
import type { Embed } from "@app/lib/file";
|
||||
import type { Embed, EmbedWithOid } from "@app/lib/file";
|
||||
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
@@ -11,31 +11,34 @@
|
||||
|
||||
import Button from "./Button.svelte";
|
||||
import IconSmall from "./IconSmall.svelte";
|
||||
import Loading from "./Loading.svelte";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
import Radio from "./Radio.svelte";
|
||||
import Textarea from "./Textarea.svelte";
|
||||
|
||||
export let enableAttachments: boolean = false;
|
||||
export let placeholder: string = "Leave your comment";
|
||||
export let submitCaption: string = "Comment";
|
||||
export let focus: boolean = false;
|
||||
export let inline: boolean = false;
|
||||
export let body: string = "";
|
||||
export let submitInProgress: boolean = false;
|
||||
|
||||
let commentBody: string = "";
|
||||
let active: boolean = false;
|
||||
let preview: boolean = false;
|
||||
let newEmbeds: Embed[] = [];
|
||||
let newEmbeds: EmbedWithOid[] = [];
|
||||
let selectionStart = 0;
|
||||
let selectionEnd = 0;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
submit: { comment: string; embeds: Embed[] };
|
||||
close: null;
|
||||
click: null;
|
||||
}>();
|
||||
|
||||
function submit() {
|
||||
dispatch("submit", { comment: commentBody, embeds: newEmbeds });
|
||||
dispatch("submit", { comment: body, embeds: newEmbeds });
|
||||
preview = false;
|
||||
newEmbeds = [];
|
||||
active = false;
|
||||
}
|
||||
|
||||
const MAX_BLOB_SIZE = 4_194_304;
|
||||
@@ -73,9 +76,9 @@
|
||||
},
|
||||
];
|
||||
const embedText = `\n`;
|
||||
commentBody = commentBody
|
||||
body = body
|
||||
.slice(0, selectionStart)
|
||||
.concat(embedText, commentBody.slice(selectionEnd));
|
||||
.concat(embedText, body.slice(selectionEnd));
|
||||
selectionStart += embedText.length;
|
||||
selectionEnd = selectionStart;
|
||||
}),
|
||||
@@ -93,6 +96,7 @@
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
.inline {
|
||||
border: 0;
|
||||
@@ -120,90 +124,71 @@
|
||||
margin-left: 1px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.inactive {
|
||||
box-shadow: 0 0 0 1px var(--color-border-hint);
|
||||
border-radius: var(--border-radius-small);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: var(--color-background-dip);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-fill-gray);
|
||||
cursor: text;
|
||||
}
|
||||
.inactive:hover {
|
||||
box-shadow: 0 0 0 1px var(--color-border-default);
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if active}
|
||||
<div class="comment-section" class:inline>
|
||||
<Radio>
|
||||
<div class="comment-section" class:inline>
|
||||
<Radio>
|
||||
<Button
|
||||
styleBorderRadius="0"
|
||||
variant={!preview ? "secondary" : "gray"}
|
||||
on:click={() => {
|
||||
preview = false;
|
||||
}}>
|
||||
<IconSmall name="edit" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
styleBorderRadius="0"
|
||||
disabled={body === ""}
|
||||
variant={preview ? "secondary" : "gray"}
|
||||
on:click={() => {
|
||||
preview = true;
|
||||
}}>
|
||||
<IconSmall name="eye-open" />
|
||||
Preview
|
||||
</Button>
|
||||
</Radio>
|
||||
{#if preview}
|
||||
<div class="preview">
|
||||
<Markdown content={body} embeds={newEmbeds} />
|
||||
</div>
|
||||
{:else}
|
||||
<Textarea
|
||||
on:drop={handleFileDrop}
|
||||
bind:selectionEnd
|
||||
bind:selectionStart
|
||||
{focus}
|
||||
on:submit={submit}
|
||||
bind:value={body}
|
||||
{placeholder} />
|
||||
{/if}
|
||||
<div class="actions">
|
||||
{#if !preview}
|
||||
<div class="caption">
|
||||
Markdown supported. {#if enableAttachments}Drop attachments into the
|
||||
text area.{/if} Press {utils.isMac() ? "⌘" : "ctrl"}↵ to submit.
|
||||
</div>
|
||||
{/if}
|
||||
<div class="buttons">
|
||||
<Button
|
||||
styleBorderRadius="0"
|
||||
variant={!preview ? "secondary" : "gray"}
|
||||
disabled={submitInProgress}
|
||||
variant="outline"
|
||||
on:click={() => {
|
||||
preview = false;
|
||||
dispatch("close");
|
||||
}}>
|
||||
<IconSmall name="edit" />
|
||||
Edit
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
styleBorderRadius="0"
|
||||
disabled={commentBody === ""}
|
||||
variant={preview ? "secondary" : "gray"}
|
||||
on:click={() => {
|
||||
preview = true;
|
||||
}}>
|
||||
<IconSmall name="eye-open" />
|
||||
Preview
|
||||
variant="secondary"
|
||||
disabled={!body || submitInProgress}
|
||||
on:click={submit}>
|
||||
{#if submitInProgress}
|
||||
<Loading small noDelay />
|
||||
{:else}
|
||||
{submitCaption}
|
||||
{/if}
|
||||
</Button>
|
||||
</Radio>
|
||||
{#if preview}
|
||||
<div class="preview">
|
||||
<Markdown content={commentBody} embeds={newEmbeds} />
|
||||
</div>
|
||||
{:else}
|
||||
<Textarea
|
||||
on:drop={handleFileDrop}
|
||||
bind:selectionEnd
|
||||
bind:selectionStart
|
||||
{focus}
|
||||
on:submit={submit}
|
||||
bind:value={commentBody}
|
||||
{placeholder} />
|
||||
{/if}
|
||||
<div class="actions">
|
||||
{#if !preview}
|
||||
<div class="caption">
|
||||
Markdown supported. {#if enableAttachments}Drop attachments into the
|
||||
text area.{/if} Press {utils.isMac() ? "⌘" : "ctrl"}↵ to submit.
|
||||
</div>
|
||||
{/if}
|
||||
<div class="buttons">
|
||||
<Button
|
||||
variant="outline"
|
||||
on:click={() => {
|
||||
preview = false;
|
||||
active = false;
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={!commentBody} on:click={submit}>
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="inactive"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={() => {
|
||||
commentBody = "";
|
||||
active = true;
|
||||
dispatch("click");
|
||||
}}>
|
||||
{placeholder}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import Loading from "./Loading.svelte";
|
||||
|
||||
export let title: string | undefined = undefined;
|
||||
export let ariaLabel: string | undefined = undefined;
|
||||
export let loading: boolean = false;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -23,13 +26,17 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={ariaLabel}
|
||||
{title}
|
||||
class="button"
|
||||
on:click>
|
||||
<slot />
|
||||
</div>
|
||||
{#if loading}
|
||||
<Loading small noDelay />
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={ariaLabel}
|
||||
{title}
|
||||
class="button"
|
||||
on:click>
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { EmbedWithOid } from "@app/lib/file";
|
||||
|
||||
import dompurify from "dompurify";
|
||||
import matter from "@radicle/gray-matter";
|
||||
import { afterUpdate } from "svelte";
|
||||
@@ -15,7 +17,7 @@
|
||||
canonicalize,
|
||||
isCommit,
|
||||
} from "@app/lib/utils";
|
||||
import { mimes, type Embed } from "@app/lib/file";
|
||||
import { mimes } from "@app/lib/file";
|
||||
|
||||
export let content: string;
|
||||
// If present, resolve all relative links with respect to this URL
|
||||
@@ -24,7 +26,7 @@
|
||||
export let rawPath: string | undefined = undefined;
|
||||
// If present, means we are in a preview context,
|
||||
// use this for image previews instead of /raw URLs.
|
||||
export let embeds: Embed[] | undefined = undefined;
|
||||
export let embeds: EmbedWithOid[] | undefined = undefined;
|
||||
|
||||
$: doc = matter(content);
|
||||
$: frontMatter = Object.entries(doc.data).filter(
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
import * as utils from "@app/lib/utils";
|
||||
|
||||
import CommentComponent from "@app/components/Comment.svelte";
|
||||
import CommentTextarea from "./CommentTextarea.svelte";
|
||||
import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
|
||||
import IconSmall from "./IconSmall.svelte";
|
||||
|
||||
export let thread: { root: Comment; replies: Comment[] };
|
||||
export let rawPath: string;
|
||||
export let enableAttachments: boolean;
|
||||
export let enableAttachments: boolean = false;
|
||||
|
||||
async function toggleReply() {
|
||||
// This tick allows the DOM to update before scrolling.
|
||||
@@ -28,6 +28,7 @@
|
||||
embeds: { name: string; content: string }[];
|
||||
body: string;
|
||||
};
|
||||
editComment: { id: string; body: string };
|
||||
react: { nids: string[]; commentId: string | undefined; reaction: string };
|
||||
cancel: never;
|
||||
}>();
|
||||
@@ -70,7 +71,10 @@
|
||||
authorAlias={root.author.alias}
|
||||
reactions={root.reactions}
|
||||
timestamp={root.timestamp}
|
||||
disableEdit={root.embeds.length > 0}
|
||||
body={root.body}
|
||||
on:edit={event =>
|
||||
dispatch("editComment", { id: root.id, body: event.detail })}
|
||||
on:react>
|
||||
<IconSmall name="chat" slot="icon" />
|
||||
</CommentComponent>
|
||||
@@ -88,14 +92,17 @@
|
||||
isLastReply={replies[replies.length - 1] === reply}
|
||||
reactions={reply.reactions}
|
||||
timestamp={reply.timestamp}
|
||||
disableEdit={reply.embeds.length > 0}
|
||||
body={reply.body}
|
||||
on:edit={event =>
|
||||
dispatch("editComment", { id: reply.id, body: event.detail })}
|
||||
on:react />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if $httpdStore.state === "authenticated"}
|
||||
<div id={`reply-${root.id}`} class="reply">
|
||||
<CommentTextarea
|
||||
<CommentToggleInput
|
||||
inline
|
||||
placeholder="Reply to comment"
|
||||
on:click={toggleReply}
|
||||
|
||||
+4
-4
@@ -1,8 +1,10 @@
|
||||
export interface Embed {
|
||||
oid: string;
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
export interface EmbedWithOid extends Embed {
|
||||
oid: string;
|
||||
}
|
||||
|
||||
async function parseGitOid(bytes: Uint8Array): Promise<string> {
|
||||
// Create the header
|
||||
@@ -99,9 +101,7 @@ const mimes: Record<string, string> = {
|
||||
zip: "application/zip",
|
||||
};
|
||||
|
||||
async function embed(
|
||||
file: File,
|
||||
): Promise<{ oid: string; name: string; content: string }> {
|
||||
async function embed(file: File): Promise<EmbedWithOid> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const oid = await parseGitOid(bytes);
|
||||
const content = await base64String(file);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { withTimeout, Mutex, E_CANCELED, E_TIMEOUT } from "async-mutex";
|
||||
|
||||
import { HttpdClient } from "@httpd-client";
|
||||
import { config } from "@app/lib/config";
|
||||
import { isLocal } from "@app/lib/utils";
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
@@ -23,6 +24,13 @@ const HTTPD_CUSTOM_PORT_KEY = "httpdCustomPort";
|
||||
|
||||
const store = writable<HttpdState>({ state: "stopped" });
|
||||
export const httpdStore = derived(store, s => s);
|
||||
export const authenticated = derived(store, s =>
|
||||
s.state === "authenticated" ? s : undefined,
|
||||
);
|
||||
export const authenticatedLocal = derived(
|
||||
store,
|
||||
s => (hostname: string) => s.state === "authenticated" && isLocal(hostname),
|
||||
);
|
||||
|
||||
export const api = new HttpdClient({
|
||||
hostname: "127.0.0.1",
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
|
||||
const dispatch = createEventDispatcher<{ save: string[] }>();
|
||||
|
||||
export let action: "create" | "edit" | "view";
|
||||
export let editInProgress: boolean = false;
|
||||
export let mode: "readWrite" | "readOnly" = "readOnly";
|
||||
export let hideEditIcon: boolean = false;
|
||||
export let locallyAuthenticated: boolean = false;
|
||||
export let assignees: string[] = [];
|
||||
|
||||
let updatedAssignees: string[] = assignees;
|
||||
@@ -47,7 +48,7 @@
|
||||
if (valid && assignee) {
|
||||
updatedAssignees = [...updatedAssignees, assignee];
|
||||
inputValue = "";
|
||||
if (action === "create") {
|
||||
if (mode !== "readOnly") {
|
||||
dispatch("save", updatedAssignees);
|
||||
}
|
||||
}
|
||||
@@ -55,7 +56,7 @@
|
||||
|
||||
function removeAssignee(assignee: string) {
|
||||
updatedAssignees = updatedAssignees.filter(x => x !== assignee);
|
||||
if (action === "create") {
|
||||
if (mode !== "readOnly") {
|
||||
dispatch("save", updatedAssignees);
|
||||
}
|
||||
}
|
||||
@@ -93,13 +94,13 @@
|
||||
<div>
|
||||
<div class="header">
|
||||
<span>Assignees</span>
|
||||
{#if action === "edit"}
|
||||
{#if locallyAuthenticated && !hideEditIcon}
|
||||
<div class="actions">
|
||||
{#if editInProgress}
|
||||
{#if mode !== "readOnly"}
|
||||
<IconButton
|
||||
on:click={() => {
|
||||
dispatch("save", updatedAssignees);
|
||||
editInProgress = !editInProgress;
|
||||
mode = "readOnly";
|
||||
}}>
|
||||
<IconSmall name="checkmark" />
|
||||
</IconButton>
|
||||
@@ -107,15 +108,12 @@
|
||||
on:click={() => {
|
||||
updatedAssignees = assignees;
|
||||
inputValue = "";
|
||||
editInProgress = !editInProgress;
|
||||
mode = "readOnly";
|
||||
}}>
|
||||
<IconSmall name="cross" />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton
|
||||
on:click={() => {
|
||||
editInProgress = !editInProgress;
|
||||
}}>
|
||||
<IconButton on:click={() => (mode = "readWrite")}>
|
||||
<IconSmall name="edit" />
|
||||
</IconButton>
|
||||
{/if}
|
||||
@@ -123,7 +121,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if editInProgress || action === "create"}
|
||||
{#if locallyAuthenticated && mode === "readWrite"}
|
||||
{#each updatedAssignees as assignee}
|
||||
<Badge variant="neutral">
|
||||
<div class="assignee">
|
||||
@@ -152,7 +150,7 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{#if editInProgress || action === "create"}
|
||||
{#if locallyAuthenticated && mode === "readWrite"}
|
||||
<div style:margin-bottom="1rem" style:margin-top="1rem">
|
||||
<TextInput
|
||||
{valid}
|
||||
|
||||
@@ -8,14 +8,15 @@
|
||||
import TextInput from "@app/components/TextInput.svelte";
|
||||
import IconButton from "@app/components/IconButton.svelte";
|
||||
|
||||
export let action: "create" | "edit" | "view" = "view";
|
||||
export let locallyAuthenticated: boolean = false;
|
||||
export let preview: boolean = false;
|
||||
export let mode: "readWrite" | "readOnly" = "readOnly";
|
||||
export let id: string | undefined = undefined;
|
||||
export let title: string = "";
|
||||
export let submitInProgress: boolean = false;
|
||||
const oldTitle = title;
|
||||
|
||||
const dispatch = createEventDispatcher<{ editTitle: string }>();
|
||||
|
||||
$: editable = action === "create" ? true : false;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -65,15 +66,15 @@
|
||||
|
||||
<div class="header">
|
||||
<div class="summary">
|
||||
{#if editable}
|
||||
{#if locallyAuthenticated && !preview && mode === "readWrite"}
|
||||
<div><slot name="icon" /></div>
|
||||
<TextInput
|
||||
placeholder="Title"
|
||||
bind:value={title}
|
||||
showKeyHint={action === "edit"}
|
||||
showKeyHint={mode === "readWrite" && Boolean(id)}
|
||||
on:submit={() => {
|
||||
if (action === "edit") {
|
||||
editable = !editable;
|
||||
if (mode === "readWrite") {
|
||||
mode = "readOnly";
|
||||
dispatch("editTitle", title);
|
||||
}
|
||||
}} />
|
||||
@@ -85,30 +86,34 @@
|
||||
{:else}
|
||||
<span class="txt-missing">No title</span>
|
||||
{/if}
|
||||
{#if action === "edit"}
|
||||
<!-- When creating a new COB id is undefined -->
|
||||
{#if locallyAuthenticated && id}
|
||||
<div class="edit-buttons">
|
||||
{#if editable}
|
||||
{#if mode === "readWrite"}
|
||||
<IconButton
|
||||
title="save title"
|
||||
loading={submitInProgress}
|
||||
on:click={() => {
|
||||
editable = !editable;
|
||||
mode = "readOnly";
|
||||
dispatch("editTitle", title);
|
||||
}}>
|
||||
<IconSmall name={"checkmark"} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
title="dismiss changes"
|
||||
loading={submitInProgress}
|
||||
on:click={() => {
|
||||
title = oldTitle;
|
||||
editable = !editable;
|
||||
mode = "readOnly";
|
||||
}}>
|
||||
<IconSmall name={"cross"} />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton
|
||||
title="edit title"
|
||||
loading={submitInProgress}
|
||||
on:click={() => {
|
||||
editable = !editable;
|
||||
mode = "readWrite";
|
||||
dispatch("editTitle", title);
|
||||
}}>
|
||||
<IconSmall name={"edit"} />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts" strictEvents>
|
||||
import type { Embed } from "@app/lib/file";
|
||||
|
||||
import Badge from "@app/components/Badge.svelte";
|
||||
import Clipboard from "@app/components/Clipboard.svelte";
|
||||
|
||||
export let embeds: { name: string; content: string }[] = [];
|
||||
export let embeds: Embed[] = [];
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
|
||||
const dispatch = createEventDispatcher<{ save: string[] }>();
|
||||
|
||||
export let action: "create" | "edit" | "view" = "view";
|
||||
export let editInProgress: boolean = false;
|
||||
export let hideEditIcon: boolean = false;
|
||||
export let mode: "readWrite" | "readOnly" = "readOnly";
|
||||
export let locallyAuthenticated: boolean = false;
|
||||
export let labels: string[] = [];
|
||||
|
||||
let updatedLabels: string[] = labels;
|
||||
@@ -41,7 +42,7 @@
|
||||
if (valid && sanitizedValue) {
|
||||
updatedLabels = [...updatedLabels, sanitizedValue];
|
||||
inputValue = "";
|
||||
if (action === "create") {
|
||||
if (mode === "readWrite") {
|
||||
dispatch("save", updatedLabels);
|
||||
}
|
||||
}
|
||||
@@ -49,7 +50,7 @@
|
||||
|
||||
function removeLabel(label: string) {
|
||||
updatedLabels = updatedLabels.filter(x => x !== label);
|
||||
if (action === "create") {
|
||||
if (mode === "readWrite") {
|
||||
dispatch("save", updatedLabels);
|
||||
}
|
||||
}
|
||||
@@ -81,14 +82,14 @@
|
||||
<div>
|
||||
<div class="metadata-section-header">
|
||||
<span>Labels</span>
|
||||
{#if action === "edit"}
|
||||
{#if locallyAuthenticated && !hideEditIcon}
|
||||
<div class="actions">
|
||||
{#if editInProgress}
|
||||
{#if mode === "readWrite"}
|
||||
<IconButton
|
||||
title="save labels"
|
||||
on:click={() => {
|
||||
dispatch("save", updatedLabels);
|
||||
editInProgress = !editInProgress;
|
||||
mode = "readOnly";
|
||||
}}>
|
||||
<IconSmall name="checkmark" />
|
||||
</IconButton>
|
||||
@@ -97,16 +98,12 @@
|
||||
on:click={() => {
|
||||
updatedLabels = labels;
|
||||
inputValue = "";
|
||||
editInProgress = !editInProgress;
|
||||
mode = "readOnly";
|
||||
}}>
|
||||
<IconSmall name="cross" />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton
|
||||
title="edit labels"
|
||||
on:click={() => {
|
||||
editInProgress = !editInProgress;
|
||||
}}>
|
||||
<IconButton title="edit labels" on:click={() => (mode = "readWrite")}>
|
||||
<IconSmall name="edit" />
|
||||
</IconButton>
|
||||
{/if}
|
||||
@@ -114,7 +111,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="metadata-section-body">
|
||||
{#if editInProgress || action === "create"}
|
||||
{#if locallyAuthenticated && mode === "readWrite"}
|
||||
{#each updatedLabels as label}
|
||||
<Badge variant="neutral">
|
||||
<div aria-label="chip" class="label">{label}</div>
|
||||
@@ -135,7 +132,7 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{#if editInProgress || action === "create"}
|
||||
{#if locallyAuthenticated && mode === "readWrite"}
|
||||
<div style:margin-bottom="2rem" style:margin-top="1rem">
|
||||
<TextInput
|
||||
{valid}
|
||||
|
||||
@@ -385,9 +385,9 @@
|
||||
{#if element.type === "thread"}
|
||||
<div class="connector" />
|
||||
<Thread
|
||||
enableAttachments={false}
|
||||
rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
|
||||
thread={element.inner}
|
||||
on:editComment
|
||||
on:react
|
||||
on:reply />
|
||||
{:else if element.type === "merge"}
|
||||
|
||||
+181
-79
@@ -4,23 +4,26 @@
|
||||
import type { IssueUpdateAction } from "@httpd-client/lib/project/issue";
|
||||
import type { Session } from "@app/lib/httpd";
|
||||
|
||||
import { isEqual } from "lodash";
|
||||
import { isEqual, uniqBy } from "lodash";
|
||||
|
||||
import * as modal from "@app/lib/modal";
|
||||
import * as router from "@app/lib/router";
|
||||
import * as utils from "@app/lib/utils";
|
||||
import { HttpdClient } from "@httpd-client";
|
||||
import { ResponseError } from "@httpd-client/lib/fetcher";
|
||||
import { httpdStore } from "@app/lib/httpd";
|
||||
import { authenticated, authenticatedLocal } from "@app/lib/httpd";
|
||||
|
||||
import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
|
||||
import Badge from "@app/components/Badge.svelte";
|
||||
import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
|
||||
import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
|
||||
import CommentTextarea from "@app/components/CommentTextarea.svelte";
|
||||
import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
|
||||
import Embeds from "@app/views/projects/Cob/Embeds.svelte";
|
||||
import ErrorModal from "@app/modals/ErrorModal.svelte";
|
||||
import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import IconButton from "@app/components/IconButton.svelte";
|
||||
import IconSmall from "@app/components/IconSmall.svelte";
|
||||
import LabelInput from "./Cob/LabelInput.svelte";
|
||||
import Layout from "./Layout.svelte";
|
||||
import Markdown from "@app/components/Markdown.svelte";
|
||||
@@ -37,11 +40,6 @@
|
||||
const rawPath = utils.getRawBasePath(project.id, baseUrl, project.head);
|
||||
const api = new HttpdClient(baseUrl);
|
||||
|
||||
let action: "edit" | "view";
|
||||
$: action =
|
||||
$httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
|
||||
? "edit"
|
||||
: "view";
|
||||
const items: [string, IssueState][] = [
|
||||
["Reopen issue", { status: "open" }],
|
||||
["Close issue as solved", { status: "closed", reason: "solved" }],
|
||||
@@ -49,23 +47,18 @@
|
||||
];
|
||||
|
||||
async function createReply({
|
||||
detail: reply,
|
||||
detail: { body, embeds, id },
|
||||
}: CustomEvent<{
|
||||
id: string;
|
||||
embeds: { name: string; content: string }[];
|
||||
embeds: Embed[];
|
||||
body: string;
|
||||
}>) {
|
||||
if ($httpdStore.state === "authenticated" && reply.body.trim().length > 0) {
|
||||
if ($authenticated && body.trim().length > 0) {
|
||||
const status = await updateIssue(
|
||||
project.id,
|
||||
issue.id,
|
||||
{
|
||||
type: "comment",
|
||||
body: reply.body,
|
||||
embeds: reply.embeds,
|
||||
replyTo: reply.id,
|
||||
},
|
||||
$httpdStore.session,
|
||||
{ type: "comment", body, embeds, replyTo: id },
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
@@ -74,18 +67,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function createComment(body: string, embeds: Embed[]) {
|
||||
if ($httpdStore.state === "authenticated" && body.trim().length > 0) {
|
||||
async function createComment({
|
||||
detail: { comment, embeds },
|
||||
}: CustomEvent<{ comment: string; embeds: Embed[] }>) {
|
||||
if ($authenticated && comment.trim().length > 0) {
|
||||
const status = await updateIssue(
|
||||
project.id,
|
||||
issue.id,
|
||||
{
|
||||
type: "comment",
|
||||
body,
|
||||
embeds: embeds,
|
||||
replyTo: issue.id,
|
||||
},
|
||||
$httpdStore.session,
|
||||
{ type: "comment", body: comment, embeds, replyTo: issue.id },
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
@@ -94,6 +84,57 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function editComment(id: string, body: string) {
|
||||
if ($authenticated && body.trim().length > 0) {
|
||||
try {
|
||||
if (issue.id === id) {
|
||||
saveDescriptionInProgress = true;
|
||||
} else {
|
||||
saveCommentInProgress = true;
|
||||
}
|
||||
const status = await updateIssue(
|
||||
project.id,
|
||||
issue.id,
|
||||
{
|
||||
type: "comment.edit",
|
||||
id,
|
||||
body,
|
||||
embeds: embeds[id],
|
||||
},
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
issue = await refreshIssue(project.id, issue, api);
|
||||
} else {
|
||||
// Reassigning issue.discussion overwrites the changed comment in Comment
|
||||
issue.discussion = issue.discussion;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
modal.show({
|
||||
component: ErrorModal,
|
||||
props: {
|
||||
title: "Issue comment editing failed",
|
||||
subtitle: [
|
||||
"There was an error while updating the issue.",
|
||||
"Check your radicle-httpd logs for details.",
|
||||
],
|
||||
error: {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
editingIssueDescription = false;
|
||||
saveDescriptionInProgress = false;
|
||||
saveCommentInProgress = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReaction({
|
||||
nids,
|
||||
id,
|
||||
@@ -103,7 +144,7 @@
|
||||
id: string;
|
||||
reaction: string;
|
||||
}) {
|
||||
if ($httpdStore.state === "authenticated") {
|
||||
if ($authenticated) {
|
||||
try {
|
||||
const status = await updateIssue(
|
||||
project.id,
|
||||
@@ -112,9 +153,11 @@
|
||||
type: "comment.react",
|
||||
id,
|
||||
reaction,
|
||||
active: nids.includes($httpdStore.session.publicKey) ? false : true,
|
||||
active: nids.includes($authenticated.session.publicKey)
|
||||
? false
|
||||
: true,
|
||||
},
|
||||
$httpdStore.session,
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
@@ -127,22 +170,40 @@
|
||||
}
|
||||
|
||||
async function editTitle({ detail: title }: CustomEvent<string>) {
|
||||
if (
|
||||
$httpdStore.state === "authenticated" &&
|
||||
title.trim().length > 0 &&
|
||||
title !== issue.title
|
||||
) {
|
||||
const status = await updateIssue(
|
||||
project.id,
|
||||
issue.id,
|
||||
{ type: "edit", title },
|
||||
$httpdStore.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
issue = await refreshIssue(project.id, issue, api);
|
||||
if ($authenticated && title.trim().length > 0 && title !== issue.title) {
|
||||
try {
|
||||
saveTitleInProgress = true;
|
||||
const status = await updateIssue(
|
||||
project.id,
|
||||
issue.id,
|
||||
{ type: "edit", title },
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
issue = await refreshIssue(project.id, issue, api);
|
||||
}
|
||||
issue.title = issue.title;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
modal.show({
|
||||
component: ErrorModal,
|
||||
props: {
|
||||
title: "Issue title editing failed",
|
||||
subtitle: [
|
||||
"There was an error while updating the issue.",
|
||||
"Check your radicle-httpd logs for details.",
|
||||
],
|
||||
error: {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
saveTitleInProgress = false;
|
||||
}
|
||||
issue.title = issue.title;
|
||||
} else {
|
||||
// Reassigning issue.title overwrites the invalid title in IssueHeader
|
||||
issue.title = issue.title;
|
||||
@@ -150,39 +211,36 @@
|
||||
}
|
||||
|
||||
async function saveLabels({ detail: labels }: CustomEvent<string[]>) {
|
||||
if ($httpdStore.state === "authenticated") {
|
||||
if ($authenticated) {
|
||||
if (isEqual(issue.labels, labels)) {
|
||||
return;
|
||||
}
|
||||
const status = await updateIssue(
|
||||
project.id,
|
||||
issue.id,
|
||||
{
|
||||
type: "label",
|
||||
labels: labels,
|
||||
},
|
||||
$httpdStore.session,
|
||||
{ type: "label", labels },
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
issue = await refreshIssue(project.id, issue, api);
|
||||
} else {
|
||||
// Reassigning issue overwrites the label changes.
|
||||
issue = issue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAssignees({ detail: assignees }: CustomEvent<string[]>) {
|
||||
if ($httpdStore.state === "authenticated") {
|
||||
if ($authenticated) {
|
||||
if (isEqual(issue.assignees, assignees)) {
|
||||
return;
|
||||
}
|
||||
const status = await updateIssue(
|
||||
project.id,
|
||||
issue.id,
|
||||
{
|
||||
type: "assign",
|
||||
assignees: assignees,
|
||||
},
|
||||
$httpdStore.session,
|
||||
{ type: "assign", assignees },
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
@@ -192,12 +250,12 @@
|
||||
}
|
||||
|
||||
async function saveStatus({ detail: state }: CustomEvent<IssueState>) {
|
||||
if ($httpdStore.state === "authenticated") {
|
||||
if ($authenticated) {
|
||||
const status = await updateIssue(
|
||||
project.id,
|
||||
issue.id,
|
||||
{ type: "lifecycle", state },
|
||||
$httpdStore.session,
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
@@ -289,14 +347,16 @@
|
||||
}
|
||||
|
||||
const issueDescription = issue.discussion[0];
|
||||
let editingIssueDescription = false;
|
||||
|
||||
$: embeds = issue.discussion.reduce(
|
||||
(acc, comment) => {
|
||||
acc.push(...comment.embeds);
|
||||
acc[comment.id] = comment.embeds;
|
||||
return acc;
|
||||
},
|
||||
[] as { name: string; content: string }[],
|
||||
{} as Record<string, Embed[]>,
|
||||
);
|
||||
$: uniqueEmbeds = uniqBy(Object.values(embeds).flat(), "content");
|
||||
$: selectedItem = issue.state.status === "closed" ? items[0] : items[1];
|
||||
$: threads = issue.discussion
|
||||
.filter(
|
||||
@@ -316,6 +376,10 @@
|
||||
(acc, [nid, emoji]) => acc.set(emoji, [...(acc.get(emoji) ?? []), nid]),
|
||||
new Map<string, string[]>(),
|
||||
);
|
||||
|
||||
let saveDescriptionInProgress = false;
|
||||
let saveTitleInProgress = false;
|
||||
let saveCommentInProgress = false;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -354,6 +418,12 @@
|
||||
height: 22px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.markdown {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.open {
|
||||
color: var(--color-fill-success);
|
||||
}
|
||||
@@ -376,9 +446,10 @@
|
||||
<div class="issue">
|
||||
<div style="display: flex; flex-direction: column; gap: 1.5rem;">
|
||||
<CobHeader
|
||||
{action}
|
||||
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
|
||||
id={issue.id}
|
||||
title={issue.title}
|
||||
submitInProgress={saveTitleInProgress}
|
||||
on:editTitle={editTitle}>
|
||||
<svelte:fragment slot="icon">
|
||||
<div
|
||||
@@ -401,21 +472,47 @@
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
<div slot="description">
|
||||
<Markdown
|
||||
content={issue.discussion[0].body}
|
||||
rawPath={utils.getRawBasePath(project.id, baseUrl, project.head)} />
|
||||
{#if $authenticatedLocal(baseUrl.hostname) && editingIssueDescription}
|
||||
<ExtendedTextarea
|
||||
enableAttachments
|
||||
body={issue.discussion[0].body}
|
||||
submitCaption="Save"
|
||||
submitInProgress={saveDescriptionInProgress}
|
||||
placeholder="Leave a description"
|
||||
on:close={() => (editingIssueDescription = false)}
|
||||
on:submit={async ({ detail: { comment } }) => {
|
||||
void editComment(issue.id, comment);
|
||||
}} />
|
||||
{:else}
|
||||
<div class="markdown">
|
||||
<Markdown
|
||||
content={issue.discussion[0].body}
|
||||
rawPath={utils.getRawBasePath(
|
||||
project.id,
|
||||
baseUrl,
|
||||
project.head,
|
||||
)} />
|
||||
<!-- TODO: Remove if statement once `radicle-httpd` fixes embed editing -->
|
||||
{#if issue.discussion[0].embeds.length === 0}
|
||||
<IconButton
|
||||
title="edit description"
|
||||
on:click={() => (editingIssueDescription = true)}>
|
||||
<IconSmall name={"edit"} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="reactions">
|
||||
{#if $httpdStore.state === "authenticated"}
|
||||
{#if $authenticated}
|
||||
<ReactionSelector
|
||||
nid={$httpdStore.session.publicKey}
|
||||
nid={$authenticated.session.publicKey}
|
||||
reactions={issueReactions}
|
||||
on:select={async event => {
|
||||
await handleReaction({ ...event.detail, id: issue.id });
|
||||
}} />
|
||||
on:select={event =>
|
||||
handleReaction({ ...event.detail, id: issue.id })} />
|
||||
{/if}
|
||||
{#if issueReactions.size > 0}
|
||||
<Reactions
|
||||
clickable={$httpdStore.state === "authenticated"}
|
||||
clickable={Boolean($authenticated)}
|
||||
reactions={issueReactions}
|
||||
on:remove={event =>
|
||||
handleReaction({ ...event.detail, id: issue.id })} />
|
||||
@@ -436,17 +533,19 @@
|
||||
enableAttachments
|
||||
{thread}
|
||||
{rawPath}
|
||||
on:editComment={({ detail: { id, body } }) =>
|
||||
editComment(id, body)}
|
||||
on:reply={createReply}
|
||||
on:react={event => handleReaction(event.detail)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if $httpdStore.state === "authenticated"}
|
||||
<CommentTextarea
|
||||
{#if $authenticated}
|
||||
<CommentToggleInput
|
||||
placeholder="Leave your comment"
|
||||
enableAttachments
|
||||
on:submit={async event => {
|
||||
await createComment(event.detail.comment, event.detail.embeds);
|
||||
}} />
|
||||
submitInProgress={saveCommentInProgress}
|
||||
on:submit={createComment} />
|
||||
<div style:display="flex">
|
||||
<CobStateButton
|
||||
items={items.filter(([, state]) => !isEqual(state, issue.state))}
|
||||
@@ -458,11 +557,14 @@
|
||||
</div>
|
||||
<div class="metadata">
|
||||
<AssigneeInput
|
||||
{action}
|
||||
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
|
||||
assignees={issue.assignees}
|
||||
on:save={saveAssignees} />
|
||||
<LabelInput {action} labels={issue.labels} on:save={saveLabels} />
|
||||
<Embeds {embeds} />
|
||||
<LabelInput
|
||||
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
|
||||
labels={issue.labels}
|
||||
on:save={saveLabels} />
|
||||
<Embeds embeds={uniqueEmbeds} />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { BaseUrl, Project } from "@httpd-client";
|
||||
import type { EmbedWithOid } from "@app/lib/file";
|
||||
|
||||
import * as modal from "@app/lib/modal";
|
||||
import * as router from "@app/lib/router";
|
||||
import * as utils from "@app/lib/utils";
|
||||
import { HttpdClient } from "@httpd-client";
|
||||
import { embed, type Embed } from "@app/lib/file";
|
||||
import { httpdStore } from "@app/lib/httpd";
|
||||
import { embed } from "@app/lib/file";
|
||||
import { authenticatedLocal, httpdStore } from "@app/lib/httpd";
|
||||
|
||||
import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
|
||||
import AuthenticationErrorModal from "@app/modals/AuthenticationErrorModal.svelte";
|
||||
@@ -25,17 +25,10 @@
|
||||
export let project: Project;
|
||||
export let tracking: boolean;
|
||||
|
||||
let newEmbeds: Embed[] = [];
|
||||
let newEmbeds: EmbedWithOid[] = [];
|
||||
let preview: boolean = false;
|
||||
let selectionStart = 0;
|
||||
let selectionEnd = 0;
|
||||
let preview: boolean = false;
|
||||
let action: "create" | "view";
|
||||
$: action =
|
||||
$httpdStore.state === "authenticated" &&
|
||||
utils.isLocal(baseUrl.hostname) &&
|
||||
!preview
|
||||
? "create"
|
||||
: "view";
|
||||
|
||||
let issueTitle = "";
|
||||
let issueText = "";
|
||||
@@ -155,19 +148,23 @@
|
||||
{@const session = $httpdStore.session}
|
||||
<div class="form">
|
||||
<div class="editor">
|
||||
<CobHeader {action} bind:title={issueTitle}>
|
||||
<CobHeader
|
||||
mode="readWrite"
|
||||
{preview}
|
||||
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
|
||||
bind:title={issueTitle}>
|
||||
<svelte:fragment slot="icon">
|
||||
<div class="open">
|
||||
<Icon name="issue" />
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="state">
|
||||
{#if action === "view"}
|
||||
{#if preview}
|
||||
<Badge size="small" variant="positive">open</Badge>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="description">
|
||||
{#if action === "create"}
|
||||
{#if !preview}
|
||||
<Textarea
|
||||
bind:selectionStart
|
||||
bind:selectionEnd
|
||||
@@ -186,7 +183,7 @@
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
<div class="author" slot="author">
|
||||
{#if action === "view"}
|
||||
{#if preview}
|
||||
opened by <NodeId
|
||||
nodeId={$httpdStore.session.publicKey}
|
||||
alias={$httpdStore.session.alias} /> now
|
||||
@@ -211,11 +208,15 @@
|
||||
</div>
|
||||
<div class="metadata">
|
||||
<AssigneeInput
|
||||
{action}
|
||||
hideEditIcon
|
||||
mode="readWrite"
|
||||
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
|
||||
on:save={({ detail: updatedAssignees }) =>
|
||||
(assignees = updatedAssignees)} />
|
||||
<LabelInput
|
||||
{action}
|
||||
hideEditIcon
|
||||
mode="readWrite"
|
||||
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
|
||||
on:save={({ detail: updatedLabels }) => (labels = updatedLabels)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { BaseUrl, Issue, IssueState, Project } from "@httpd-client";
|
||||
|
||||
import * as utils from "@app/lib/utils";
|
||||
import { HttpdClient } from "@httpd-client";
|
||||
import { ISSUES_PER_PAGE } from "./router";
|
||||
import { closeFocused } from "@app/components/Popover.svelte";
|
||||
import { httpdStore } from "@app/lib/httpd";
|
||||
import { authenticatedLocal } from "@app/lib/httpd";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import DropdownList from "@app/components/DropdownList.svelte";
|
||||
@@ -124,7 +123,7 @@
|
||||
</Link>
|
||||
</DropdownList>
|
||||
</Popover>
|
||||
{#if $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)}
|
||||
{#if $authenticatedLocal(baseUrl.hostname)}
|
||||
<div style="margin-left: auto;">
|
||||
<Link
|
||||
route={{
|
||||
|
||||
+213
-50
@@ -36,6 +36,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import type { BaseUrl, Patch, PatchUpdateAction } from "@httpd-client";
|
||||
import type { Embed } from "@app/lib/file";
|
||||
import type { PatchView } from "./router";
|
||||
import type { Route } from "@app/lib/router";
|
||||
import type { Session } from "@app/lib/httpd";
|
||||
@@ -46,27 +47,33 @@
|
||||
import * as utils from "@app/lib/utils";
|
||||
import { HttpdClient } from "@httpd-client";
|
||||
import { capitalize, isEqual } from "lodash";
|
||||
import { httpdStore } from "@app/lib/httpd";
|
||||
import {
|
||||
authenticated,
|
||||
authenticatedLocal,
|
||||
httpdStore,
|
||||
} from "@app/lib/httpd";
|
||||
|
||||
import Badge from "@app/components/Badge.svelte";
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import Changeset from "@app/views/projects/Changeset.svelte";
|
||||
import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
|
||||
import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
|
||||
import CommentTextarea from "@app/components/CommentTextarea.svelte";
|
||||
import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
|
||||
import CommitTeaser from "@app/views/projects/Commit/CommitTeaser.svelte";
|
||||
import DropdownList from "@app/components/DropdownList.svelte";
|
||||
import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
|
||||
import ErrorModal from "@app/modals/ErrorModal.svelte";
|
||||
import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import IconButton from "@app/components/IconButton.svelte";
|
||||
import IconSmall from "@app/components/IconSmall.svelte";
|
||||
import LabelInput from "@app/views/projects/Cob/LabelInput.svelte";
|
||||
import Layout from "@app/views/projects/Layout.svelte";
|
||||
import Link from "@app/components/Link.svelte";
|
||||
import Markdown from "@app/components/Markdown.svelte";
|
||||
import Popover, { closeFocused } from "@app/components/Popover.svelte";
|
||||
import NodeId from "@app/components/NodeId.svelte";
|
||||
import Placeholder from "@app/components/Placeholder.svelte";
|
||||
import Popover, { closeFocused } from "@app/components/Popover.svelte";
|
||||
import Radio from "@app/components/Radio.svelte";
|
||||
import RevisionComponent from "@app/views/projects/Cob/Revision.svelte";
|
||||
|
||||
@@ -84,10 +91,99 @@
|
||||
["Convert to draft", { status: "draft" }],
|
||||
];
|
||||
|
||||
async function editTitle({ detail: title }: CustomEvent<string>) {
|
||||
if ($authenticated && title.trim().length > 0 && title !== patch.title) {
|
||||
try {
|
||||
saveTitleInProgress = true;
|
||||
const status = await updatePatch(
|
||||
project.id,
|
||||
patch.id,
|
||||
{
|
||||
type: "edit",
|
||||
title,
|
||||
target: "delegates",
|
||||
},
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
patch = await api.project.getPatchById(project.id, patch.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
modal.show({
|
||||
component: ErrorModal,
|
||||
props: {
|
||||
title: "Patch title editing failed",
|
||||
subtitle: [
|
||||
"There was an error while updating the issue.",
|
||||
"Check your radicle-httpd logs for details.",
|
||||
],
|
||||
error: {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
saveTitleInProgress = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function editDescription({
|
||||
detail: { comment: description },
|
||||
}: CustomEvent<{ comment: string; embeds: Embed[] }>) {
|
||||
if (
|
||||
$authenticated &&
|
||||
description.trim().length > 0 &&
|
||||
description !== patch.title
|
||||
) {
|
||||
try {
|
||||
saveDescriptionInProgress = true;
|
||||
const status = await updatePatch(
|
||||
project.id,
|
||||
patch.id,
|
||||
{
|
||||
type: "revision.edit",
|
||||
revision: patch.id,
|
||||
description,
|
||||
},
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
editingDescription = false;
|
||||
patch = await api.project.getPatchById(project.id, patch.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
modal.show({
|
||||
component: ErrorModal,
|
||||
props: {
|
||||
title: "Patch description editing failed",
|
||||
subtitle: [
|
||||
"There was an error while updating the issue.",
|
||||
"Check your radicle-httpd logs for details.",
|
||||
],
|
||||
error: {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
saveDescriptionInProgress = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createReply({
|
||||
detail: reply,
|
||||
}: CustomEvent<{ id: string; body: string }>) {
|
||||
if ($httpdStore.state === "authenticated" && reply.body.trim().length > 0) {
|
||||
if ($authenticated && reply.body.trim().length > 0) {
|
||||
const status = await updatePatch(
|
||||
project.id,
|
||||
patch.id,
|
||||
@@ -97,7 +193,7 @@
|
||||
body: reply.body,
|
||||
replyTo: reply.id,
|
||||
},
|
||||
$httpdStore.session,
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
@@ -115,7 +211,7 @@
|
||||
reaction: string;
|
||||
}>,
|
||||
) {
|
||||
if ($httpdStore.state === "authenticated") {
|
||||
if ($authenticated) {
|
||||
const status = await updatePatch(
|
||||
project.id,
|
||||
patch.id,
|
||||
@@ -124,9 +220,11 @@
|
||||
revision: revisionId,
|
||||
comment: id,
|
||||
reaction,
|
||||
active: nids.includes($httpdStore.session.publicKey) ? false : true,
|
||||
active: nids.includes($authenticated.session.publicKey)
|
||||
? false
|
||||
: true,
|
||||
},
|
||||
$httpdStore.session,
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
@@ -135,15 +233,16 @@
|
||||
}
|
||||
}
|
||||
async function createComment(commentBody: string) {
|
||||
if (
|
||||
$httpdStore.state === "authenticated" &&
|
||||
commentBody.trim().length > 0
|
||||
) {
|
||||
if ($authenticated && commentBody.trim().length > 0) {
|
||||
const status = await updatePatch(
|
||||
project.id,
|
||||
patch.id,
|
||||
{ type: "revision.comment", body: commentBody, revision: revisionId },
|
||||
$httpdStore.session,
|
||||
{
|
||||
type: "revision.comment",
|
||||
body: commentBody,
|
||||
revision: revisionId,
|
||||
},
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
@@ -151,13 +250,37 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function editComment({
|
||||
detail: { id, body },
|
||||
}: CustomEvent<{ id: string; body: string }>) {
|
||||
if ($authenticated && body.trim().length > 0) {
|
||||
const status = await updatePatch(
|
||||
project.id,
|
||||
patch.id,
|
||||
{
|
||||
type: "revision.comment.edit",
|
||||
comment: id,
|
||||
body,
|
||||
revision: revisionId,
|
||||
embeds: [],
|
||||
},
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
patch = await api.project.getPatchById(project.id, patch.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStatus({ detail: state }: CustomEvent<PatchState>) {
|
||||
if ($httpdStore.state === "authenticated" && state.status !== "merged") {
|
||||
if ($authenticated && state.status !== "merged") {
|
||||
const status = await updatePatch(
|
||||
project.id,
|
||||
patch.id,
|
||||
{ type: "lifecycle", state },
|
||||
$httpdStore.session,
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
@@ -170,22 +293,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
function badgeColor(status: string): ComponentProps<Badge>["variant"] {
|
||||
if (status === "draft") {
|
||||
return "foreground";
|
||||
} else if (status === "open") {
|
||||
return "positive";
|
||||
} else if (status === "archived") {
|
||||
return "caution";
|
||||
} else if (status === "merged") {
|
||||
return "primary";
|
||||
} else {
|
||||
return "foreground";
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLabels({ detail: labels }: CustomEvent<string[]>) {
|
||||
if ($httpdStore.state === "authenticated") {
|
||||
if ($authenticated) {
|
||||
if (isEqual(patch.labels, labels)) {
|
||||
return;
|
||||
}
|
||||
@@ -200,7 +310,7 @@
|
||||
project.id,
|
||||
revision,
|
||||
{ type: "label", labels: labels },
|
||||
$httpdStore.session,
|
||||
$authenticated.session,
|
||||
api,
|
||||
);
|
||||
if (status === "success") {
|
||||
@@ -237,11 +347,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: action = (
|
||||
$httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
|
||||
? "edit"
|
||||
: "view"
|
||||
) as "edit" | "view";
|
||||
function badgeColor(status: string): ComponentProps<Badge>["variant"] {
|
||||
if (status === "draft") {
|
||||
return "foreground";
|
||||
} else if (status === "open") {
|
||||
return "positive";
|
||||
} else if (status === "archived") {
|
||||
return "caution";
|
||||
} else if (status === "merged") {
|
||||
return "primary";
|
||||
} else {
|
||||
return "foreground";
|
||||
}
|
||||
}
|
||||
|
||||
type Tab = "activity" | "changes";
|
||||
|
||||
@@ -292,6 +410,7 @@
|
||||
return patchReviews;
|
||||
}
|
||||
|
||||
let editingDescription = false;
|
||||
let revisionId: string;
|
||||
$: if (view.name === "diff") {
|
||||
revisionId = patch.revisions[patch.revisions.length - 1].id;
|
||||
@@ -299,6 +418,8 @@
|
||||
revisionId = view.revision;
|
||||
}
|
||||
|
||||
$: description = patch.revisions[0].description;
|
||||
$: newDescription = description;
|
||||
$: patchReviews = computeReviews(patch);
|
||||
$: selectedItem = patch.state.status === "open" ? items[1] : items[0];
|
||||
$: timelineTuple = patch.revisions.map<
|
||||
@@ -349,6 +470,9 @@
|
||||
})),
|
||||
].sort((a, b) => a.timestamp - b.timestamp),
|
||||
]);
|
||||
|
||||
let saveDescriptionInProgress = false;
|
||||
let saveTitleInProgress = false;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -417,6 +541,16 @@
|
||||
.review-reject {
|
||||
color: var(--color-foreground-red);
|
||||
}
|
||||
.revision-description {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
.edit-buttons {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
.diff-button-range {
|
||||
font-family: var(--font-family-monospace);
|
||||
font-weight: var(--font-weight-bold);
|
||||
@@ -444,7 +578,12 @@
|
||||
<Layout {baseUrl} {project} {tracking} activeTab="patches">
|
||||
<div class="patch">
|
||||
<div>
|
||||
<CobHeader id={patch.id} title={patch.title}>
|
||||
<CobHeader
|
||||
id={patch.id}
|
||||
title={patch.title}
|
||||
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
|
||||
submitInProgress={saveTitleInProgress}
|
||||
on:editTitle={editTitle}>
|
||||
<svelte:fragment slot="icon">
|
||||
<div
|
||||
class="state"
|
||||
@@ -461,17 +600,36 @@
|
||||
</Badge>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="description">
|
||||
{#if patch.revisions[0].description}
|
||||
<Markdown
|
||||
content={patch.revisions[0].description}
|
||||
rawPath={utils.getRawBasePath(
|
||||
project.id,
|
||||
baseUrl,
|
||||
patch.revisions[0].id,
|
||||
)} />
|
||||
{:else}
|
||||
<span class="txt-missing">No description available</span>
|
||||
{/if}
|
||||
<div class="revision-description">
|
||||
{#if $authenticatedLocal(baseUrl.hostname) && editingDescription}
|
||||
<ExtendedTextarea
|
||||
body={newDescription}
|
||||
submitCaption="Save"
|
||||
submitInProgress={saveDescriptionInProgress}
|
||||
placeholder="Leave your description"
|
||||
on:close={() => (editingDescription = false)}
|
||||
on:submit={editDescription} />
|
||||
{:else if description}
|
||||
<Markdown
|
||||
content={description}
|
||||
rawPath={utils.getRawBasePath(
|
||||
project.id,
|
||||
baseUrl,
|
||||
patch.revisions[0].id,
|
||||
)} />
|
||||
{:else}
|
||||
<span class="txt-missing">No description available</span>
|
||||
{/if}
|
||||
{#if $authenticatedLocal(baseUrl.hostname) && !editingDescription}
|
||||
<div class="edit-buttons">
|
||||
<IconButton
|
||||
title="edit description"
|
||||
on:click={() => (editingDescription = true)}>
|
||||
<IconSmall name={"edit"} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<div class="author" slot="author">
|
||||
opened by <NodeId
|
||||
@@ -610,6 +768,7 @@
|
||||
projectHead={project.head}
|
||||
{...revision}
|
||||
first={index === 0}
|
||||
on:editComment={editComment}
|
||||
on:react={event => handleReaction(revisionId, event)}
|
||||
on:reply={createReply}
|
||||
patchId={patch.id}
|
||||
@@ -619,7 +778,8 @@
|
||||
{#if index === patch.revisions.length - 1}
|
||||
{#if $httpdStore.state === "authenticated" && view.name === "activity"}
|
||||
<div class="connector" />
|
||||
<CommentTextarea
|
||||
<CommentToggleInput
|
||||
placeholder="Leave your comment"
|
||||
on:submit={async event => {
|
||||
await createComment(event.detail.comment);
|
||||
}} />
|
||||
@@ -689,7 +849,10 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<LabelInput {action} labels={patch.labels} on:save={saveLabels} />
|
||||
<LabelInput
|
||||
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
|
||||
labels={patch.labels}
|
||||
on:save={saveLabels} />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -115,7 +115,7 @@ test("navigate line numbers", async ({ page }) => {
|
||||
// Check that we go back to the Markdown view when navigating to a different
|
||||
// file.
|
||||
await page.getByRole("link", { name: "footnotes.md" }).click();
|
||||
await expect(page.getByRole("button", { name: "Plain" })).toHaveClass(
|
||||
await expect(page.getByRole("button", { name: "Markdown" })).toHaveClass(
|
||||
/secondary/,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user