Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Surface artifact peer details and blur trusted redactions
Daniel Norman committed 7 days ago
commit fdb11f2c6b1156904a9685783a0d8efcb51f67c8
parent 6260258aa5ac000e6ca9c30fe45a3375f9da1d17
1 file changed +285 -92
modified src/views/repo/Release.svelte
@@ -68,6 +68,31 @@
    );
  }

+
  type PeerRole = "author" | "delegate" | "other";
+

+
  // Classify a peer DID relative to a specific artifact so the UI can
+
  // visually rank attestations / locations / redactions by trust. The
+
  // artifact author wins over delegate if both apply.
+
  function peerRole(did: string, artifact: Artifact): PeerRole {
+
    if (did === artifact.author.did) return "author";
+
    if (delegateDids.includes(did)) return "delegate";
+
    return "other";
+
  }
+

+
  // Redactions written by the artifact's author or a repo delegate are
+
  // treated as authoritative — we blur the artifact body and surface the
+
  // reason so users can still read why it was withdrawn.
+
  function trustedRedactions(artifact: Artifact) {
+
    return artifact.redactions.filter(
+
      r => peerRole(r.peer.did, artifact) !== "other",
+
    );
+
  }
+

+
  // Reveal toggles so the locations/attestations sections stay tucked
+
  // away by default but can be expanded per artifact.
+
  const revealLocations = $state<Record<string, boolean>>({});
+
  const revealAttestations = $state<Record<string, boolean>>({});
