Add revision commit list

This commit is contained in:
Rūdolfs Ošiņš
2025-02-04 15:49:00 -06:00
parent f34522b689
commit 014a3e9eee
11 changed files with 500 additions and 179 deletions
+4 -5
View File
@@ -42,9 +42,8 @@ pub async fn diff_stats(
pub async fn list_commits(
ctx: tauri::State<'_, AppState>,
rid: RepoId,
parent: Option<String>,
skip: Option<usize>,
take: Option<usize>,
) -> Result<types::cobs::PaginatedQuery<Vec<types::repo::Commit>>, Error> {
ctx.list_commits(rid, parent, skip, take)
base: String,
head: String,
) -> Result<Vec<types::repo::Commit>, Error> {
ctx.list_commits(rid, base, head)
}
+14 -146
View File
@@ -210,158 +210,26 @@ pub trait Repo: Profile {
fn list_commits(
&self,
rid: identity::RepoId,
parent: Option<String>,
skip: Option<usize>,
take: Option<usize>,
) -> Result<cobs::PaginatedQuery<Vec<repo::Commit>>, Error> {
base: String,
head: String,
) -> Result<Vec<repo::Commit>, Error> {
let profile = self.profile();
let cursor = skip.unwrap_or(0);
let take = take.unwrap_or(20);
let repo = profile.storage.repository(rid)?;
let sha = match parent {
Some(commit) => commit,
None => repo.head()?.1.to_string(),
};
let repo = surf::Repository::open(repo.path())?;
let history = repo.history(&sha)?;
let history = repo.history(&head)?;
let mut commits = history
let commits = history
.take_while(|c| {
if let Ok(c) = c {
c.id.to_string() != base
} else {
false
}
})
.filter_map(|c| c.map(Into::into).ok())
.skip(cursor)
.take(take + 1); // Take one extra item to check if there's more.
.collect();
let paginated_commits: Vec<_> = commits.by_ref().take(take).collect();
let more = commits.next().is_some();
Ok::<_, Error>(cobs::PaginatedQuery {
cursor,
more,
content: paginated_commits.to_vec(),
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
use std::str::FromStr;
use std::vec;
use radicle::crypto::test::signer::MockSigner;
use radicle::{git, test};
use radicle_surf::Author;
use crate::cobs;
use crate::repo;
use crate::traits::repo::Repo;
use crate::AppState;
#[test]
fn list_commits_pagination() {
let signer = MockSigner::from_seed([0xff; 32]);
let tempdir = tempfile::tempdir().unwrap();
let profile = crate::test::profile(tempdir.path(), [0xff; 32]);
let (rid, _, _, _) =
test::fixtures::project(tempdir.path().join("original"), &profile.storage, &signer)
.unwrap();
let state = AppState { profile };
let commits = Repo::list_commits(&state, rid, None, None, Some(1)).unwrap();
assert_eq!(
commits,
cobs::PaginatedQuery {
cursor: 0,
more: true,
content: vec![repo::Commit {
id: git::Oid::from_str("f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354").unwrap(),
author: Author {
name: "anonymous".to_string(),
email: "anonymous@radicle.xyz".to_string(),
time: radicle::git::raw::Time::new(1514817556, 0).into(),
},
committer: Author {
name: "anonymous".to_string(),
email: "anonymous@radicle.xyz".to_string(),
time: radicle::git::raw::Time::new(1514817556, 0).into(),
},
message: "Second commit".to_string(),
summary: "Second commit".to_string(),
parents: vec![
git::Oid::from_str("08c788dd1be6315de09e3fe09b5b1b7a2b8711d9").unwrap()
],
}],
}
);
let commits = Repo::list_commits(&state, rid, None, Some(1), None).unwrap();
assert_eq!(
commits,
cobs::PaginatedQuery {
cursor: 1,
more: false,
content: vec![repo::Commit {
id: git::Oid::from_str("08c788dd1be6315de09e3fe09b5b1b7a2b8711d9").unwrap(),
author: Author {
name: "anonymous".to_string(),
email: "anonymous@radicle.xyz".to_string(),
time: radicle::git::raw::Time::new(1514817556, 0).into(),
},
committer: Author {
name: "anonymous".to_string(),
email: "anonymous@radicle.xyz".to_string(),
time: radicle::git::raw::Time::new(1514817556, 0).into(),
},
message: "Initial commit".to_string(),
summary: "Initial commit".to_string(),
parents: vec![],
}],
}
);
}
#[test]
fn list_commits_with_head() {
let signer = MockSigner::from_seed([0xff; 32]);
let tempdir = tempfile::tempdir().unwrap();
let profile = crate::test::profile(tempdir.path(), [0xff; 32]);
let (rid, _, _, _) =
test::fixtures::project(tempdir.path().join("original"), &profile.storage, &signer)
.unwrap();
let state = AppState { profile };
let commits = Repo::list_commits(
&state,
rid,
Some("08c788dd1be6315de09e3fe09b5b1b7a2b8711d9".to_string()),
None,
None,
)
.unwrap();
assert_eq!(
commits,
cobs::PaginatedQuery {
cursor: 0,
more: false,
content: vec![repo::Commit {
id: git::Oid::from_str("08c788dd1be6315de09e3fe09b5b1b7a2b8711d9").unwrap(),
author: Author {
name: "anonymous".to_string(),
email: "anonymous@radicle.xyz".to_string(),
time: radicle::git::raw::Time::new(1514817556, 0).into(),
},
committer: Author {
name: "anonymous".to_string(),
email: "anonymous@radicle.xyz".to_string(),
time: radicle::git::raw::Time::new(1514817556, 0).into(),
},
message: "Initial commit".to_string(),
summary: "Initial commit".to_string(),
parents: vec![],
}],
}
);
Ok(commits)
}
}
+48
View File
@@ -25,6 +25,7 @@
"@tauri-apps/cli": "^2.1.0",
"@tsconfig/svelte": "^5.0.4",
"@types/lodash": "^4.17.13",
"@types/md5": "^2.3.5",
"@types/node": "^22.10.2",
"@types/wait-on": "^5.3.4",
"@wooorm/starry-night": "^3.5.0",
@@ -46,6 +47,7 @@
"marked-footnote": "^1.2.4",
"marked-katex-extension": "^5.1.3",
"marked-linkify-it": "^3.1.12",
"md5": "^2.3.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"svelte": "^5.14.0",
@@ -1373,6 +1375,13 @@
"integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==",
"dev": true
},
"node_modules/@types/md5": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.5.tgz",
"integrity": "sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.10.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
@@ -1972,6 +1981,16 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/charenc": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": "*"
}
},
"node_modules/check-error": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
@@ -2056,6 +2075,16 @@
"node": ">= 8"
}
},
"node_modules/crypt": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": "*"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2867,6 +2896,13 @@
"node": ">=0.8.19"
}
},
"node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
"dev": true,
"license": "MIT"
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
@@ -3197,6 +3233,18 @@
"marked": ">=4 <16"
}
},
"node_modules/md5": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"charenc": "0.0.2",
"crypt": "0.0.2",
"is-buffer": "~1.1.6"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+2
View File
@@ -40,6 +40,7 @@
"@tauri-apps/cli": "^2.1.0",
"@tsconfig/svelte": "^5.0.4",
"@types/lodash": "^4.17.13",
"@types/md5": "^2.3.5",
"@types/node": "^22.10.2",
"@types/wait-on": "^5.3.4",
"@wooorm/starry-night": "^3.5.0",
@@ -61,6 +62,7 @@
"marked-footnote": "^1.2.4",
"marked-katex-extension": "^5.1.3",
"marked-linkify-it": "^3.1.12",
"md5": "^2.3.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"svelte": "^5.14.0",
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import type { Commit } from "@bindings/repo/Commit";
import { twemoji } from "@app/lib/utils";
import CompactCommitAuthorship from "@app/components/CompactCommitAuthorship.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import InlineTitle from "@app/components/InlineTitle.svelte";
import NakedButton from "@app/components/NakedButton.svelte";
interface Props {
commit: Commit;
}
const { commit }: Props = $props();
let commitMessageVisible = $state(false);
</script>
<style>
.teaser {
display: flex;
font-size: var(--font-size-small);
align-items: start;
padding: 0.125rem 0;
}
.message {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.left {
display: flex;
gap: 0.5rem;
padding: 0 0.5rem;
flex-direction: column;
}
.right {
display: flex;
align-items: center;
gap: 1rem;
margin-left: auto;
height: 21px;
}
.commit-message {
font-size: var(--font-size-tiny);
}
.commit-expand-button {
height: 21px;
display: flex;
align-items: center;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>
<div class="teaser" aria-label="commit-teaser">
<div class="left">
<div class="message">
<div class="summary" use:twemoji>
<InlineTitle fontSize="small" content={commit.summary} />
</div>
{#if commit.message.trim() !== commit.summary.trim()}
<div class="commit-expand-button">
<NakedButton
stylePadding="0 4px"
variant="ghost"
onclick={() => {
commitMessageVisible = !commitMessageVisible;
}}>
<Icon name="ellipsis" />
</NakedButton>
</div>
{/if}
</div>
{#if commitMessageVisible}
<div class="commit-message">
<pre>{commit.message.replace(commit.summary, "").trim()}</pre>
</div>
{/if}
</div>
<div class="right">
<CompactCommitAuthorship {commit}>
<Id id={commit.id} variant="commit" />
</CompactCommitAuthorship>
</div>
</div>
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Border from "./Border.svelte";
import Icon from "./Icon.svelte";
import NakedButton from "./NakedButton.svelte";
interface Props {
leftHeader: Snippet;
children: Snippet;
expanded: boolean;
}
/* eslint-disable prefer-const */
let { leftHeader, children, expanded }: Props = $props();
/* eslint-enable prefer-const */
</script>
<style>
.header {
display: flex;
align-items: center;
height: 2rem;
padding-left: 0.25rem;
font-size: var(--font-size-small);
background-color: var(--color-background-default);
}
.left {
display: flex;
gap: 0.5rem;
align-items: center;
}
.divider {
width: calc(100% + 4px);
position: relative;
top: -6px;
left: -2px;
z-index: 1;
height: 2px;
background-color: var(--color-fill-ghost);
}
</style>
<Border
variant="ghost"
styleFlexDirection="column"
styleAlignItems="flex-start">
<div class="header" class:collapsed={!expanded}>
<div class="left">
<NakedButton
stylePadding="0 4px"
variant="ghost"
onclick={async () => {
expanded = !expanded;
}}>
<Icon name={expanded ? "chevron-down" : "chevron-right"} />
</NakedButton>
{@render leftHeader()}
</div>
</div>
{#if expanded}
<div class="divider"></div>
<div style:width="100%">
{@render children()}
</div>
{/if}
</Border>
@@ -0,0 +1,106 @@
<script lang="ts">
import type { Commit } from "@bindings/repo/Commit";
import type { Snippet } from "svelte";
import * as utils from "@app/lib/utils";
import HoverPopover from "./HoverPopover.svelte";
interface Props {
children: Snippet;
commit: Commit;
}
const { children, commit }: Props = $props();
</script>
<style>
.authorship {
display: flex;
font-size: var(--font-size-small);
column-gap: 0.5rem;
align-items: center;
white-space: nowrap;
}
.person {
display: flex;
align-items: center;
flex-wrap: nowrap;
white-space: nowrap;
gap: 0.5rem;
font-family: var(--font-family-monospace);
font-weight: var(--font-weight-semibold);
}
.label {
font-family: var(--font-family-sans-serif);
font-weight: var(--font-weight-regular);
color: var(--color-foreground-dim);
}
.avatar {
width: 1rem;
height: 1rem;
clip-path: var(--1px-corner-fill);
}
</style>
<div class="authorship">
<HoverPopover
stylePopoverPositionLeft="-8rem"
stylePopoverPositionBottom="1.5rem">
{#snippet toggle()}
<div style="display: flex;">
{#if commit.author.email === commit.committer.email}
<img
class="avatar"
alt="avatar"
src={utils.gravatarURL(commit.committer.email)} />
{:else}
<img
style:margin-right="0.25rem"
class="avatar"
alt="avatar"
src={utils.gravatarURL(commit.author.email)} />
<img
class="avatar"
alt="avatar"
src={utils.gravatarURL(commit.committer.email)} />
{/if}
</div>
{/snippet}
{#snippet popover()}
<div class="popover">
{#if commit.author.email === commit.committer.email}
<div class="person">
<div class="label">Author</div>
<img
class="avatar"
alt="avatar"
src={utils.gravatarURL(commit.committer.email)} />
{commit.author.name}
</div>
{:else}
<div class="person">
<div class="label">Author</div>
<img
class="avatar"
alt="avatar"
src={utils.gravatarURL(commit.author.email)} />
{commit.author.name}
</div>
<div class="person">
<div class="label">Committer</div>
<img
class="avatar"
alt="avatar"
src={utils.gravatarURL(commit.committer.email)} />
{commit.committer.name}
</div>
{/if}
</div>
{/snippet}
</HoverPopover>
{@render children()}
<div title={utils.absoluteTimestamp(commit.committer.time * 1000)}>
{utils.formatTimestamp(commit.committer.time * 1000)}
</div>
</div>
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import type { Snippet } from "svelte";
import debounce from "lodash/debounce";
interface Props {
popover: Snippet;
stylePopoverPositionBottom: string | undefined;
stylePopoverPositionLeft: string | undefined;
toggle: Snippet;
}
const {
popover,
stylePopoverPositionBottom,
stylePopoverPositionLeft,
toggle,
}: Props = $props();
let visible: boolean = $state(false);
const setVisible = debounce((value: boolean) => {
visible = value;
}, 50);
</script>
<style>
.container {
position: relative;
display: inline-block;
}
.popover {
background: var(--color-fill-ghost);
border-radius: var(--border-radius-regular);
padding: 0.5rem 1rem;
box-shadow: var(--elevation-low);
position: absolute;
clip-path: var(--2px-corner-fill);
z-index: 10;
}
</style>
<div class="container">
<div
role="button"
tabindex="0"
onmouseenter={() => setVisible(true)}
onmouseleave={() => setVisible(false)}>
{@render toggle()}
{#if visible}
<div style:position="absolute">
<div
class="popover"
style:left={stylePopoverPositionLeft}
style:bottom={stylePopoverPositionBottom}>
{@render popover()}
</div>
</div>
{/if}
</div>
</div>
+5
View File
@@ -29,6 +29,7 @@
| "dashboard"
| "delegate"
| "diff"
| "ellipsis"
| "expand"
| "expand-panel"
| "eye"
@@ -432,6 +433,10 @@
<path d="M7 4H8V9H7V4Z" />
<path d="M5 6H10V7H5V6Z" />
<path d="M5 10H10V11H5V10Z" />
{:else if name === "ellipsis"}
<path d="M2 7H4V9H2V7Z" />
<path d="M7 7H9V9H7V7Z" />
<path d="M12 7H14V9H12V7Z" />
{:else if name === "expand"}
<path d="M9 1.5H8V2.5H9V1.5Z" />
<path d="M10 2.5H9V3.5H10V2.5Z" />
+90 -28
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Author } from "@bindings/cob/Author";
import type { Commit } from "@bindings/repo/Commit";
import type { Config } from "@bindings/config/Config";
import type { Diff } from "@bindings/diff/Diff";
import type { Embed } from "@bindings/cob/thread/Embed";
@@ -16,9 +17,12 @@
import { publicKeyFromDid, scrollIntoView } from "@app/lib/utils";
import Changeset from "@app/components/Changeset.svelte";
import CobCommitTeaser from "./CobCommitTeaser.svelte";
import CommentComponent from "@app/components/Comment.svelte";
import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
import CommitsContainer from "@app/components/CommitsContainer.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "./Id.svelte";
import NakedButton from "./NakedButton.svelte";
import ReviewTeaser from "@app/components/ReviewTeaser.svelte";
import ThreadComponent from "@app/components/Thread.svelte";
@@ -226,6 +230,14 @@
},
});
}
async function loadCommits(rid: string, base: string, head: string) {
return invoke<Commit[]>("list_commits", {
rid,
base,
head,
});
}
</script>
<style>
@@ -255,6 +267,32 @@
margin-left: 1.25rem;
background-color: var(--color-background-float);
}
.commits {
position: relative;
display: flex;
flex-direction: column;
font-size: 0.875rem;
margin-left: 0.5rem;
gap: 0.5rem;
padding: 1rem 0.5rem 0.5rem 1rem;
border-left: 1px solid var(--color-fill-separator);
}
.commit:last-of-type::after {
content: "";
position: absolute;
left: -18.5px;
top: 14px;
bottom: -0.5rem;
border-left: 4px solid var(--color-background-default);
}
.commit-dot {
width: 4px;
height: 4px;
position: absolute;
top: 0.625rem;
left: -18.5px;
background-color: var(--color-fill-separator);
}
</style>
<div class="txt-small patch-body">
@@ -282,15 +320,14 @@
</div>
<div style:margin="1rem 0">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
tabindex="0"
class="txt-semibold global-flex"
style:margin-bottom="1rem"
style:cursor="pointer"
onclick={() => (hideDiscussion = !hideDiscussion)}>
<Icon name={hideDiscussion ? "chevron-right" : "chevron-down"} />Discussion
<div class="global-flex" style:margin-bottom="1rem">
<NakedButton
stylePadding="0 4px"
variant="ghost"
onclick={() => (hideDiscussion = !hideDiscussion)}>
<Icon name={hideDiscussion ? "chevron-right" : "chevron-down"} />
<div class="txt-semibold global-flex txt-regular">Discussion</div>
</NakedButton>
</div>
<div class:hide={hideDiscussion}>
{#each threads as thread}
@@ -325,17 +362,16 @@
{#if revision.reviews && revision.reviews.length}
<div style:margin="1rem 0">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
tabindex="0"
class="txt-semibold global-flex"
style:margin-bottom="1rem"
style:cursor="pointer"
onclick={() => (hideReviews = !hideReviews)}>
<Icon name={hideReviews ? "chevron-right" : "chevron-down"} />Reviews
<div class="global-flex" style:margin-bottom="1rem">
<NakedButton
stylePadding="0 4px"
variant="ghost"
onclick={() => (hideReviews = !hideReviews)}>
<Icon name={hideReviews ? "chevron-right" : "chevron-down"} />
<div class="txt-semibold global-flex txt-regular">Reviews</div>
</NakedButton>
</div>
<div class:hide={hideReviews}>
<div class:hide={hideReviews} style:margin-top="1rem">
{#each revision.reviews as review}
<ReviewTeaser {rid} {review} />
{/each}
@@ -343,19 +379,16 @@
</div>
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="txt-semibold global-flex"
style:margin-bottom={hideChanges ? undefined : "1rem"}>
<div
style:cursor="pointer"
style:min-height="2rem"
class="global-flex"
role="button"
tabindex="0"
<NakedButton
stylePadding="0 4px"
variant="ghost"
onclick={() => (hideChanges = !hideChanges)}>
<Icon name={hideChanges ? "chevron-right" : "chevron-down"} />Changes
</div>
<Icon name={hideChanges ? "chevron-right" : "chevron-down"} />
<div class="txt-semibold global-flex txt-regular">Changes</div>
</NakedButton>
{#if !hideChanges}
<div style:margin-left="auto">
<NakedButton
@@ -372,7 +405,36 @@
</div>
{/if}
</div>
<div class:hide={hideChanges}>
{#await loadCommits(rid, revision.base, revision.head) then commits}
<div style:margin-bottom="1rem">
<CommitsContainer expanded={filesExpanded}>
{#snippet leftHeader()}
<div class="txt-semibold">Commits</div>
{/snippet}
{#snippet children()}
<div style:padding="0 1rem">
<div
class="global-flex txt-small"
style:color="var(--color-foreground-dim)">
<Icon name="branch" /><Id id={revision.base} variant="commit" />
<div class="global-counter">base</div>
</div>
<div class="commits">
{#each commits.reverse() as commit}
<div class="commit" style:position="relative">
<div class="commit-dot"></div>
<CobCommitTeaser {commit} />
</div>
{/each}
</div>
</div>
{/snippet}
</CommitsContainer>
</div>
{/await}
{#await loadHighlightedDiff(rid, revision.base, revision.head)}
<span class="txt-small">Loading…</span>
{:then diff}
+9
View File
@@ -6,6 +6,7 @@ import type { Patch } from "@bindings/cob/patch/Patch";
import bs58 from "bs58";
import twemojiModule from "twemoji";
import md5 from "md5";
import NodeId from "@app/components/NodeId.svelte";
@@ -211,3 +212,11 @@ export function parseNodeId(
return undefined;
}
// Get the gravatar URL of an email.
export function gravatarURL(email: string): string {
const address = email.trim().toLowerCase();
const hash = md5(address);
return `https://www.gravatar.com/avatar/${hash}`;
}