mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-05 12:22:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdff3efe23 | ||
|
|
b7f24c1070 | ||
|
|
9682875ed9 | ||
|
|
6b2c052899 | ||
|
|
d32994e50f | ||
|
|
859ca9a59a | ||
|
|
177cd191e3 | ||
|
|
31e6bacd66 | ||
|
|
6b6c97a52d | ||
|
|
f1dc7d012e | ||
|
|
7af3a536c3 | ||
|
|
fdbb24049d | ||
|
|
ad3f9cb200 | ||
|
|
5f88e85835 | ||
|
|
6039ad2fd7 | ||
|
|
5e81bc9aef | ||
|
|
e127c6f58e | ||
|
|
8d7d7a86cf | ||
|
|
fd62159e61 | ||
|
|
46a8491bbc | ||
|
|
ff14d10d9c | ||
|
|
65c402f845 | ||
|
|
97bf2e2852 | ||
|
|
8c9f2cbfaa | ||
|
|
0f98e597ed | ||
|
|
65cfad60bb | ||
|
|
32f04b94cb | ||
|
|
61e8cc88fe | ||
|
|
1bcb770556 | ||
|
|
418239f977 | ||
|
|
034c4c3761 | ||
|
|
dfa260c2d0 | ||
|
|
b3274c9e68 | ||
|
|
9aaa2db932 | ||
|
|
712e9207e3 | ||
|
|
b4b2baef32 | ||
|
|
70917449d9 | ||
|
|
2351f9e126 | ||
|
|
befa1ad5ae | ||
|
|
789e0eff26 | ||
|
|
8a6e169471 | ||
|
|
53a8800eca | ||
|
|
1ff80e521a | ||
|
|
1e3da7aebc | ||
|
|
cd96e3a2b4 | ||
|
|
80fcb22f89 | ||
|
|
c9857839f5 | ||
|
|
ab607dc7b8 | ||
|
|
7260dfe1e0 | ||
|
|
b56cd0c95f | ||
|
|
a418fb2195 | ||
|
|
511a14087e | ||
|
|
be43f35ffc | ||
|
|
57d114aa52 | ||
|
|
1d1655d2a7 | ||
|
|
4ebc6f6f4a | ||
|
|
db408f4b06 | ||
|
|
f832695a6a | ||
|
|
213f92f496 | ||
|
|
96e8adc553 | ||
|
|
233c50d238 | ||
|
|
7937410e87 | ||
|
|
b50996b907 | ||
|
|
3f4b56ae90 | ||
|
|
edbc3786eb | ||
|
|
c7419a7f74 | ||
|
|
ee09b723af | ||
|
|
c617fcbfda | ||
|
|
622aa756cc | ||
|
|
c52199b1d0 | ||
|
|
ec83b1222f | ||
|
|
033d3b0288 | ||
|
|
5cfdd79a18 | ||
|
|
ce1f2a8f37 | ||
|
|
2b1d5488d6 | ||
|
|
e50780e4b3 | ||
|
|
c2846f9348 | ||
|
|
4266d0bd18 | ||
|
|
711df9bf07 | ||
|
|
24328c4c52 | ||
|
|
d15c574b13 | ||
|
|
198fd97a80 | ||
|
|
c285bd7e9c | ||
|
|
55774f588e |
No files matched your search
@@ -107,6 +107,13 @@ latest styles.
|
|||||||
- **Magnet helpers**:
|
- **Magnet helpers**:
|
||||||
- Use `safeDecodeMagnet()` and `normalizeAndAugmentMagnet()` from `js/magnetUtils.js` to preserve hashes and add `ws=` / `xs=` hints safely.
|
- Use `safeDecodeMagnet()` and `normalizeAndAugmentMagnet()` from `js/magnetUtils.js` to preserve hashes and add `ws=` / `xs=` hints safely.
|
||||||
|
|
||||||
|
### Relay compatibility
|
||||||
|
|
||||||
|
Bitvid now requests per-video discussion counts using the NIP-45 `COUNT` frame. The bundled client opens each relay via
|
||||||
|
`this.pool.ensureRelay(url)` and streams a raw `COUNT` message, so your relay stack must understand that verb (nostr-tools ≥ 1.8
|
||||||
|
or any relay advertising NIP-45 support). Relays that do not implement `COUNT` are skipped gracefully—the UI keeps the count
|
||||||
|
placeholder at “—” and development builds log a warning—so mixed deployments remain usable while you phase in compatible relays.
|
||||||
|
|
||||||
### Adding Features
|
### Adding Features
|
||||||
|
|
||||||
1. **Fork the repository** and create a new branch for your feature.
|
1. **Fork the repository** and create a new branch for your feature.
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns:serif="http://www.serif.com/"
|
||||||
|
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"
|
||||||
|
>
|
||||||
|
<g transform="matrix(1,0,0,1,12,12)">
|
||||||
|
<g transform="matrix(1,0,0,1,-12,-12)">
|
||||||
|
<path
|
||||||
|
d="M12,2C6.486,2 2,6.486 2,12C2,17.514 6.486,22 12,22C17.514,22 22,17.514 22,12C22,6.486 17.514,2 12,2ZM12,4C16.411,4 20,7.589 20,12C20,16.411 16.411,20 12,20C7.589,20 4,16.411 4,12C4,7.589 7.589,4 12,4ZM11,7C10.448,7 10,7.448 10,8L10,13C10,13.266 10.105,13.52 10.293,13.707L13.293,16.707C13.683,17.098 14.317,17.098 14.707,16.707C15.098,16.317 15.098,15.683 14.707,15.293L12,12.586L12,8C12,7.448 11.552,7 11,7Z"
|
||||||
|
style="fill:rgb(92,111,138);fill-rule:nonzero;"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
+129
-19
@@ -88,6 +88,15 @@
|
|||||||
>
|
>
|
||||||
Blocked
|
Blocked
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
id="profileNavHistory"
|
||||||
|
type="button"
|
||||||
|
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||||
|
aria-controls="profilePaneHistory"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
Watch history
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
id="profileNavAdmin"
|
id="profileNavAdmin"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -107,27 +116,90 @@
|
|||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-labelledby="profileNavAccount"
|
aria-labelledby="profileNavAccount"
|
||||||
>
|
>
|
||||||
<div class="flex items-center space-x-4">
|
<!--
|
||||||
<img
|
The account pane now relies on bespoke CSS (see css/style.css under
|
||||||
id="profileModalAvatar"
|
"Profile modal account pane") so we are not dependent on utility
|
||||||
src="assets/svg/default-profile.svg"
|
classes that may be purged or overridden by other builds.
|
||||||
alt="Profile"
|
-->
|
||||||
class="w-16 h-16 object-cover rounded-full"
|
<div class="profile-switcher" id="profileSwitcherTray">
|
||||||
/>
|
<div class="profile-switcher__header">
|
||||||
<p id="profileModalName" class="text-lg font-semibold truncate">
|
<div class="profile-switcher__intro">
|
||||||
Loading...
|
<h3 class="profile-switcher__title">Switch profiles</h3>
|
||||||
|
<p class="profile-switcher__subtitle">
|
||||||
|
Save multiple Nostr profiles so you can swap identities without leaving BitVid.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="profile-switcher__badge">Preview</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="profile-card profile-card--active"
|
||||||
|
aria-label="Current Nostr profile"
|
||||||
|
aria-pressed="true"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<span class="profile-card__avatar">
|
||||||
|
<img
|
||||||
|
id="profileModalAvatar"
|
||||||
|
src="assets/svg/default-profile.svg"
|
||||||
|
alt="Active profile avatar"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="profile-card__meta">
|
||||||
|
<span class="profile-card__topline">
|
||||||
|
<span class="profile-card__label">Active profile</span>
|
||||||
|
<a
|
||||||
|
id="profileChannelLink"
|
||||||
|
href="#"
|
||||||
|
class="profile-card__action hidden"
|
||||||
|
>
|
||||||
|
View channel
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<span id="profileModalName" class="profile-card__name">Loading...</span>
|
||||||
|
<span id="profileModalNpub" class="profile-card__npub">npub1...</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="profileSwitcherList"
|
||||||
|
class="profile-switcher__list"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="Additional Nostr profiles"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="profileAddAccountBtn"
|
||||||
|
class="profile-switcher__add"
|
||||||
|
aria-label="Add a new Nostr profile"
|
||||||
|
>
|
||||||
|
<span class="profile-switcher__addAvatar">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="profile-switcher__addCopy">
|
||||||
|
<span class="profile-switcher__addTitle">Add profile</span>
|
||||||
|
<span class="profile-switcher__addHint">Link another npub</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="profile-switcher__helper">
|
||||||
|
Profiles you add here will stay on this device so you can quickly switch before posting or moderating.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a
|
|
||||||
id="profileChannelLink"
|
|
||||||
href="#"
|
|
||||||
class="inline-flex items-center text-sm text-blue-400 hover:text-blue-300 transition-colors hidden"
|
|
||||||
>
|
|
||||||
View your channel
|
|
||||||
</a>
|
|
||||||
<p class="text-sm text-gray-400">
|
|
||||||
Manage your account details and upcoming preferences here.
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -209,6 +281,44 @@
|
|||||||
</div>
|
</div>
|
||||||
<ul id="blockedList" class="space-y-3"></ul>
|
<ul id="blockedList" class="space-y-3"></ul>
|
||||||
</section>
|
</section>
|
||||||
|
<section
|
||||||
|
id="profilePaneHistory"
|
||||||
|
class="profile-pane hidden space-y-4"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="profileNavHistory"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Your recently watched videos appear here so you can jump back into favorites.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
id="profileHistoryLoading"
|
||||||
|
class="flex items-center justify-center gap-3 py-6"
|
||||||
|
>
|
||||||
|
<div class="h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<p
|
||||||
|
id="profileHistoryStatus"
|
||||||
|
class="text-sm text-gray-300 hidden"
|
||||||
|
aria-live="polite"
|
||||||
|
role="status"
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="profileHistoryEmpty"
|
||||||
|
class="hidden rounded-lg border border-dashed border-gray-700 p-6 text-center text-sm text-gray-400"
|
||||||
|
>
|
||||||
|
You haven’t watched any videos yet.
|
||||||
|
</div>
|
||||||
|
<div id="profileHistoryScroll" class="relative max-h-[420px] overflow-y-auto pr-1">
|
||||||
|
<div
|
||||||
|
id="profileHistoryGrid"
|
||||||
|
class="watch-history-grid watch-history-grid--modal hidden"
|
||||||
|
role="list"
|
||||||
|
></div>
|
||||||
|
<div id="profileHistorySentinel" class="h-1 w-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section
|
<section
|
||||||
id="profilePaneAdmin"
|
id="profilePaneAdmin"
|
||||||
class="profile-pane hidden space-y-6"
|
class="profile-pane hidden space-y-6"
|
||||||
|
|||||||
@@ -34,6 +34,17 @@
|
|||||||
/>
|
/>
|
||||||
<span class="sidebar-text">Explore</span>
|
<span class="sidebar-text">Explore</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="#view=history"
|
||||||
|
class="flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="assets/svg/history-icon.svg"
|
||||||
|
alt="History"
|
||||||
|
class="w-6 h-6 mr-3 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="sidebar-text">History</span>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
id="subscriptionsLink"
|
id="subscriptionsLink"
|
||||||
href="#view=subscriptions"
|
href="#view=subscriptions"
|
||||||
|
|||||||
@@ -44,3 +44,72 @@ export const ADMIN_WHITELIST_MODE_STORAGE_KEY = "bitvid_admin_whitelist_mode";
|
|||||||
* default state when no preference has been stored in localStorage yet.
|
* default state when no preference has been stored in localStorage yet.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_WHITELIST_MODE_ENABLED = true;
|
export const DEFAULT_WHITELIST_MODE_ENABLED = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nostr kind used when persisting watch history events.
|
||||||
|
*
|
||||||
|
* BitVid’s roadmap standardizes on kind 30078 so that watch events, view logs,
|
||||||
|
* and media metadata stay in the same family of documents. Operators that want
|
||||||
|
* to experiment with a separate list kind (for example, a NIP-51 collection)
|
||||||
|
* can flip this number so long as their relays accept the chosen kind.
|
||||||
|
*/
|
||||||
|
export const WATCH_HISTORY_KIND = 30078;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifier applied to the watch-history list when storing it on relays.
|
||||||
|
*
|
||||||
|
* This becomes the value of the `d` tag so clients can find the correct list
|
||||||
|
* without guessing. Customize the slug if you need isolation between multiple
|
||||||
|
* BitVid deployments that share relays, but keep it stable once clients begin
|
||||||
|
* syncing history.
|
||||||
|
*/
|
||||||
|
export const WATCH_HISTORY_LIST_IDENTIFIER = "watch-history";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of watch-history entries to retain per user.
|
||||||
|
*
|
||||||
|
* The roadmap targets a rolling window of 1,500 items so the UI can highlight
|
||||||
|
* recently played videos without ballooning relay storage. Adjust this cap up
|
||||||
|
* or down depending on your retention and privacy policies; smaller values
|
||||||
|
* reduce storage pressure while larger ones surface a deeper backlog.
|
||||||
|
*/
|
||||||
|
export const WATCH_HISTORY_MAX_ITEMS = 1500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether clients should resolve watch-history entries in batches.
|
||||||
|
*
|
||||||
|
* When enabled, BitVid fetches video metadata in grouped queries instead of
|
||||||
|
* issuing one request per entry. Operators with relays that struggle under
|
||||||
|
* bursty loads can disable batching at the cost of additional round trips.
|
||||||
|
*/
|
||||||
|
export const WATCH_HISTORY_BATCH_RESOLVE = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum size of the JSON payload for each watch-history chunk, measured
|
||||||
|
* before encryption.
|
||||||
|
*
|
||||||
|
* BitVid splits large histories across multiple events to stay within relay
|
||||||
|
* limits. This cap keeps individual chunks under roughly 60 KB so that
|
||||||
|
* encrypted payloads remain relay-friendly even after base64 expansion.
|
||||||
|
* Lower the number if your relays enforce tighter limits.
|
||||||
|
*/
|
||||||
|
export const WATCH_HISTORY_PAYLOAD_MAX_BYTES = 60000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of watch-history events to request when syncing from relays.
|
||||||
|
*
|
||||||
|
* Chunked histories can span several events. This fetch limit should exceed
|
||||||
|
* the maximum number of chunks you expect a client to publish in one snapshot
|
||||||
|
* so the UI can stitch the full list back together.
|
||||||
|
*/
|
||||||
|
export const WATCH_HISTORY_FETCH_EVENT_LIMIT = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long clients should cache watch-history snapshots in localStorage.
|
||||||
|
*
|
||||||
|
* A 24-hour window keeps recently played videos available across reloads
|
||||||
|
* without requiring a fresh relay sync on every visit. Increase the value for
|
||||||
|
* longer-lived caches or decrease it if your deployment needs tighter
|
||||||
|
* retention guarantees.
|
||||||
|
*/
|
||||||
|
export const WATCH_HISTORY_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
+615
@@ -104,6 +104,31 @@ header img {
|
|||||||
padding: 1.5rem 0;
|
padding: 1.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dedicated watch-history layout stacks cards vertically */
|
||||||
|
.watch-history-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile modal uses the same vertical stack but without outer padding */
|
||||||
|
.watch-history-grid.watch-history-grid--modal {
|
||||||
|
padding: 0;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.watch-history-grid {
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-grid.watch-history-grid--modal {
|
||||||
|
gap: 1.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Video Cards */
|
/* Video Cards */
|
||||||
.video-card {
|
.video-card {
|
||||||
background-color: var(--color-card);
|
background-color: var(--color-card);
|
||||||
@@ -131,6 +156,249 @@ header img {
|
|||||||
animation: video-card-fade-in 220ms ease-out forwards;
|
animation: video-card-fade-in 220ms ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Watch history cards */
|
||||||
|
.watch-history-card {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: stretch;
|
||||||
|
background-color: var(--color-card);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border: 0.0625rem solid transparent;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card--private {
|
||||||
|
border-color: rgba(234, 179, 8, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__primary {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1 1 18rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__thumbnail {
|
||||||
|
display: block;
|
||||||
|
flex: 0 0 14rem;
|
||||||
|
max-width: 14rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__thumbnailInner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__thumbnailInner img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.4;
|
||||||
|
cursor: pointer;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__title:hover,
|
||||||
|
.watch-history-card__title:focus {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__created {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 0 0 16rem;
|
||||||
|
min-width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__watched {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__creator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__creatorAvatar {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #1f2937;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__creatorAvatar:hover,
|
||||||
|
.watch-history-card__creatorAvatar:focus {
|
||||||
|
box-shadow: 0 0 0 0.125rem rgba(59, 130, 246, 0.5);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__creatorAvatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__creatorName {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #e5e7eb;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__creatorName:hover,
|
||||||
|
.watch-history-card__creatorName:focus {
|
||||||
|
color: #60a5fa;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 0.0625rem solid transparent;
|
||||||
|
background-color: #1f2937;
|
||||||
|
color: #e5e7eb;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease,
|
||||||
|
border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__action:hover,
|
||||||
|
.watch-history-card__action:focus {
|
||||||
|
background-color: #111827;
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #374151;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__action--primary {
|
||||||
|
background-color: #2563eb;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__action--primary:hover,
|
||||||
|
.watch-history-card__action--primary:focus {
|
||||||
|
background-color: #1d4ed8;
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__action--danger {
|
||||||
|
background-color: #7f1d1d;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__action--danger:hover,
|
||||||
|
.watch-history-card__action--danger:focus {
|
||||||
|
background-color: #991b1b;
|
||||||
|
border-color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.watch-history-card__meta {
|
||||||
|
flex: 1 1 18rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.watch-history-card {
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__thumbnail {
|
||||||
|
flex-basis: 11rem;
|
||||||
|
max-width: 11rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.watch-history-card {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__primary,
|
||||||
|
.watch-history-card__meta {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-history-card__meta {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes video-card-fade-in {
|
@keyframes video-card-fade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -269,6 +537,290 @@ header img {
|
|||||||
-webkit-backdrop-filter: blur(0.625rem); /* For Safari */
|
-webkit-backdrop-filter: blur(0.625rem); /* For Safari */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Profile modal account pane
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
The modal previously mixed Tailwind-style utility classes with our global
|
||||||
|
stylesheet. Those utilities are not always present in the production build,
|
||||||
|
so important layout rules (borders, gaps, flex behaviour) were getting
|
||||||
|
dropped. This block consolidates the account tab styling in one predictable
|
||||||
|
place so future tweaks only require CSS updates here instead of scattered
|
||||||
|
inline class lists.
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
#profileModal .profile-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Ensure the global `.hidden` helper keeps working for the tab panes. Our
|
||||||
|
profile-pane declaration above sets a `display` value with higher
|
||||||
|
specificity than `.hidden`, so we explicitly restore the hidden state when
|
||||||
|
both classes are present.
|
||||||
|
*/
|
||||||
|
#profileModal .profile-pane.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-switcher {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: clamp(1.25rem, 2vw, 1.5rem);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
border: 0.0625rem solid rgba(148, 163, 184, 0.28);
|
||||||
|
background: linear-gradient(145deg, rgba(15, 23, 42, 0.94), rgba(15, 23, 42, 0.72));
|
||||||
|
box-shadow: 0 1.75rem 3.5rem rgba(8, 11, 30, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-switcher__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-switcher__intro {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-switcher__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-switcher__subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-switcher__badge {
|
||||||
|
align-self: flex-start;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(59, 130, 246, 0.18);
|
||||||
|
color: rgba(191, 219, 254, 0.95);
|
||||||
|
border: 0.0625rem solid rgba(96, 165, 250, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 0.0625rem solid rgba(148, 163, 184, 0.24);
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.08);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card:hover {
|
||||||
|
border-color: rgba(148, 163, 184, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card:focus-visible {
|
||||||
|
border-color: rgba(96, 165, 250, 0.6);
|
||||||
|
box-shadow: 0 0 0 0.1875rem rgba(37, 99, 235, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card--active {
|
||||||
|
border-color: rgba(59, 130, 246, 0.55);
|
||||||
|
background: linear-gradient(160deg, rgba(37, 99, 235, 0.22), rgba(15, 23, 42, 0.78));
|
||||||
|
box-shadow: 0 1.35rem 2.8rem rgba(37, 99, 235, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card__avatar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 0.1875rem solid rgba(148, 163, 184, 0.3);
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card__avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card--active .profile-card__avatar {
|
||||||
|
border-color: rgba(96, 165, 250, 0.7);
|
||||||
|
box-shadow: 0 0 0 0.1875rem rgba(59, 130, 246, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card__meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card__topline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card__label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(191, 219, 254, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card__action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 0.0625rem solid rgba(59, 130, 246, 0.4);
|
||||||
|
background: rgba(59, 130, 246, 0.14);
|
||||||
|
color: rgba(191, 219, 254, 0.95);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card__action:hover,
|
||||||
|
#profileModal .profile-card__action:focus-visible {
|
||||||
|
background: rgba(59, 130, 246, 0.24);
|
||||||
|
border-color: rgba(59, 130, 246, 0.6);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card__name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card__npub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
color: rgba(148, 163, 184, 0.85);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-switcher__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-switcher__add {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 0.125rem dashed rgba(148, 163, 184, 0.35);
|
||||||
|
background: rgba(15, 23, 42, 0.55);
|
||||||
|
color: rgba(226, 232, 240, 0.85);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-switcher__add:hover,
|
||||||
|
#profileModal .profile-switcher__add:focus-visible {
|
||||||
|
border-color: rgba(96, 165, 250, 0.65);
|
||||||
|
background: rgba(59, 130, 246, 0.18);
|
||||||
|
color: #ffffff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-switcher__addAvatar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 0.125rem dashed currentColor;
|
||||||
|
color: inherit;
|
||||||
|
background: rgba(15, 23, 42, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-switcher__addAvatar svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-switcher__addCopy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-switcher__addTitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-switcher__addHint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(148, 163, 184, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-switcher__helper {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(148, 163, 184, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
#profileModal .profile-switcher__header {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profileModal .profile-card__topline {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* If you ever want to show it, add ".flex" class dynamically */
|
/* If you ever want to show it, add ".flex" class dynamically */
|
||||||
#playerModal.flex {
|
#playerModal.flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -535,6 +1087,27 @@ button:not(.icon-button):focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Notifications */
|
/* Notifications */
|
||||||
|
#notificationPortal {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(env(safe-area-inset-top, 0px) + 1.5rem);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 400; /* Keep notifications above the highest modal overlays */
|
||||||
|
width: min(90vw, 32rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notificationPortal > * {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 1.25rem 3rem rgba(15, 23, 42, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
/* Base styling without a forced display */
|
/* Base styling without a forced display */
|
||||||
#errorContainer,
|
#errorContainer,
|
||||||
#successContainer {
|
#successContainer {
|
||||||
@@ -550,6 +1123,48 @@ button:not(.icon-button):focus {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.watch-history-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-spinner--inline {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
border: 0.125rem solid rgba(96, 165, 250, 0.35);
|
||||||
|
border-radius: 9999px;
|
||||||
|
border-top-color: #60a5fa;
|
||||||
|
display: inline-block;
|
||||||
|
height: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner {
|
||||||
|
align-items: center;
|
||||||
|
background-color: rgba(31, 41, 55, 0.9);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner .status-spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
border: 0.125rem solid rgba(229, 231, 235, 0.35);
|
||||||
|
border-radius: 9999px;
|
||||||
|
border-top-color: #f9fafb;
|
||||||
|
height: 1.25rem;
|
||||||
|
width: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
/* Responsive Design */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
#videoList {
|
#videoList {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Nostr authentication storage
|
||||||
|
|
||||||
|
Bitvid keeps lightweight state in `localStorage` so the UI can remember which
|
||||||
|
accounts signed in recently. This document explains the storage schema so future
|
||||||
|
updates can evolve it without breaking existing users.
|
||||||
|
|
||||||
|
## Storage keys
|
||||||
|
|
||||||
|
- `bitvid:profileCache:v1` – short-lived metadata cache for profile names and
|
||||||
|
avatars. See `js/app.js` for TTL and eviction rules.
|
||||||
|
- `bitvid:savedProfiles:v1` – persistent list of accounts the user authenticated
|
||||||
|
with in this browser.
|
||||||
|
|
||||||
|
Both keys live in the main origin scope. Clearing one should not affect the
|
||||||
|
other.
|
||||||
|
|
||||||
|
## Saved profile schema
|
||||||
|
|
||||||
|
`bitvid:savedProfiles:v1` is a JSON object with the shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"pubkey": "<hex-encoded pubkey>",
|
||||||
|
"npub": "<bech32 npub, optional>",
|
||||||
|
"name": "<cached display name>",
|
||||||
|
"picture": "<cached avatar URL>",
|
||||||
|
"authType": "<auth strategy>"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"activePubkey": "<hex pubkey or null>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `pubkey` always stores a lowercase 64-character hex string and is the primary
|
||||||
|
dedupe key.
|
||||||
|
- `npub` is optional. When omitted, the UI regenerates it on load using
|
||||||
|
`NostrTools.nip19.npubEncode`.
|
||||||
|
- `name` and `picture` are cached hints that keep the profile switcher snappy.
|
||||||
|
They may be empty strings and should be treated as hints, not the source of
|
||||||
|
truth.
|
||||||
|
- `activePubkey` tracks the last account that successfully authenticated in this
|
||||||
|
browser. It may be `null` when the user logs out but keeps saved entries.
|
||||||
|
|
||||||
|
### `authType` enum
|
||||||
|
|
||||||
|
`authType` describes how the profile authenticated:
|
||||||
|
|
||||||
|
- `"nip07"` – Browser extension flow (current default).
|
||||||
|
- `"nsec"` – Reserved for future direct key import or signer integrations. New
|
||||||
|
auth strategies should pick a distinct string and document how migration works
|
||||||
|
alongside any recovery tooling.
|
||||||
|
|
||||||
|
When the app reads stored entries it normalises unknown values back to
|
||||||
|
`"nip07"` but keeps recognised alternatives intact.
|
||||||
|
|
||||||
|
## Migration notes
|
||||||
|
|
||||||
|
Earlier builds only persisted a single `userPubKey` string. During startup the
|
||||||
|
app now:
|
||||||
|
|
||||||
|
1. Attempts to read `bitvid:savedProfiles:v1` and validate the payload.
|
||||||
|
2. If the key is missing (or malformed) but a legacy `userPubKey` entry exists,
|
||||||
|
it seeds `savedProfiles` with that value and writes the new structure.
|
||||||
|
3. Once the JSON payload is written successfully, the legacy `userPubKey` entry
|
||||||
|
is removed.
|
||||||
|
|
||||||
|
Future migrations should follow the same pattern: validate, normalise, write the
|
||||||
|
new format, then clean up legacy keys to avoid data loss.
|
||||||
@@ -17,5 +17,8 @@ Run this checklist before publishing releases or merging changes that touch uplo
|
|||||||
5. **Cross-Browser Verification**
|
5. **Cross-Browser Verification**
|
||||||
- Spot-check Chromium and Firefox (desktop) for console warnings about CORS, Range requests, or tracker connectivity.
|
- Spot-check Chromium and Firefox (desktop) for console warnings about CORS, Range requests, or tracker connectivity.
|
||||||
- Optionally validate on mobile Safari/Chrome if the change targets mobile UX.
|
- Optionally validate on mobile Safari/Chrome if the change targets mobile UX.
|
||||||
|
6. **Saved Profile Metadata Refresh**
|
||||||
|
- In the browser devtools console/storage panel, delete `bitvid:profileCache:v1` but keep `bitvid:savedProfiles:v1` populated with Nostr pubkeys that lack stored names/avatars.
|
||||||
|
- Reload the page and open the profile switcher; confirm avatars and display names populate automatically without manual refresh.
|
||||||
|
|
||||||
Document findings (pass/fail notes plus relevant screenshots or logs) so they can be attached to release or PR notes.
|
Document findings (pass/fail notes plus relevant screenshots or logs) so they can be attached to release or PR notes.
|
||||||
+54
-11
@@ -149,17 +149,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Error Container -->
|
<!-- Notification Portal -->
|
||||||
<div
|
<div
|
||||||
id="errorContainer"
|
id="notificationPortal"
|
||||||
class="hidden bg-red-100 text-red-900 p-4 rounded-md mb-4"
|
role="region"
|
||||||
></div>
|
aria-live="polite"
|
||||||
|
aria-label="System notifications"
|
||||||
|
>
|
||||||
|
<!-- Error Container -->
|
||||||
|
<div
|
||||||
|
id="errorContainer"
|
||||||
|
class="hidden bg-red-100 text-red-900 p-4 rounded-md mb-4"
|
||||||
|
></div>
|
||||||
|
|
||||||
<!-- Success Container -->
|
<!-- Status Container -->
|
||||||
<div
|
<div
|
||||||
id="successContainer"
|
id="statusContainer"
|
||||||
class="hidden bg-green-100 text-green-900 p-4 rounded-md mb-4"
|
class="hidden status-banner"
|
||||||
></div>
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span class="status-spinner" aria-hidden="true"></span>
|
||||||
|
<span class="status-message" data-status-message></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Container -->
|
||||||
|
<div
|
||||||
|
id="successContainer"
|
||||||
|
class="hidden bg-green-100 text-green-900 p-4 rounded-md mb-4"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Dynamic views (like most-recent-videos.html, etc.) -->
|
<!-- Dynamic views (like most-recent-videos.html, etc.) -->
|
||||||
<main id="viewContainer" class="flex-grow mb-8"></main>
|
<main id="viewContainer" class="flex-grow mb-8"></main>
|
||||||
@@ -181,8 +200,32 @@
|
|||||||
<!-- Additional Scripts -->
|
<!-- Additional Scripts -->
|
||||||
<script src="js/libs/nostr.bundle.js"></script>
|
<script src="js/libs/nostr.bundle.js"></script>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { nip19, SimplePool } from "https://esm.sh/[email protected]";
|
import * as nostrTools from "https://esm.sh/[email protected]";
|
||||||
window.NostrTools = { nip19, SimplePool };
|
|
||||||
|
const existing =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.NostrTools &&
|
||||||
|
typeof window.NostrTools === "object"
|
||||||
|
? window.NostrTools
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const merged = { ...existing, ...nostrTools };
|
||||||
|
|
||||||
|
if (
|
||||||
|
!merged.generatePrivateKey &&
|
||||||
|
typeof merged.generateSecretKey === "function"
|
||||||
|
) {
|
||||||
|
merged.generatePrivateKey = merged.generateSecretKey.bind(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!merged.generateSecretKey &&
|
||||||
|
typeof merged.generatePrivateKey === "function"
|
||||||
|
) {
|
||||||
|
merged.generateSecretKey = merged.generatePrivateKey.bind(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.NostrTools = merged;
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="js/config.js"></script>
|
<script type="module" src="js/config.js"></script>
|
||||||
<script type="module" src="js/lists.js"></script>
|
<script type="module" src="js/lists.js"></script>
|
||||||
|
|||||||
@@ -794,7 +794,6 @@ async function loadUserVideos(pubkey) {
|
|||||||
app.handleUrlHealthBadge({ video, url, badgeEl });
|
app.handleUrlHealthBadge({ video, url, badgeEl });
|
||||||
});
|
});
|
||||||
|
|
||||||
window.app.videoList = container;
|
|
||||||
window.app.attachVideoListHandler();
|
window.app.attachVideoListHandler();
|
||||||
|
|
||||||
// Lazy-load images
|
// Lazy-load images
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import {
|
|||||||
ADMIN_SUPER_NPUB,
|
ADMIN_SUPER_NPUB,
|
||||||
ADMIN_WHITELIST_MODE_STORAGE_KEY,
|
ADMIN_WHITELIST_MODE_STORAGE_KEY,
|
||||||
DEFAULT_WHITELIST_MODE_ENABLED,
|
DEFAULT_WHITELIST_MODE_ENABLED,
|
||||||
|
WATCH_HISTORY_KIND,
|
||||||
|
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||||
|
WATCH_HISTORY_MAX_ITEMS,
|
||||||
|
WATCH_HISTORY_BATCH_RESOLVE,
|
||||||
|
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
|
||||||
|
WATCH_HISTORY_FETCH_EVENT_LIMIT,
|
||||||
|
WATCH_HISTORY_CACHE_TTL_MS,
|
||||||
} from "../config/instance-config.js";
|
} from "../config/instance-config.js";
|
||||||
|
|
||||||
export const isDevMode = true; // Set to false for production
|
export const isDevMode = true; // Set to false for production
|
||||||
@@ -42,3 +49,15 @@ export function setWhitelistMode(enabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { ADMIN_WHITELIST_MODE_STORAGE_KEY };
|
export { ADMIN_WHITELIST_MODE_STORAGE_KEY };
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Watch history — roadmap defaults balance retention with relay load
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export { WATCH_HISTORY_KIND };
|
||||||
|
export { WATCH_HISTORY_LIST_IDENTIFIER };
|
||||||
|
export { WATCH_HISTORY_MAX_ITEMS };
|
||||||
|
export { WATCH_HISTORY_BATCH_RESOLVE };
|
||||||
|
export { WATCH_HISTORY_PAYLOAD_MAX_BYTES };
|
||||||
|
export { WATCH_HISTORY_FETCH_EVENT_LIMIT };
|
||||||
|
export { WATCH_HISTORY_CACHE_TTL_MS };
|
||||||
+1346
File diff suppressed because it is too large.
Load diff
@@ -378,7 +378,6 @@ function handleHashChange() {
|
|||||||
if (typeof initFn === "function") {
|
if (typeof initFn === "function") {
|
||||||
initFn();
|
initFn();
|
||||||
}
|
}
|
||||||
recordView("most-recent-videos");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
+2929
-19
File diff suppressed because it is too large.
Load diff
+1
-4
@@ -614,10 +614,7 @@ class SubscriptionsManager {
|
|||||||
window.app.handleUrlHealthBadge({ video, url, badgeEl });
|
window.app.handleUrlHealthBadge({ video, url, badgeEl });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (window.app) {
|
window.app?.attachVideoListHandler?.();
|
||||||
window.app.videoList = container;
|
|
||||||
window.app.attachVideoListHandler?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lazy-load
|
// Lazy-load
|
||||||
const lazyEls = container.querySelectorAll("[data-lazy]");
|
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ export const viewInitRegistry = {
|
|||||||
explore: () => {
|
explore: () => {
|
||||||
console.log("Explore view loaded.");
|
console.log("Explore view loaded.");
|
||||||
},
|
},
|
||||||
|
history: async () => {
|
||||||
|
try {
|
||||||
|
const module = await import("./historyView.js");
|
||||||
|
if (typeof module.initHistoryView === "function") {
|
||||||
|
await module.initHistoryView();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initialize history view:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscriptions view:
|
* Subscriptions view:
|
||||||
|
|||||||
@@ -0,0 +1,746 @@
|
|||||||
|
import "./test-helpers/setup-localstorage.mjs";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
const {
|
||||||
|
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||||
|
WATCH_HISTORY_KIND,
|
||||||
|
} = await import("../js/config.js");
|
||||||
|
|
||||||
|
if (typeof globalThis.window === "undefined") {
|
||||||
|
globalThis.window = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.window.NostrTools) {
|
||||||
|
globalThis.window.NostrTools = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.window.NostrTools.getEventHash = (event) => {
|
||||||
|
const payload = `${event.kind}:${event.pubkey}:${event.created_at}`;
|
||||||
|
return `hash-${Buffer.from(payload).toString("hex")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.window.NostrTools.signEvent = (event, privateKey) =>
|
||||||
|
`sig-${privateKey.slice(0, 8)}-${event.created_at}`;
|
||||||
|
|
||||||
|
delete globalThis.window.NostrTools.nip04;
|
||||||
|
delete globalThis.window.nostr;
|
||||||
|
|
||||||
|
const { nostrClient } = await import("../js/nostr.js");
|
||||||
|
|
||||||
|
function createPublishingClient(actorPubkey) {
|
||||||
|
const client = new nostrClient.constructor();
|
||||||
|
const publishedEvents = [];
|
||||||
|
const payloads = [];
|
||||||
|
client.pool = {
|
||||||
|
publish(urls, event) {
|
||||||
|
publishedEvents.push(event);
|
||||||
|
return {
|
||||||
|
on(eventName, handler) {
|
||||||
|
if (eventName === "ok" || eventName === "seen") {
|
||||||
|
setTimeout(handler, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
list: async () => [],
|
||||||
|
};
|
||||||
|
client.relays = ["wss://unit.test"];
|
||||||
|
client.pubkey = "";
|
||||||
|
client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" };
|
||||||
|
client.ensureSessionActor = async () => actorPubkey;
|
||||||
|
client.encryptWatchHistoryPayload = async (_actor, payload) => {
|
||||||
|
payloads.push(payload);
|
||||||
|
return { ok: true, ciphertext: JSON.stringify(payload) };
|
||||||
|
};
|
||||||
|
client.persistWatchHistoryEntry = () => {};
|
||||||
|
client.cancelWatchHistoryRepublish = () => {};
|
||||||
|
client.scheduleWatchHistoryRepublish = () => {};
|
||||||
|
return { client, publishedEvents, payloads };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDecryptClient(actorPubkey) {
|
||||||
|
const client = new nostrClient.constructor();
|
||||||
|
client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" };
|
||||||
|
client.ensureSessionActor = async () => actorPubkey;
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTOR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
|
||||||
|
const { client: publishingClient, publishedEvents, payloads } =
|
||||||
|
createPublishingClient(ACTOR);
|
||||||
|
|
||||||
|
publishingClient.watchHistoryPayloadMaxBytes = 400;
|
||||||
|
publishingClient.watchHistoryFetchEventLimit = 10;
|
||||||
|
|
||||||
|
const baseWatchedAt = 1_700_000_000_000;
|
||||||
|
const longPointers = Array.from({ length: 8 }, (_, index) => {
|
||||||
|
const relay = index % 3 === 0 ? "wss://relay.unit" : null;
|
||||||
|
const watchedAt = baseWatchedAt + index * 60_000;
|
||||||
|
if (index % 2 === 0) {
|
||||||
|
return {
|
||||||
|
type: "e",
|
||||||
|
value: `event-${index}-${"x".repeat(90)}`,
|
||||||
|
relay,
|
||||||
|
watchedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "a",
|
||||||
|
value: `30078:${ACTOR}:history-${index}-${"y".repeat(60)}`,
|
||||||
|
relay,
|
||||||
|
watchedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const publishResult = await publishingClient.publishWatchHistorySnapshot(
|
||||||
|
ACTOR,
|
||||||
|
longPointers
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(publishResult.ok, true, "publish should report success");
|
||||||
|
assert(publishResult.events.length > 1, "snapshot should chunk into multiple events");
|
||||||
|
assert.equal(
|
||||||
|
publishResult.events.length,
|
||||||
|
payloads.length,
|
||||||
|
"each chunk should produce an encryption payload"
|
||||||
|
);
|
||||||
|
|
||||||
|
payloads.forEach((payload) => {
|
||||||
|
const size = JSON.stringify(payload).length;
|
||||||
|
assert(
|
||||||
|
size <= publishingClient.watchHistoryPayloadMaxBytes,
|
||||||
|
`payload size ${size} should respect cap`
|
||||||
|
);
|
||||||
|
assert.equal(payload.version, 2, "chunk payload should use version 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshotIds = new Set(
|
||||||
|
publishResult.events.map((event) =>
|
||||||
|
event.tags.find((tag) => Array.isArray(tag) && tag[0] === "snapshot")?.[1]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert.equal(snapshotIds.size, 1, "all chunks should share a snapshot id");
|
||||||
|
|
||||||
|
const headTag = publishResult.event.tags.find(
|
||||||
|
(tag) => Array.isArray(tag) && tag[0] === "head"
|
||||||
|
);
|
||||||
|
assert(headTag, "head chunk should include head tag");
|
||||||
|
|
||||||
|
const chunkIdentifiers = publishResult.events.map((event) =>
|
||||||
|
event.tags.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
chunkIdentifiers.length,
|
||||||
|
publishResult.events.length,
|
||||||
|
"each chunk should expose a d tag"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
chunkIdentifiers[0],
|
||||||
|
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||||
|
"head chunk should retain canonical watch history identifier"
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqueChunkIdentifiers = new Set(chunkIdentifiers);
|
||||||
|
assert.equal(
|
||||||
|
uniqueChunkIdentifiers.size,
|
||||||
|
publishResult.events.length,
|
||||||
|
"each chunk should have a unique d tag"
|
||||||
|
);
|
||||||
|
|
||||||
|
chunkIdentifiers.slice(1).forEach((identifier, index) => {
|
||||||
|
assert(
|
||||||
|
identifier && identifier.startsWith("watch-history:"),
|
||||||
|
`chunk ${index + 1} should use a watch-history namespace identifier`
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
identifier,
|
||||||
|
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||||
|
"only the head chunk should use the canonical identifier"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const headATags = publishResult.event.tags
|
||||||
|
.filter((tag) => Array.isArray(tag) && tag[0] === "a")
|
||||||
|
.map((tag) => tag[1]);
|
||||||
|
|
||||||
|
const expectedChunkAddresses = publishResult.events.map((event) => {
|
||||||
|
const identifier = event.tags.find(
|
||||||
|
(tag) => Array.isArray(tag) && tag[0] === "d"
|
||||||
|
)?.[1];
|
||||||
|
assert(identifier, "chunk should include identifier tag");
|
||||||
|
return `${WATCH_HISTORY_KIND}:${ACTOR}:${identifier}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
expectedChunkAddresses.forEach((address) => {
|
||||||
|
assert(
|
||||||
|
headATags.includes(address),
|
||||||
|
`head chunk should reference ${address}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
delete globalThis.window.nostr;
|
||||||
|
|
||||||
|
if (!globalThis.window.NostrTools.nip04) {
|
||||||
|
globalThis.window.NostrTools.nip04 = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.window.NostrTools.nip04.decrypt = async (
|
||||||
|
_priv,
|
||||||
|
_pub,
|
||||||
|
ciphertext
|
||||||
|
) => ciphertext;
|
||||||
|
|
||||||
|
const decryptClient = createDecryptClient(ACTOR);
|
||||||
|
decryptClient.relays = ["wss://unit.test"];
|
||||||
|
decryptClient.watchHistoryFetchEventLimit = 10;
|
||||||
|
decryptClient.pool = {
|
||||||
|
list: async () => publishedEvents,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetched = await decryptClient.fetchWatchHistory(ACTOR);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
fetched.items.length,
|
||||||
|
longPointers.length,
|
||||||
|
"fetch should reassemble all chunked items"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
fetched.items.map((item) => ({
|
||||||
|
type: item.type,
|
||||||
|
value: item.value,
|
||||||
|
relay: item.relay || null,
|
||||||
|
watchedAt: item.watchedAt,
|
||||||
|
})),
|
||||||
|
longPointers.map((item) => ({
|
||||||
|
type: item.type,
|
||||||
|
value: item.value,
|
||||||
|
relay: item.relay || null,
|
||||||
|
watchedAt: item.watchedAt,
|
||||||
|
})),
|
||||||
|
"chunked fetch should preserve pointer order and timestamps"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof localStorage !== "undefined") {
|
||||||
|
localStorage.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
const headEvent = publishResult.event;
|
||||||
|
const chunkEvents = publishResult.events.filter(
|
||||||
|
(event) => event.id !== headEvent.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshotId = headEvent.tags.find(
|
||||||
|
(tag) => Array.isArray(tag) && tag[0] === "snapshot"
|
||||||
|
)?.[1];
|
||||||
|
|
||||||
|
const freshClient = createDecryptClient(ACTOR);
|
||||||
|
freshClient.relays = ["wss://unit.test"];
|
||||||
|
freshClient.watchHistoryFetchEventLimit = 10;
|
||||||
|
|
||||||
|
const observedFilters = [];
|
||||||
|
freshClient.pool = {
|
||||||
|
list: async (_relays, filters) => {
|
||||||
|
observedFilters.push(filters);
|
||||||
|
|
||||||
|
const firstFilter = filters[0] || {};
|
||||||
|
const dFilter = Array.isArray(firstFilter["#d"])
|
||||||
|
? firstFilter["#d"]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (filters.length === 1 && dFilter.includes(WATCH_HISTORY_LIST_IDENTIFIER)) {
|
||||||
|
return [headEvent];
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedIdentifiers = new Set();
|
||||||
|
const requestedSnapshots = new Set();
|
||||||
|
|
||||||
|
for (const filter of filters) {
|
||||||
|
if (Array.isArray(filter?.["#d"])) {
|
||||||
|
for (const identifier of filter["#d"]) {
|
||||||
|
requestedIdentifiers.add(identifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(filter?.["#snapshot"])) {
|
||||||
|
for (const value of filter["#snapshot"]) {
|
||||||
|
requestedSnapshots.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = [];
|
||||||
|
|
||||||
|
for (const event of publishResult.events) {
|
||||||
|
const identifier = event.tags.find(
|
||||||
|
(tag) => Array.isArray(tag) && tag[0] === "d"
|
||||||
|
)?.[1];
|
||||||
|
const eventSnapshot = event.tags.find(
|
||||||
|
(tag) => Array.isArray(tag) && tag[0] === "snapshot"
|
||||||
|
)?.[1];
|
||||||
|
|
||||||
|
if (identifier && requestedIdentifiers.has(identifier)) {
|
||||||
|
responses.push(event);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (eventSnapshot && requestedSnapshots.has(eventSnapshot)) {
|
||||||
|
responses.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const rebuilt = await freshClient.fetchWatchHistory(ACTOR);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
rebuilt.items.length,
|
||||||
|
longPointers.length,
|
||||||
|
"fresh client should rebuild full watch history list"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
rebuilt.items.map((item) => ({
|
||||||
|
type: item.type,
|
||||||
|
value: item.value,
|
||||||
|
relay: item.relay || null,
|
||||||
|
watchedAt: item.watchedAt,
|
||||||
|
})),
|
||||||
|
longPointers.map((item) => ({
|
||||||
|
type: item.type,
|
||||||
|
value: item.value,
|
||||||
|
relay: item.relay || null,
|
||||||
|
watchedAt: item.watchedAt,
|
||||||
|
})),
|
||||||
|
"rebuilt snapshot should preserve timestamps"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
observedFilters.length >= 2,
|
||||||
|
"fetch should include a follow-up query for chunk events"
|
||||||
|
);
|
||||||
|
|
||||||
|
const followUpFilters = observedFilters[observedFilters.length - 1];
|
||||||
|
const identifiersRequested = new Set();
|
||||||
|
let snapshotRequested = false;
|
||||||
|
|
||||||
|
for (const filter of followUpFilters) {
|
||||||
|
if (Array.isArray(filter?.["#d"])) {
|
||||||
|
for (const identifier of filter["#d"]) {
|
||||||
|
identifiersRequested.add(identifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(filter?.["#snapshot"])) {
|
||||||
|
snapshotRequested = snapshotRequested || filter["#snapshot"].includes(snapshotId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(
|
||||||
|
chunkEvents.every((event) => {
|
||||||
|
const identifier = event.tags.find(
|
||||||
|
(tag) => Array.isArray(tag) && tag[0] === "d"
|
||||||
|
)?.[1];
|
||||||
|
return (
|
||||||
|
identifiersRequested.has(identifier) ||
|
||||||
|
(snapshotId && snapshotRequested)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
"follow-up query should target chunk identifiers or snapshot"
|
||||||
|
);
|
||||||
|
|
||||||
|
const MONOTONIC_ACTOR = `${ACTOR}-monotonic`;
|
||||||
|
const { client: monotonicPublishingClient, publishedEvents: monotonicEvents } =
|
||||||
|
createPublishingClient(MONOTONIC_ACTOR);
|
||||||
|
|
||||||
|
const monotonicOriginalDateNow = Date.now;
|
||||||
|
try {
|
||||||
|
const monotonicBaseSeconds = 1_700_123_456;
|
||||||
|
Date.now = () => monotonicBaseSeconds * 1000;
|
||||||
|
|
||||||
|
const firstMonotonicPointers = [
|
||||||
|
{ type: "e", value: "event-monotonic-1", relay: null },
|
||||||
|
{ type: "e", value: "event-monotonic-2", relay: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const firstSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot(
|
||||||
|
MONOTONIC_ACTOR,
|
||||||
|
firstMonotonicPointers
|
||||||
|
);
|
||||||
|
|
||||||
|
const baselineEntry = monotonicPublishingClient.createWatchHistoryEntry(
|
||||||
|
firstSnapshot.event,
|
||||||
|
firstMonotonicPointers,
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
monotonicPublishingClient.watchHistoryCache.set(
|
||||||
|
MONOTONIC_ACTOR,
|
||||||
|
baselineEntry
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondMonotonicPointers = [
|
||||||
|
{ type: "e", value: "event-monotonic-3", relay: null },
|
||||||
|
{ type: "e", value: "event-monotonic-1", relay: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const secondSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot(
|
||||||
|
MONOTONIC_ACTOR,
|
||||||
|
secondMonotonicPointers,
|
||||||
|
baselineEntry
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
secondSnapshot.event.created_at > firstSnapshot.event.created_at,
|
||||||
|
"new snapshot should advance created_at when reusing the same second"
|
||||||
|
);
|
||||||
|
|
||||||
|
const monotonicDecryptClient = createDecryptClient(MONOTONIC_ACTOR);
|
||||||
|
monotonicDecryptClient.pool = { list: async () => monotonicEvents };
|
||||||
|
|
||||||
|
const monotonicFetched = await monotonicDecryptClient.fetchWatchHistory(
|
||||||
|
MONOTONIC_ACTOR
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
monotonicFetched.items.map((item) => ({
|
||||||
|
type: item.type,
|
||||||
|
value: item.value,
|
||||||
|
relay: item.relay || null,
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{ type: "e", value: "event-monotonic-3", relay: null },
|
||||||
|
{ type: "e", value: "event-monotonic-1", relay: null },
|
||||||
|
],
|
||||||
|
"newer snapshot should win pointer ordering"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
Date.now = monotonicOriginalDateNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.clear();
|
||||||
|
|
||||||
|
const UPDATE_ACTOR = `${ACTOR}-update`;
|
||||||
|
const { client: updateClient } = createPublishingClient(UPDATE_ACTOR);
|
||||||
|
const updateOriginalDateNow = Date.now;
|
||||||
|
try {
|
||||||
|
const updateTimestamp = 1_701_111_111_111;
|
||||||
|
Date.now = () => updateTimestamp;
|
||||||
|
|
||||||
|
const updateResult = await updateClient.updateWatchHistoryList({
|
||||||
|
type: "e",
|
||||||
|
value: "update-pointer",
|
||||||
|
relay: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
updateResult.ok,
|
||||||
|
true,
|
||||||
|
"updateWatchHistoryList should publish when adding a new pointer"
|
||||||
|
);
|
||||||
|
const updateEntry = updateClient.watchHistoryCache.get(UPDATE_ACTOR);
|
||||||
|
assert(updateEntry, "updateWatchHistoryList should cache the new entry");
|
||||||
|
assert.equal(
|
||||||
|
updateEntry.items[0].watchedAt,
|
||||||
|
updateTimestamp,
|
||||||
|
"cached entry should record watchedAt from Date.now()"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
updateResult.items[0].watchedAt,
|
||||||
|
updateTimestamp,
|
||||||
|
"returned publish result should include the watchedAt timestamp"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
Date.now = updateOriginalDateNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REMOVE_ACTOR = `${ACTOR}-remove`;
|
||||||
|
const {
|
||||||
|
client: removalClient,
|
||||||
|
payloads: removalPayloads,
|
||||||
|
} = createPublishingClient(REMOVE_ACTOR);
|
||||||
|
|
||||||
|
const removalPointers = [
|
||||||
|
{
|
||||||
|
type: "e",
|
||||||
|
value: "remove-first",
|
||||||
|
relay: null,
|
||||||
|
watchedAt: 1_701_222_000_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "e",
|
||||||
|
value: "remove-second",
|
||||||
|
relay: null,
|
||||||
|
watchedAt: 1_701_222_100_000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await removalClient.publishWatchHistorySnapshot(
|
||||||
|
REMOVE_ACTOR,
|
||||||
|
removalPointers
|
||||||
|
);
|
||||||
|
|
||||||
|
const removalKey = `e:${removalPointers[1].value.trim().toLowerCase()}`;
|
||||||
|
const removalResult = await removalClient.removeWatchHistoryItem(removalKey);
|
||||||
|
|
||||||
|
assert.equal(removalResult.ok, true, "removeWatchHistoryItem should succeed");
|
||||||
|
assert.equal(
|
||||||
|
removalResult.removedKey,
|
||||||
|
removalKey,
|
||||||
|
"removeWatchHistoryItem should echo the removed pointer key"
|
||||||
|
);
|
||||||
|
|
||||||
|
const latestRemovalPayload = removalPayloads[removalPayloads.length - 1];
|
||||||
|
assert.equal(
|
||||||
|
latestRemovalPayload.items.length,
|
||||||
|
1,
|
||||||
|
"removal republish should trim the pointer list"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
latestRemovalPayload.items[0].value,
|
||||||
|
removalPointers[0].value,
|
||||||
|
"republished payload should only include remaining pointer"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
latestRemovalPayload.items[0].watchedAt,
|
||||||
|
removalPointers[0].watchedAt,
|
||||||
|
"republished payload should preserve watchedAt"
|
||||||
|
);
|
||||||
|
|
||||||
|
const removalEntry = removalClient.watchHistoryCache.get(REMOVE_ACTOR);
|
||||||
|
assert.equal(
|
||||||
|
removalEntry.items.length,
|
||||||
|
1,
|
||||||
|
"watch history cache should drop the removed pointer"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
removalEntry.items[0].value,
|
||||||
|
removalPointers[0].value,
|
||||||
|
"remaining cached pointer should match the survivor"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
removalEntry.items[0].watchedAt,
|
||||||
|
removalPointers[0].watchedAt,
|
||||||
|
"remaining cached pointer should retain watchedAt"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
removalResult.items.map((item) => item.value).join(","),
|
||||||
|
removalPointers.slice(0, 1).map((item) => item.value).join(","),
|
||||||
|
"removeWatchHistoryItem result should reflect updated items"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
removalResult.items[0].watchedAt,
|
||||||
|
removalPointers[0].watchedAt,
|
||||||
|
"removeWatchHistoryItem result should preserve watchedAt"
|
||||||
|
);
|
||||||
|
|
||||||
|
const VIDEO_POINTER_AUTHOR = `${ACTOR}-video-author`;
|
||||||
|
const VIDEO_POINTER_IDENTIFIER = "pointer-video-identifier";
|
||||||
|
const videoPointer = {
|
||||||
|
type: "a",
|
||||||
|
value: `${WATCH_HISTORY_KIND}:${VIDEO_POINTER_AUTHOR}:${VIDEO_POINTER_IDENTIFIER}`,
|
||||||
|
relay: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pointerClient = createDecryptClient(ACTOR);
|
||||||
|
const pointerEntry = pointerClient.createWatchHistoryEntry(
|
||||||
|
null,
|
||||||
|
[videoPointer],
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
pointerClient.watchHistoryCache.set(ACTOR, pointerEntry);
|
||||||
|
pointerClient.fetchWatchHistory = async () => {};
|
||||||
|
|
||||||
|
const observedResolveFilters = [];
|
||||||
|
const videoEvent = {
|
||||||
|
id: "video-pointer-event",
|
||||||
|
kind: WATCH_HISTORY_KIND,
|
||||||
|
pubkey: VIDEO_POINTER_AUTHOR,
|
||||||
|
created_at: 1_700_555_000,
|
||||||
|
tags: [["d", VIDEO_POINTER_IDENTIFIER]],
|
||||||
|
content: JSON.stringify({
|
||||||
|
version: 3,
|
||||||
|
title: "Pointer Video",
|
||||||
|
url: "https://cdn.example.com/video.mp4",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
pointerClient.pool = {
|
||||||
|
list: async (_relays, filters) => {
|
||||||
|
observedResolveFilters.push(filters);
|
||||||
|
return [videoEvent];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedFromPointer = await pointerClient.resolveWatchHistory(1);
|
||||||
|
|
||||||
|
const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolvedFromPointer.length,
|
||||||
|
1,
|
||||||
|
"resolveWatchHistory should return results for 'a' style video pointers"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolvedFromPointer[0].id,
|
||||||
|
videoEvent.id,
|
||||||
|
"resolved video should match the pointer event id"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolvedFromPointer[0].title,
|
||||||
|
"Pointer Video",
|
||||||
|
"resolved video should parse title from the event payload"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(resolvedFromPointer[0].watchHistory, "resolved video should include watchHistory metadata");
|
||||||
|
assert.equal(
|
||||||
|
resolvedFromPointer[0].watchHistory.key,
|
||||||
|
pointerKey,
|
||||||
|
"watchHistory metadata should expose the pointer key"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolvedFromPointer[0].watchHistory.pointer.value,
|
||||||
|
videoPointer.value,
|
||||||
|
"watchHistory metadata should include the pointer payload"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolvedFromPointer[0].watchHistory.pointer.watchedAt,
|
||||||
|
pointerEntry.items[0].watchedAt,
|
||||||
|
"watchHistory metadata should preserve watchedAt"
|
||||||
|
);
|
||||||
|
|
||||||
|
const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR);
|
||||||
|
assert(
|
||||||
|
cachedEntry.resolvedVideos.has(pointerKey),
|
||||||
|
"resolved cache should retain the video"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
cachedEntry.resolvedVideos.get(pointerKey)?.id,
|
||||||
|
videoEvent.id,
|
||||||
|
"resolved cache should store the converted video result"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
cachedEntry.delivered.has(pointerKey),
|
||||||
|
"delivery set should mark the pointer as delivered"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
observedResolveFilters.some((filters) =>
|
||||||
|
filters.some((filter) =>
|
||||||
|
Array.isArray(filter?.["#d"]) &&
|
||||||
|
filter["#d"].includes(VIDEO_POINTER_IDENTIFIER)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"resolveWatchHistory should request the video identifier instead of treating it as a chunk"
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondResolve = await pointerClient.resolveWatchHistory(1);
|
||||||
|
assert.equal(
|
||||||
|
secondResolve.length,
|
||||||
|
0,
|
||||||
|
"subsequent resolve calls should avoid refetching already delivered videos"
|
||||||
|
);
|
||||||
|
|
||||||
|
pointerClient.resetWatchHistoryProgress(ACTOR);
|
||||||
|
assert.equal(
|
||||||
|
cachedEntry.delivered.size,
|
||||||
|
0,
|
||||||
|
"resetWatchHistoryProgress should clear delivered pointers"
|
||||||
|
);
|
||||||
|
|
||||||
|
pointerClient.pool = {
|
||||||
|
list: async () => {
|
||||||
|
throw new Error("cached resolve should not trigger network fetch");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cachedResolve = await pointerClient.resolveWatchHistory(1);
|
||||||
|
assert.equal(
|
||||||
|
cachedResolve.length,
|
||||||
|
1,
|
||||||
|
"reset should allow cached videos to be delivered again"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
cachedResolve[0].id,
|
||||||
|
videoEvent.id,
|
||||||
|
"cached resolve should reuse the stored video payload"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
cachedResolve[0].watchHistory?.watchedAt === pointerEntry.items[0].watchedAt,
|
||||||
|
"cached resolve should retain watchHistory metadata"
|
||||||
|
);
|
||||||
|
|
||||||
|
const originalDateNow = Date.now;
|
||||||
|
try {
|
||||||
|
const baseTime = 1_700_000_000_000;
|
||||||
|
const ttlActor = `${ACTOR}-ttl`;
|
||||||
|
const pointer = { type: "e", value: "event-ttl", relay: null };
|
||||||
|
|
||||||
|
const cachingClient = createDecryptClient(ttlActor);
|
||||||
|
cachingClient.watchHistoryCacheTtlMs = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
Date.now = () => baseTime;
|
||||||
|
|
||||||
|
const entry = cachingClient.createWatchHistoryEntry(null, [pointer], baseTime);
|
||||||
|
cachingClient.watchHistoryCache.set(ttlActor, entry);
|
||||||
|
cachingClient.persistWatchHistoryEntry(ttlActor, entry);
|
||||||
|
|
||||||
|
const reloadClient = createDecryptClient(ttlActor);
|
||||||
|
reloadClient.watchHistoryCacheTtlMs = cachingClient.watchHistoryCacheTtlMs;
|
||||||
|
|
||||||
|
Date.now = () => baseTime + 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const cachedSnapshot = await reloadClient.fetchWatchHistory(ttlActor);
|
||||||
|
assert.equal(
|
||||||
|
cachedSnapshot.items.length,
|
||||||
|
1,
|
||||||
|
"cached snapshot should survive within extended TTL"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
cachedSnapshot.items[0].value,
|
||||||
|
pointer.value,
|
||||||
|
"cached snapshot should preserve pointer"
|
||||||
|
);
|
||||||
|
|
||||||
|
localStorage.clear();
|
||||||
|
|
||||||
|
const expiryClient = createDecryptClient(ttlActor);
|
||||||
|
expiryClient.watchHistoryCacheTtlMs = 1_000;
|
||||||
|
|
||||||
|
Date.now = () => baseTime;
|
||||||
|
|
||||||
|
const expiringEntry = expiryClient.createWatchHistoryEntry(
|
||||||
|
null,
|
||||||
|
[pointer],
|
||||||
|
baseTime
|
||||||
|
);
|
||||||
|
expiryClient.watchHistoryCache.set(ttlActor, expiringEntry);
|
||||||
|
expiryClient.persistWatchHistoryEntry(ttlActor, expiringEntry);
|
||||||
|
|
||||||
|
Date.now = () => baseTime + 1_500;
|
||||||
|
|
||||||
|
const expiredSnapshot = await expiryClient.fetchWatchHistory(ttlActor);
|
||||||
|
assert.equal(
|
||||||
|
expiredSnapshot.items.length,
|
||||||
|
0,
|
||||||
|
"expired snapshot should be dropped"
|
||||||
|
);
|
||||||
|
const cachedFallback = expiryClient.watchHistoryCache.get(ttlActor);
|
||||||
|
assert.equal(
|
||||||
|
cachedFallback?.items?.length ?? 0,
|
||||||
|
0,
|
||||||
|
"expired snapshot should not preserve stale items in memory"
|
||||||
|
);
|
||||||
|
const storage = expiryClient.getWatchHistoryStorage();
|
||||||
|
assert.equal(
|
||||||
|
Object.prototype.hasOwnProperty.call(storage.actors, ttlActor),
|
||||||
|
false,
|
||||||
|
"expired snapshot should be removed from localStorage"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
Date.now = originalDateNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("watch history tests passed");
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!-- views/history.html -->
|
||||||
|
<section id="watchHistoryView" class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-2xl font-semibold text-white">Watch History</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="watchHistoryLoading"
|
||||||
|
class="flex flex-col items-center justify-center py-12 space-y-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-10 w-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
<p
|
||||||
|
id="watchHistoryStatus"
|
||||||
|
class="text-gray-300 text-center hidden"
|
||||||
|
aria-live="polite"
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
|
<div id="watchHistoryGrid" class="watch-history-grid hidden"></div>
|
||||||
|
<p id="watchHistoryEmpty" class="hidden text-gray-400 text-center py-12">
|
||||||
|
Your watch history is empty. Watch some videos to populate this list.
|
||||||
|
</p>
|
||||||
|
<div id="watchHistorySentinel" class="h-1 w-full"></div>
|
||||||
|
<script type="module" src="js/historyView.js"></script>
|
||||||
|
</section>
|
||||||
Reference in new issue
Block a user