+

  const draft = $state<Record<string, { key: string; value: string }>>({});
  $effect(() => {
    for (const a of release.artifacts) {
@@ -484,6 +509,91 @@
    color: var(--color-text-secondary);
    text-align: center;
  }
+
  .reveal {
+
    background: none;
+
    border: none;
+
    padding: 0;
+
    cursor: pointer;
+
    font: var(--txt-body-s-regular);
+
    color: var(--color-text-secondary);
+
    text-decoration: underline dotted;
+
  }
+
  .reveal:hover {
+
    color: var(--color-text-primary);
+
  }
+
  .peer-list {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.375rem;
+
    margin-top: 0.5rem;
+
    padding: 0.5rem 0.75rem;
+
    background-color: var(--color-surface-canvas);
+
    border: 1px solid var(--color-border-subtle);
+
    border-radius: var(--border-radius-sm);
+
  }
+
  .peer-row {
+
    display: flex;
+
    align-items: flex-start;
+
    flex-wrap: wrap;
+
    gap: 0.5rem;
+
    font: var(--txt-body-s-regular);
+
  }
+
  .role-badge {
+
    display: inline-flex;
+
    align-items: center;
+
    height: 1.25rem;
+
    padding: 0 0.375rem;
+
    border-radius: var(--border-radius-sm);
+
    font: var(--txt-body-s-regular);
+
    border: 1px solid var(--color-border-subtle);
+
    color: var(--color-text-secondary);
+
  }
+
  .role-badge.author {
+
    background-color: var(--color-feedback-success-bg);
+
    border-color: var(--color-feedback-success-border);
+
    color: var(--color-feedback-success-text);
+
  }
+
  .role-badge.delegate {
+
    background-color: var(--color-feedback-warning-bg);
+
    border-color: var(--color-feedback-warning-border);
+
    color: var(--color-feedback-warning-text);
+
  }
+
  .peer-url-list {
+
    margin: 0;
+
    padding-left: 1rem;
+
    width: 100%;
+
    color: var(--color-text-secondary);
+
    word-break: break-all;
+
  }
+
  .reason {
+
    color: var(--color-text-primary);
+
    margin-left: 0.25rem;
+
  }
+
  .redactions {
+
    margin-top: 0.5rem;
+
    padding: 0.5rem 0.75rem;
+
    background-color: var(--color-feedback-error-bg);
+
    border: 1px solid var(--color-feedback-error-border);
+
    border-radius: var(--border-radius-sm);
+
    color: var(--color-feedback-error-text);
+
  }
+
  .redactions-title {
+
    font: var(--txt-body-s-semibold);
+
    margin-bottom: 0.25rem;
+
  }
+
  .redaction-row {
+
    display: flex;
+
    flex-wrap: wrap;
+
    align-items: center;
+
    gap: 0.375rem;
+
    font: var(--txt-body-s-regular);
+
  }
+
  .blur {
+
    filter: blur(4px);
+
    pointer-events: none;
+
    user-select: none;
+
    opacity: 0.6;
+
  }
</style>

<Layout>
@@ -536,110 +646,193 @@
      </div>

      {#each release.artifacts as artifact (artifact.cid)}
+
        {@const trusted = trustedRedactions(artifact)}
        <div class="artifact">
-
          <div class="artifact-row">
-
            <span class="name">{artifact.name}</span>
-
            <span class="kind">[{artifact.kind}]</span>
-
            {#if isAvailableLocally(artifact)}
-
              <span class="pill available" title="Available in local store">
-
                <Icon name="checkmark" />
-
                Local
-
              </span>
-
            {/if}
-
            <div class="actions">
-
              <button
-
                onclick={() => download(artifact)}
-
                disabled={busy[artifact.cid] || artifact.locations.length === 0}
-
                title={artifact.locations.length === 0
-
                  ? "No locations to download from"
-
                  : "Download to disk"}>
-
                Download
-
              </button>
-
              {#if isShared(artifact)}
+
          {#if trusted.length > 0}
+
            <div class="redactions">
+
              <div class="redactions-title">
+
                Redacted by {trusted.length === 1
+
                  ? "the artifact author or a delegate"
+
                  : "delegates / the artifact author"}
+
              </div>
+
              {#each trusted as redaction (redaction.peer.did)}
+
                <div class="redaction-row">
+
                  <NodeId {...authorForNodeId(redaction.peer)} />
+
                  <span
+
                    class="role-badge {peerRole(redaction.peer.did, artifact)}">
+
                    {peerRole(redaction.peer.did, artifact)}
+
                  </span>
+
                  <span class="reason">{redaction.reason}</span>
+
                </div>
+
              {/each}
+
            </div>
+
          {/if}
+
          <div class:blur={trusted.length > 0}>
+
            <div class="artifact-row">
+
              <span class="name">{artifact.name}</span>
+
              <span class="kind">[{artifact.kind}]</span>
+
              {#if isAvailableLocally(artifact)}
+
                <span class="pill available" title="Available in local store">
+
                  <Icon name="checkmark" />
+
                  Local
+
                </span>
+
              {/if}
+
              <div class="actions">
                <button
-
                  onclick={() => unseed(artifact)}
-
                  disabled={busy[artifact.cid]}>
-
                  Unseed
+
                  onclick={() => download(artifact)}
+
                  disabled={busy[artifact.cid] ||
+
                    artifact.locations.length === 0}
+
                  title={artifact.locations.length === 0
+
                    ? "No locations to download from"
+
                    : "Download to disk"}>
+
                  Download
                </button>
-
              {:else}
+
                {#if isShared(artifact)}
+
                  <button
+
                    onclick={() => unseed(artifact)}
+
                    disabled={busy[artifact.cid]}>
+
                    Unseed
+
                  </button>
+
                {:else}
+
                  <button
+
                    onclick={() => seed(artifact)}
+
                    disabled={busy[artifact.cid]}>
+
                    Seed
+
                  </button>
+
                {/if}
                <button
-
                  onclick={() => seed(artifact)}
+
                  onclick={() => attest(artifact)}
                  disabled={busy[artifact.cid]}>
-
                  Seed
+
                  Attest
                </button>
-
              {/if}
+
                {#if canEditMetadata(artifact)}
+
                  <button
+
                    class="danger"
+
                    onclick={() => redact(artifact)}
+
                    disabled={busy[artifact.cid]}>
+
                    Redact
+
                  </button>
+
                {/if}
+
              </div>
+
            </div>
+
            <div style:margin-top="0.375rem">
+
              <Id id={artifact.cid} clipboard={artifact.cid} shorten={false} />
+
            </div>
+
            <div class="meta-line">
              <button
-
                onclick={() => attest(artifact)}
-
                disabled={busy[artifact.cid]}>
-
                Attest
+
                class="reveal"
+
                onclick={() =>
+
                  (revealAttestations[artifact.cid] =
+
                    !revealAttestations[artifact.cid])}
+
                disabled={artifact.attestations.length === 0}>
+
                {artifact.attestations.length} attestations
              </button>
-
              {#if canEditMetadata(artifact)}
-
                <button
-
                  class="danger"
-
                  onclick={() => redact(artifact)}
-
                  disabled={busy[artifact.cid]}>
-
                  Redact
-
                </button>
+
              <button
+
                class="reveal"
+
                onclick={() =>
+
                  (revealLocations[artifact.cid] =
+
                    !revealLocations[artifact.cid])}
+
                disabled={artifact.locations.length === 0}>
+
                {artifact.locations.length} locations
+
              </button>
+
              {#if artifact.redactions.length > trusted.length}
+
                <span style:color="var(--color-feedback-error-text)">
+
                  {artifact.redactions.length - trusted.length} other redactions
+
                </span>
              {/if}
            </div>
-
          </div>
-
          <div style:margin-top="0.375rem">
-
            <Id id={artifact.cid} clipboard={artifact.cid} shorten={false} />
-
          </div>
-
          <div class="meta-line">
-
            <span>{artifact.attestations.length} attestations</span>
-
            <span>{artifact.locations.length} locations</span>
-
            {#if artifact.redactions.length > 0}
-
              <span style:color="var(--color-fill-error)">
-
                {artifact.redactions.length} redactions
-
              </span>
+
            {#if revealAttestations[artifact.cid] && artifact.attestations.length > 0}
+
              <div class="peer-list">
+
                {#each artifact.attestations as att (att.did)}
+
                  <div class="peer-row">
+
                    <NodeId {...authorForNodeId(att)} />
+
                    {#if peerRole(att.did, artifact) !== "other"}
+
                      <span class="role-badge {peerRole(att.did, artifact)}">
+
                        {peerRole(att.did, artifact)}
+
                      </span>
+
                    {/if}
+
                  </div>
+
                {/each}
+
              </div>
+
            {/if}
+
            {#if revealLocations[artifact.cid] && artifact.locations.length > 0}
+
              <div class="peer-list">
+
                {#each artifact.locations as loc (loc.peer.did)}
+
                  <div class="peer-row">
+
                    <NodeId {...authorForNodeId(loc.peer)} />
+
                    {#if peerRole(loc.peer.did, artifact) !== "other"}
+
                      <span
+
                        class="role-badge {peerRole(loc.peer.did, artifact)}">
+
                        {peerRole(loc.peer.did, artifact)}
+
                      </span>
+
                    {/if}
+
                    <ul class="peer-url-list">
+
                      {#each loc.urls as url}
+
                        <li>{url}</li>
+
                      {/each}
+
                    </ul>
+
                  </div>
+
                {/each}
+
              </div>
+
            {/if}
+
            {#if artifact.redactions.length > trusted.length}
+
              <div class="peer-list">
+
                {#each artifact.redactions.filter(r => peerRole(r.peer.did, artifact) === "other") as redaction (redaction.peer.did)}
+
                  <div class="peer-row">
+
                    <NodeId {...authorForNodeId(redaction.peer)} />
+
                    <span class="reason">{redaction.reason}</span>
+
                  </div>
+
                {/each}
+
              </div>
+
            {/if}
+
            {#if artifact.metadata && Object.keys(artifact.metadata).length > 0}
+
              <dl class="metadata">
+
                {#each Object.entries(artifact.metadata) as [key, value] (key)}
+
                  <dt>{key}</dt>
+
                  <dd>
+
                    <span class="value">
+
                      {typeof value === "string"
+
                        ? value
+
                        : JSON.stringify(value)}
+
                    </span>
+
                    {#if canEditMetadata(artifact)}
+
                      <button
+
                        class="remove-meta"
+
                        title="Remove"
+
                        onclick={() => removeMetadata(artifact, key)}
+
                        disabled={busy[artifact.cid]}>
+
                        ×
+
                      </button>
+
                    {/if}
+
                  </dd>
+
                {/each}
+
              </dl>
+
            {/if}
+
            {#if canEditMetadata(artifact) && draft[artifact.cid]}
+
              <form
+
                class="add-meta"
+
                onsubmit={e => {
+
                  e.preventDefault();
+
                  const d = draft[artifact.cid];
+
                  void setMetadata(artifact, d.key, d.value);
+
                }}>
+
                <input
+
                  type="text"
+
                  placeholder="key"
+
                  bind:value={draft[artifact.cid].key}
+
                  disabled={busy[artifact.cid]} />
+
                <input
+
                  type="text"
+
                  placeholder="value (string or JSON)"
+
                  bind:value={draft[artifact.cid].value}
+
                  disabled={busy[artifact.cid]} />
+
                <button type="submit" disabled={busy[artifact.cid]}>Add</button>
+
              </form>
+
            {/if}
+
            {#if progress[artifact.cid]}
+
              <div class="progress">{progressText(artifact.cid)}</div>
            {/if}
          </div>
-
          {#if artifact.metadata && Object.keys(artifact.metadata).length > 0}
-
            <dl class="metadata">
-
              {#each Object.entries(artifact.metadata) as [key, value] (key)}
-
                <dt>{key}</dt>
-
                <dd>
-
                  <span class="value">
-
                    {typeof value === "string" ? value : JSON.stringify(value)}
-
                  </span>
-
                  {#if canEditMetadata(artifact)}
-
                    <button
-
                      class="remove-meta"
-
                      title="Remove"
-
                      onclick={() => removeMetadata(artifact, key)}
-
                      disabled={busy[artifact.cid]}>
-
                      ×
-
                    </button>
-
                  {/if}
-
                </dd>
-
              {/each}
-
            </dl>
-
          {/if}
-
          {#if canEditMetadata(artifact) && draft[artifact.cid]}
-
            <form
-
              class="add-meta"
-
              onsubmit={e => {
-
                e.preventDefault();
-
                const d = draft[artifact.cid];
-
                void setMetadata(artifact, d.key, d.value);
-
              }}>
-
              <input
-
                type="text"
-
                placeholder="key"
-
                bind:value={draft[artifact.cid].key}
-
                disabled={busy[artifact.cid]} />
-
              <input
-
                type="text"
-
                placeholder="value (string or JSON)"
-
                bind:value={draft[artifact.cid].value}
-
                disabled={busy[artifact.cid]} />
-
              <button type="submit" disabled={busy[artifact.cid]}>Add</button>
-
            </form>
-
          {/if}
-
          {#if progress[artifact.cid]}
-
            <div class="progress">{progressText(artifact.cid)}</div>
-
          {/if}
        </div>
      {:else}
        <div class="empty">No artifacts in this release</div>