feat(notes): share an always-latest link, not just a snapshot
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
The Share button now offers two links: the pinned snapshot (default) and an "always latest" link that carries note paths in `?liveNotes` instead of blob shas. On open they resolve to the current shas against the HEAD file list and the URL is rewritten to the ordinary pinned form, so freshness, editing and the snapshot banner keep working unchanged.
This commit is contained in:
@@ -11,6 +11,7 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
|||||||
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
||||||
import { useNoteView } from "@/hooks/useNoteView.hook"
|
import { useNoteView } from "@/hooks/useNoteView.hook"
|
||||||
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
|
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
|
||||||
|
import { useResolveLiveNotes } from "@/hooks/useResolveLiveNotes.hook"
|
||||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||||
import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook"
|
import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook"
|
||||||
import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
|
import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
|
||||||
@@ -46,6 +47,10 @@ const { listenToClick } = useLinks("note-display")
|
|||||||
const { stackedNotes, scrollToFocusedNote, scrollToLastStackedNote } =
|
const { stackedNotes, scrollToFocusedNote, scrollToLastStackedNote } =
|
||||||
useRouteQueryStackedNotes()
|
useRouteQueryStackedNotes()
|
||||||
|
|
||||||
|
// A living link arrives with paths in `?liveNotes`; resolve them to the latest
|
||||||
|
// shas, then focus the deepest note just like an ordinary shared link does.
|
||||||
|
useResolveLiveNotes(() => scrollToLastStackedNote())
|
||||||
|
|
||||||
const { titles } = useNoteView()
|
const { titles } = useNoteView()
|
||||||
const { isLogged } = useGitHubLogin()
|
const { isLogged } = useGitHubLogin()
|
||||||
useResizeContainer("note-container", stackedNotes)
|
useResizeContainer("note-container", stackedNotes)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import FontChange from "@/components/FontChange.vue"
|
import FontChange from "@/components/FontChange.vue"
|
||||||
import HomeButton from "@/components/HomeButton.vue"
|
import HomeButton from "@/components/HomeButton.vue"
|
||||||
|
import ShareNote from "@/components/ShareNote.vue"
|
||||||
|
|
||||||
defineProps<{ user: string; repo: string }>()
|
defineProps<{ user: string; repo: string }>()
|
||||||
</script>
|
</script>
|
||||||
@@ -55,6 +56,7 @@ defineProps<{ user: string; repo: string }>()
|
|||||||
<path d="M5 20l6 -16l2 0l7 16" />
|
<path d="M5 20l6 -16l2 0l7 16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<share-note />
|
||||||
<router-link
|
<router-link
|
||||||
class="btn btn-ghost btn-circle"
|
class="btn btn-ghost btn-circle"
|
||||||
:to="{ name: 'FluxNoteView', params: { user, repo } }"
|
:to="{ name: 'FluxNoteView', params: { user, repo } }"
|
||||||
|
|||||||
168
src/components/ShareNote.vue
Normal file
168
src/components/ShareNote.vue
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { LocationQueryRaw, useRoute, useRouter } from "vue-router"
|
||||||
|
|
||||||
|
import { stackToLivePaths } from "@/modules/note/liveNotes"
|
||||||
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
|
import { confirmMessage, errorMessage } from "@/utils/notif"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
|
||||||
|
const stackedShas = computed<string[]>(() => {
|
||||||
|
const raw = route.query.stackedNotes
|
||||||
|
if (!raw) return []
|
||||||
|
return (Array.isArray(raw) ? raw : [raw]).filter(
|
||||||
|
(sha): sha is string => typeof sha === "string"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasStack = computed(() => stackedShas.value.length > 0)
|
||||||
|
|
||||||
|
const absoluteUrl = (query: LocationQueryRaw): string => {
|
||||||
|
const { href } = router.resolve({ path: route.path, query })
|
||||||
|
return `${window.location.origin}${href}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The exact snapshot: the current URL, with each note pinned to its blob sha.
|
||||||
|
const snapshotUrl = (): string => absoluteUrl({ ...route.query })
|
||||||
|
|
||||||
|
// The living link: the same notes referenced by path, so the recipient always
|
||||||
|
// lands on the latest version. See useResolveLiveNotes for the read side.
|
||||||
|
const livingUrl = (): string => {
|
||||||
|
const query = { ...route.query }
|
||||||
|
delete query.stackedNotes
|
||||||
|
return absoluteUrl({
|
||||||
|
...query,
|
||||||
|
liveNotes: stackToLivePaths(stackedShas.value, store.files)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () =>
|
||||||
|
(document.getElementById("share_modal") as HTMLDialogElement | null)?.close()
|
||||||
|
|
||||||
|
const copy = async (url: string, label: string) => {
|
||||||
|
close()
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
confirmMessage(`🔗 ${label} link copied`)
|
||||||
|
} catch {
|
||||||
|
errorMessage("❌ Couldn't copy the link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-circle text-base-content"
|
||||||
|
title="Share this view"
|
||||||
|
aria-label="Share this view"
|
||||||
|
onclick="share_modal.showModal()"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-share"
|
||||||
|
width="30"
|
||||||
|
height="30"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<circle cx="6" cy="12" r="3" />
|
||||||
|
<circle cx="18" cy="6" r="3" />
|
||||||
|
<circle cx="18" cy="18" r="3" />
|
||||||
|
<line x1="8.7" y1="10.7" x2="15.3" y2="7.3" />
|
||||||
|
<line x1="8.7" y1="13.3" x2="15.3" y2="16.7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<dialog id="share_modal" class="modal">
|
||||||
|
<div class="modal-box max-w-md">
|
||||||
|
<h3 class="text-lg font-bold">Share this view</h3>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="share-option"
|
||||||
|
@click="copy(snapshotUrl(), 'Snapshot')"
|
||||||
|
>
|
||||||
|
<span class="share-option-icon">📌</span>
|
||||||
|
<span class="share-option-text">
|
||||||
|
<span class="share-option-title">This exact version</span>
|
||||||
|
<span class="share-option-desc">
|
||||||
|
Pinned to what you see now — it never changes.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="hasStack"
|
||||||
|
type="button"
|
||||||
|
class="share-option"
|
||||||
|
@click="copy(livingUrl(), 'Always-latest')"
|
||||||
|
>
|
||||||
|
<span class="share-option-icon">🔄</span>
|
||||||
|
<span class="share-option-text">
|
||||||
|
<span class="share-option-title">Always latest</span>
|
||||||
|
<span class="share-option-desc">
|
||||||
|
Follows these notes — the reader gets the current version.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-ghost">Close</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.share-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid var(--color-base-300);
|
||||||
|
border-radius: var(--radius-box, 0.5rem);
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
color: var(--color-base-content);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-option-icon {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-option-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-option-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-option-desc {
|
||||||
|
font-size: 0.85em;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
50
src/hooks/useResolveLiveNotes.hook.ts
Normal file
50
src/hooks/useResolveLiveNotes.hook.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
|
||||||
|
import { resolveLivePathsToShas } from "@/modules/note/liveNotes"
|
||||||
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
|
|
||||||
|
// A living link (`?liveNotes=path&liveNotes=path`) carries file paths instead of
|
||||||
|
// pinned blob shas. On open we resolve each path to the latest sha against the
|
||||||
|
// HEAD file list and rewrite the URL to the ordinary pinned `stackedNotes` form
|
||||||
|
// — so the recipient lands on the current version, and from there everything
|
||||||
|
// (freshness, editing, the snapshot banner) behaves exactly as a shared snapshot
|
||||||
|
// would. The rewrite is a `replace`, so no history entry and no view transition.
|
||||||
|
export const useResolveLiveNotes = (onResolved?: () => void) => {
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
|
||||||
|
let resolved = false
|
||||||
|
|
||||||
|
const tryResolve = () => {
|
||||||
|
if (resolved) return
|
||||||
|
|
||||||
|
const raw = route.query.liveNotes
|
||||||
|
if (!raw) {
|
||||||
|
resolved = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the HEAD file list before resolving paths to shas.
|
||||||
|
if (store.files.length === 0) return
|
||||||
|
resolved = true
|
||||||
|
|
||||||
|
const entries = (Array.isArray(raw) ? raw : [raw]).filter(
|
||||||
|
(entry): entry is string => typeof entry === "string"
|
||||||
|
)
|
||||||
|
const shas = resolveLivePathsToShas(entries, store.files)
|
||||||
|
|
||||||
|
const query = { ...route.query }
|
||||||
|
delete query.liveNotes
|
||||||
|
if (shas.length) {
|
||||||
|
query.stackedNotes = shas
|
||||||
|
} else {
|
||||||
|
delete query.stackedNotes
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace({ path: route.path, query }).then(() => onResolved?.())
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => store.files.length, tryResolve, { immediate: true })
|
||||||
|
}
|
||||||
62
src/modules/note/liveNotes.spec.ts
Normal file
62
src/modules/note/liveNotes.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { resolveLivePathsToShas, stackToLivePaths } from "./liveNotes"
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
{ path: "README.md", sha: "a".repeat(40) },
|
||||||
|
{ path: "notes/one.md", sha: "b".repeat(40) },
|
||||||
|
{ path: "notes/two.md", sha: "c".repeat(40) }
|
||||||
|
]
|
||||||
|
|
||||||
|
describe("stackToLivePaths", () => {
|
||||||
|
it("maps each stacked sha to its current file path", () => {
|
||||||
|
expect(stackToLivePaths(["b".repeat(40), "c".repeat(40)], files)).toEqual([
|
||||||
|
"notes/one.md",
|
||||||
|
"notes/two.md"
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps a sha verbatim when no path resolves (drifted snapshot)", () => {
|
||||||
|
const orphan = "d".repeat(40)
|
||||||
|
expect(stackToLivePaths(["b".repeat(40), orphan], files)).toEqual([
|
||||||
|
"notes/one.md",
|
||||||
|
orphan
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves order", () => {
|
||||||
|
expect(stackToLivePaths(["c".repeat(40), "a".repeat(40)], files)).toEqual([
|
||||||
|
"notes/two.md",
|
||||||
|
"README.md"
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resolveLivePathsToShas", () => {
|
||||||
|
it("resolves each path to its latest sha", () => {
|
||||||
|
expect(
|
||||||
|
resolveLivePathsToShas(["notes/one.md", "notes/two.md"], files)
|
||||||
|
).toEqual(["b".repeat(40), "c".repeat(40)])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("passes a sha-shaped entry through as a pinned fallback", () => {
|
||||||
|
const orphan = "d".repeat(40)
|
||||||
|
expect(resolveLivePathsToShas(["notes/one.md", orphan], files)).toEqual([
|
||||||
|
"b".repeat(40),
|
||||||
|
orphan
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("drops a renamed or deleted path so the view degrades gracefully", () => {
|
||||||
|
expect(
|
||||||
|
resolveLivePathsToShas(["notes/gone.md", "notes/two.md"], files)
|
||||||
|
).toEqual(["c".repeat(40)])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips a live-shared stack back to the same shas", () => {
|
||||||
|
const shas = ["a".repeat(40), "b".repeat(40)]
|
||||||
|
expect(resolveLivePathsToShas(stackToLivePaths(shas, files), files)).toEqual(
|
||||||
|
shas
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
38
src/modules/note/liveNotes.ts
Normal file
38
src/modules/note/liveNotes.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// A shared link pins each stacked note to a blob sha, so the recipient sees the
|
||||||
|
// exact snapshot forever. A "living" link trades that pin for freshness: notes
|
||||||
|
// are referenced by their file path instead, and re-resolved to the latest blob
|
||||||
|
// sha against the repo's HEAD file list every time the link is opened.
|
||||||
|
|
||||||
|
const SHA_PATTERN = /^[0-9a-f]{40}$/i
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode the current stack (blob shas) as a living reference list. Each note
|
||||||
|
* becomes its file path when one resolves from the HEAD file list, so the link
|
||||||
|
* re-resolves to the latest version on open. A sha with no known path (e.g. an
|
||||||
|
* already-drifted snapshot the sharer never pulled) is kept verbatim, staying
|
||||||
|
* pinned — order is preserved either way.
|
||||||
|
*/
|
||||||
|
export const stackToLivePaths = (
|
||||||
|
shas: ReadonlyArray<string>,
|
||||||
|
files: ReadonlyArray<{ path?: string; sha?: string }>
|
||||||
|
): string[] =>
|
||||||
|
shas.map((sha) => files.find((file) => file.sha === sha)?.path ?? sha)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a living reference list back to current blob shas against the HEAD
|
||||||
|
* file list: a path maps to its latest sha; an entry already shaped like a sha
|
||||||
|
* is passed through (the pinned fallback above); anything else — a renamed or
|
||||||
|
* deleted path — is dropped so the view degrades gracefully instead of trying
|
||||||
|
* to fetch a blob that no longer exists.
|
||||||
|
*/
|
||||||
|
export const resolveLivePathsToShas = (
|
||||||
|
entries: ReadonlyArray<string>,
|
||||||
|
files: ReadonlyArray<{ path?: string; sha?: string }>
|
||||||
|
): string[] =>
|
||||||
|
entries
|
||||||
|
.map((entry) => {
|
||||||
|
const latestSha = files.find((file) => file.path === entry)?.sha
|
||||||
|
if (latestSha) return latestSha
|
||||||
|
return SHA_PATTERN.test(entry) ? entry : null
|
||||||
|
})
|
||||||
|
.filter((sha): sha is string => sha !== null)
|
||||||
Reference in New Issue
Block a user