Merge pull request #13 from PR0M3TH3AN/unstable

added profile/channel pages, subscription lists and channel subscription feed, removed "private" video flags
This commit is contained in:
thePR0M3TH3AN
2025-02-10 08:10:37 -05:00
committed by GitHub
17 changed files with 1353 additions and 101 deletions

11
assets/svg/feed-icon.svg Normal file
View File

@@ -0,0 +1,11 @@
<?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 id="iconmonstr-weather-64.svg" transform="matrix(1,0,0,1,12,12)">
<g id="iconmonstr-filter-down-lined.svg" transform="matrix(1,0,0,1,-12,-12)">
<path d="M15.344,17.778C15.344,17.364 15.008,17.028 14.594,17.028L9.434,17.028C9.02,17.028 8.684,17.364 8.684,17.778C8.684,18.192 9.02,18.528 9.434,18.528L14.594,18.528C15.008,18.528 15.344,18.192 15.344,17.778ZM17.55,13.778C17.55,13.364 17.214,13.028 16.8,13.028L7.204,13.028C6.79,13.028 6.454,13.364 6.454,13.778C6.454,14.192 6.79,14.528 7.204,14.528L16.8,14.528C17.214,14.528 17.55,14.192 17.55,13.778ZM20,9.778C20,9.364 19.664,9.028 19.25,9.028L4.75,9.028C4.336,9.028 4,9.364 4,9.778C4,10.192 4.336,10.528 4.75,10.528L19.25,10.528C19.664,10.528 20,10.192 20,9.778ZM22,5.778C22,5.364 21.664,5.028 21.25,5.028L2.75,5.028C2.336,5.028 2,5.364 2,5.778C2,6.192 2.336,6.528 2.75,6.528L21.25,6.528C21.664,6.528 22,6.192 22,5.778Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</g>
<g id="iconmonstr-filter-down-lined.svg1" serif:id="iconmonstr-filter-down-lined.svg">
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

16
assets/svg/hot-icon.svg Normal file
View File

@@ -0,0 +1,16 @@
<?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 id="iconmonstr-weather-64.svg" transform="matrix(1,0,0,1,12,12)">
<g id="iconmonstr-whats-hot-1.svg" transform="matrix(1,0,0,1,-12,-12)">
<clipPath id="_clip1">
<rect x="0" y="0" width="24" height="24"/>
</clipPath>
<g clip-path="url(#_clip1)">
<path d="M8.625,0C9.235,7.189 3,9.664 3,15.996C3,20.297 6.069,23.968 12,24C17.931,24.032 21,19.586 21,15.044C21,10.903 18.938,6.998 15.048,4.57C15.972,7.177 14.742,9.558 13.547,10.378C13.617,7.041 12.422,2.089 8.625,0ZM13.336,13C17.091,16.989 14.785,22 11.769,22C9.934,22 8.99,20.735 9,19.423C9.019,16.99 11.737,16.988 13.336,13Z" style="fill:rgb(92,111,138);"/>
</g>
</g>
<g id="iconmonstr-whats-hot-1.svg1" serif:id="iconmonstr-whats-hot-1.svg">
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,9 @@
<?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 id="iconmonstr-weather-64.svg" transform="matrix(1,0,0,1,12,12)">
<g transform="matrix(1,0,0,1,-12,-12)">
<path d="M11,10L0,10L0,8L11,8C11.552,8 12,7.552 12,7C12,6.448 11.552,6 11,6C10.597,6 10.253,6.242 10.095,6.587L8.346,5.631C8.845,4.666 9.84,4 11,4C14.971,4 14.969,10 11,10ZM18,17C18,15.344 16.656,14 15,14L0,14L0,16L15,16C15.552,16 16,16.448 16,17C16,17.552 15.552,18 15,18C14.597,18 14.253,17.758 14.095,17.413L12.346,18.369C12.845,19.334 13.84,20 15,20C16.656,20 18,18.656 18,17ZM19.014,9.345C19.096,8.592 19.726,8 20.5,8C21.327,8 22,8.673 22,9.5C22,10.327 21.327,11 20.5,11L0,11L0,13L20.5,13C22.432,13 24,11.432 24,9.5C24,7.568 22.432,6 20.5,6C18.876,6 17.523,7.116 17.128,8.617L19.014,9.345Z" style="fill:rgb(92,111,138);fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,5 @@
<?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;">
<path d="M21.698,10.658L24,12L11.998,19L0,12L2.301,10.658L11.998,16.316L21.698,10.658ZM11.998,21.315L2.301,15.657L0,17L11.998,24L24,17L21.698,15.658L11.998,21.315ZM24,7L11.998,-0L0,7L11.998,14L24,7Z" style="fill:white;fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 694 B

View File

@@ -25,28 +25,7 @@
<!-- Sticky top bar, if you want a top heading or 'X' button up here -->
<div
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center justify-between"
>
<!-- If you want an X to close, you can add it here, for example:
<button
id="closeDisclaimerBtn"
class="flex items-center justify-center w-10 h-10 rounded-full bg-black/50 hover:bg-black/70 transition-all duration-200 backdrop-blur focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-blue-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
-->
>
</div>
<!-- Main Content -->

View File

@@ -12,11 +12,11 @@
class="flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
>
<img
src="assets/svg/home-icon.svg"
alt="Home"
src="assets/svg/recent-icon.svg"
alt="Recent"
class="w-6 h-6 mr-3 flex-shrink-0"
/>
<span class="sidebar-text">Home</span>
<span class="sidebar-text">Recent</span>
</a>
<a
href="#view=explore"

View File

@@ -105,7 +105,7 @@
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
></textarea>
</div>
<!--
<div class="flex items-center space-x-2">
<input
type="checkbox"
@@ -116,7 +116,7 @@
>Private Listing (Encrypt Magnet)</span
>
</div>
-->
<button
type="submit"
class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"

View File

@@ -2,8 +2,31 @@
> ⚠️ **Note:** If you find a new bug thats not listed here. Please submit a [Bug Report](https://bitvid.network/?modal=bug).
- Speed up loading in subscriptions. Save to local cache?
- Fix "Dev Mode" publishing "Live Mode" notes—add a flag for dev mode posts.
- Fix issue with video post set to private.
- Disable "private video" until I can make it work better.
- Make "private video" work better using nip-04 encryption for magnet field.
- Fix slow back button issues on Firefox.
- Add Amber login support for mobile.
- Add settings (three dots) button for all videos, not the same as gear menu. Only show delete and edit gear for logged-in users videos.
- Add support for "Playlist" lists and other custom lists (named whatever) and also "Watch Later" list and "Watch History".
- Add an "add to playlist" button to the edit button.
- Add "add to watch later" option to edit button.
- Add block button to settings button.
- Add report button to settings button.
- Fix autoplay broken on iPhone chrome.
- Fix playback broken on Safari on iPhone.
- Update note spec v3 transition to include new fields that have been added like "has previous" or "videoRootID" or enentID if null.
- Change "explore" view to "kids" view and add flag to all notes to see if they are for kids.
- Add "seed" lists.
- Make "edit" video a form modal rather than a browser pop-up.
- Add "upload thumbnail" as option to add/edit video form. (use https://apidocs.imgur.com/)
- Fix issue where page refreshes when pulling up a video in the video modal.
- Fix sidebar media query settings on medium-sized screens. (tablet/laptops)
- Fix various "text wrap" issues causing scroll left and right on profile and modal pages.
- Add zaps to profile and video modal pages.
- Add comments to video modal pages.
## Feature Additions

View File

@@ -40,7 +40,7 @@ header img {
padding: 1rem;
}
/* Video Grid */
/* Video Grids */
#videoList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
@@ -48,6 +48,22 @@ header img {
padding: 1.5rem 0;
}
/* Subscriptions grid: same pattern as #videoList */
#subscriptionsVideoList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
padding: 1.5rem 0;
}
/* Now also match for channelVideoList (channel profile) */
#channelVideoList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
padding: 1.5rem 0;
}
/* Video Cards */
.video-card {
background-color: var(--color-card);
@@ -464,7 +480,6 @@ footer a:hover {
padding-top: 56.25%;
background-color: #1e293b;
}
.ratio-16-9 > img {
position: absolute;
top: 0;

191
js/app.js
View File

@@ -299,10 +299,8 @@ class bitvidApp {
}
}
/**
* After we load the video modal, store references in `this.*`.
*/
updateModalElements() {
// Existing references
this.playerModal = document.getElementById("playerModal") || null;
this.modalVideo = document.getElementById("modalVideo") || null;
this.modalStatus = document.getElementById("modalStatus") || null;
@@ -315,20 +313,22 @@ class bitvidApp {
this.videoTitle = document.getElementById("videoTitle") || null;
this.videoDescription = document.getElementById("videoDescription") || null;
this.videoTimestamp = document.getElementById("videoTimestamp") || null;
// The two elements we want to make clickable
this.creatorAvatar = document.getElementById("creatorAvatar") || null;
this.creatorName = document.getElementById("creatorName") || null;
this.creatorNpub = document.getElementById("creatorNpub") || null;
// Copy/Share buttons
this.copyMagnetBtn = document.getElementById("copyMagnetBtn") || null;
this.shareBtn = document.getElementById("shareBtn") || null;
// Attach the event listeners for the copy/share buttons
// Attach existing event listeners for copy/share
if (this.copyMagnetBtn) {
this.copyMagnetBtn.addEventListener("click", () => {
this.handleCopyMagnet();
});
}
// UPDATED: This share button just copies the ?v= URL to the clipboard:
if (this.shareBtn) {
this.shareBtn.addEventListener("click", () => {
if (!this.currentVideo) {
@@ -350,6 +350,56 @@ class bitvidApp {
}
});
}
// Add click handlers for avatar and name => channel profile
if (this.creatorAvatar) {
this.creatorAvatar.style.cursor = "pointer";
this.creatorAvatar.addEventListener("click", () => {
this.openCreatorChannel();
});
}
if (this.creatorName) {
this.creatorName.style.cursor = "pointer";
this.creatorName.addEventListener("click", () => {
this.openCreatorChannel();
});
}
}
goToProfile(pubkey) {
if (!pubkey) {
this.showError("No creator info available.");
return;
}
try {
const npub = window.NostrTools.nip19.npubEncode(pubkey);
// Switch to channel profile view
window.location.hash = `#view=channel-profile&npub=${npub}`;
} catch (err) {
console.error("Failed to go to channel:", err);
this.showError("Could not open channel.");
}
}
openCreatorChannel() {
if (!this.currentVideo || !this.currentVideo.pubkey) {
this.showError("No creator info available.");
return;
}
try {
// Encode the hex pubkey to npub
const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey);
// Close the video modal
this.hideModal();
// Switch to channel profile view
window.location.hash = `#view=channel-profile&npub=${npub}`;
} catch (err) {
console.error("Failed to open creator channel:", err);
this.showError("Could not open channel.");
}
}
/**
@@ -658,31 +708,33 @@ class bitvidApp {
async batchFetchProfiles(authorSet) {
const pubkeys = Array.from(authorSet);
if (!pubkeys.length) return;
const filter = {
kinds: [0],
authors: pubkeys,
limit: pubkeys.length,
};
try {
// Query each relay
const results = await Promise.all(
nostrClient.relays.map(relayUrl =>
nostrClient.relays.map((relayUrl) =>
nostrClient.pool.list([relayUrl], [filter])
)
);
const allProfileEvents = results.flat();
// Keep only the newest per author
const newestEvents = new Map();
for (const evt of allProfileEvents) {
if (!newestEvents.has(evt.pubkey) ||
evt.created_at > newestEvents.get(evt.pubkey).created_at) {
if (
!newestEvents.has(evt.pubkey) ||
evt.created_at > newestEvents.get(evt.pubkey).created_at
) {
newestEvents.set(evt.pubkey, evt);
}
}
// Update the cache & DOM
for (const [pubkey, evt] of newestEvents.entries()) {
try {
@@ -701,7 +753,7 @@ class bitvidApp {
console.error("Batch profile fetch error:", err);
}
}
updateProfileInDOM(pubkey, profile) {
// For any .author-pic[data-pubkey=...]
const picEls = document.querySelectorAll(
@@ -741,7 +793,7 @@ class bitvidApp {
thumbnail: thumbEl?.value.trim() || "",
description: descEl?.value.trim() || "",
mode: isDevMode ? "dev" : "live",
isPrivate: privEl?.checked || false,
// isPrivate: privEl?.checked || false,
};
if (!formData.title || !formData.magnet) {
@@ -973,10 +1025,10 @@ class bitvidApp {
});
if (this.videoSubscription) {
console.log("[loadVideos] subscription remains open to get live updates.");
console.log(
"[loadVideos] subscription remains open to get live updates."
);
}
} else {
// Already subscribed: just show what's cached
const allCached = nostrClient.getActiveVideos();
@@ -1005,12 +1057,12 @@ class bitvidApp {
async loadOlderVideos(lastTimestamp) {
// 1) Use nostrClient to fetch older slices
const olderVideos = await nostrClient.fetchOlderVideos(lastTimestamp);
if (!olderVideos || olderVideos.length === 0) {
this.showSuccess("No more older videos found.");
return;
}
// 2) Merge them into the clients allEvents / activeMap
for (const v of olderVideos) {
nostrClient.allEvents.set(v.id, v);
@@ -1019,11 +1071,11 @@ class bitvidApp {
// You can call getActiveKey(v) if you want to match your codes approach.
// Then re-check if this one is newer than whats stored, etc.
}
// 3) Re-render
const all = nostrClient.getActiveVideos();
this.renderVideoList(all);
}
}
/**
* Returns true if there's at least one strictly older version
@@ -1044,8 +1096,8 @@ class bitvidApp {
async renderVideoList(videos) {
if (!this.videoList) return;
// Check if there's anything to show
// 1) If no videos
if (!videos || videos.length === 0) {
this.videoList.innerHTML = `
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
@@ -1053,26 +1105,23 @@ class bitvidApp {
</p>`;
return;
}
// Sort newest first
// 2) Sort newest first
videos.sort((a, b) => b.created_at - a.created_at);
// Convert allEvents to an array for checking older overshadowed events
const fullAllEventsArray = Array.from(nostrClient.allEvents.values());
const fragment = document.createDocumentFragment();
// 1) Collect authors here so we can fetch profiles in one go
const authorSet = new Set();
// 3) Build each card
videos.forEach((video, index) => {
if (!video.id || !video.title) {
console.error("Video missing ID/title:", video);
return;
}
// Track this author's pubkey for the batch fetch later
authorSet.add(video.pubkey);
const nevent = window.NostrTools.nip19.neventEncode({ id: video.id });
const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(
nevent
@@ -1083,13 +1132,13 @@ class bitvidApp {
? "border-2 border-yellow-500"
: "border-none";
const timeAgo = this.formatTimeAgo(video.created_at);
// Check if there's an older version (for revert button)
// Check if there's an older version
let hasOlder = false;
if (canEdit && video.videoRootId) {
hasOlder = this.hasOlderVersion(video, fullAllEventsArray);
}
const revertButton = hasOlder
? `
<button
@@ -1100,8 +1149,7 @@ class bitvidApp {
</button>
`
: "";
// Gear menu (only shown if canEdit)
const gearMenu = canEdit
? `
<div class="relative inline-block ml-3 overflow-visible">
@@ -1139,10 +1187,10 @@ class bitvidApp {
</div>
`
: "";
// Card markup
const cardHtml = `
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
<!-- The clickable link to play video -->
<a
href="${shareUrl}"
data-play-magnet="${encodeURIComponent(video.magnet)}"
@@ -1157,6 +1205,7 @@ class bitvidApp {
</div>
</a>
<div class="p-4">
<!-- Title triggers the video modal as well -->
<h3
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
data-play-magnet="${encodeURIComponent(video.magnet)}"
@@ -1182,6 +1231,7 @@ class bitvidApp {
</p>
<div class="flex items-center text-xs text-gray-500 mt-1">
<span>${timeAgo}</span>
<!-- We removed the 'Channel' button here -->
</div>
</div>
</div>
@@ -1190,28 +1240,22 @@ class bitvidApp {
</div>
</div>
`;
// Turn the HTML into an element
const template = document.createElement("template");
template.innerHTML = cardHtml.trim();
const cardEl = template.content.firstElementChild;
fragment.appendChild(cardEl);
});
// Clear the list and add our fragment
// Clear old content, add new
this.videoList.innerHTML = "";
this.videoList.appendChild(fragment);
// Lazy-load images
const lazyEls = this.videoList.querySelectorAll("[data-lazy]");
lazyEls.forEach((el) => this.mediaLoader.observe(el));
// -------------------------------
// Gear menu / button event listeners
// -------------------------------
// Toggle the gear menu
// GEAR MENU / button event listeners...
const gearButtons = this.videoList.querySelectorAll(
"[data-settings-dropdown]"
);
@@ -1224,7 +1268,7 @@ class bitvidApp {
}
});
});
// Edit button
const editButtons = this.videoList.querySelectorAll("[data-edit-index]");
editButtons.forEach((button) => {
@@ -1235,9 +1279,11 @@ class bitvidApp {
this.handleEditVideo(index);
});
});
// Revert button
const revertButtons = this.videoList.querySelectorAll("[data-revert-index]");
const revertButtons = this.videoList.querySelectorAll(
"[data-revert-index]"
);
revertButtons.forEach((button) => {
button.addEventListener("click", () => {
const index = button.getAttribute("data-revert-index");
@@ -1246,7 +1292,7 @@ class bitvidApp {
this.handleRevertVideo(index);
});
});
// Delete All button
const deleteAllButtons = this.videoList.querySelectorAll(
"[data-delete-all-index]"
@@ -1259,11 +1305,34 @@ class bitvidApp {
this.handleFullDeleteVideo(index);
});
});
// 2) After building cards, do one batch profile fetch
this.batchFetchProfiles(authorSet);
// === NEW: attach click listeners to .author-pic and .author-name
const authorPics = this.videoList.querySelectorAll(".author-pic");
authorPics.forEach((pic) => {
pic.style.cursor = "pointer";
pic.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation(); // avoids playing the video
const pubkey = pic.getAttribute("data-pubkey");
this.goToProfile(pubkey);
});
});
const authorNames = this.videoList.querySelectorAll(".author-name");
authorNames.forEach((nameEl) => {
nameEl.style.cursor = "pointer";
nameEl.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation(); // avoids playing the video
const pubkey = nameEl.getAttribute("data-pubkey");
this.goToProfile(pubkey);
});
});
}
/**
* Updates the modal to reflect current torrent stats.
* We remove the unused torrent.status references,
@@ -1357,7 +1426,7 @@ class bitvidApp {
"New Description? (blank=keep existing)",
video.description
);
const wantPrivate = confirm("Make this video private? OK=Yes, Cancel=No");
// const wantPrivate = confirm("Make this video private? OK=Yes, Cancel=No");
// 4) Build final updated fields (or fallback to existing)
const title =
@@ -1372,7 +1441,7 @@ class bitvidApp {
// 5) Create an object with the new data
const updatedData = {
version: video.version || 2,
isPrivate: wantPrivate,
// isPrivate: wantPrivate,
title,
magnet,
thumbnail,

496
js/channelProfile.js Normal file
View File

@@ -0,0 +1,496 @@
// js/channelProfile.js
import { nostrClient } from "./nostr.js";
import { app } from "./app.js";
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
import { initialBlacklist, initialWhitelist } from "./lists.js";
import { isWhitelistEnabled } from "./config.js";
/**
* Initialize the channel profile view.
* Called when #view=channel-profile&npub=...
*/
export async function initChannelProfileView() {
// 1) Get npub from hash
const hashParams = new URLSearchParams(window.location.hash.slice(1));
const npub = hashParams.get("npub");
if (!npub) {
console.error(
"No npub found in hash (e.g. #view=channel-profile&npub=...)"
);
return;
}
// 2) Decode npub => hex pubkey
let hexPub;
try {
const decoded = window.NostrTools.nip19.decode(npub);
if (decoded.type === "npub" && decoded.data) {
hexPub = decoded.data;
} else {
throw new Error("Invalid npub decoding result.");
}
} catch (err) {
console.error("Error decoding npub:", err);
return;
}
// 3) If user is logged in, load subscriptions and show sub/unsub button
if (app.pubkey) {
await subscriptions.loadSubscriptions(app.pubkey);
renderSubscribeButton(hexPub);
} else {
const btn = document.getElementById("subscribeBtnArea");
if (btn) btn.classList.add("hidden");
}
// 4) Load users profile (banner, avatar, etc.)
await loadUserProfile(hexPub);
// 5) Load users videos (filtered + rendered like the home feed)
await loadUserVideos(hexPub);
}
/**
* Renders a Subscribe / Unsubscribe button with an icon,
* using color #fe0032 and the subscribe-button-icon.svg on the left.
*/
function renderSubscribeButton(channelHex) {
const container = document.getElementById("subscribeBtnArea");
if (!container) return;
container.classList.remove("hidden");
const alreadySubscribed = subscriptions.isSubscribed(channelHex);
// We'll use #fe0032 for both subscribe/unsubscribe,
// and the same icon. If you prefer separate logic for unsub, you can do it here.
container.innerHTML = `
<button
id="subscribeToggleBtn"
class="flex items-center gap-2 px-4 py-2 rounded text-white
hover:opacity-90 focus:outline-none"
style="background-color: #fe0032;"
>
<img
src="assets/svg/subscribe-button-icon.svg"
alt="Subscribe Icon"
class="w-5 h-5"
/>
<span>${alreadySubscribed ? "Unsubscribe" : "Subscribe"}</span>
</button>
`;
const toggleBtn = document.getElementById("subscribeToggleBtn");
if (toggleBtn) {
toggleBtn.addEventListener("click", async () => {
if (!app.pubkey) {
console.error("Not logged in => cannot subscribe/unsubscribe.");
return;
}
try {
if (alreadySubscribed) {
await subscriptions.removeChannel(channelHex, app.pubkey);
} else {
await subscriptions.addChannel(channelHex, app.pubkey);
}
// Re-render the button so it toggles state
renderSubscribeButton(channelHex);
} catch (err) {
console.error("Failed to update subscription:", err);
}
});
}
}
/**
* Fetches and displays the user's metadata (kind=0).
*/
async function loadUserProfile(pubkey) {
try {
const events = await nostrClient.pool.list(nostrClient.relays, [
{ kinds: [0], authors: [pubkey], limit: 1 },
]);
if (events.length && events[0].content) {
const meta = JSON.parse(events[0].content);
// Banner
const bannerEl = document.getElementById("channelBanner");
if (bannerEl) {
bannerEl.src = meta.banner || "assets/jpg/default-banner.jpg";
}
// Avatar
const avatarEl = document.getElementById("channelAvatar");
if (avatarEl) {
avatarEl.src = meta.picture || "assets/svg/default-profile.svg";
}
// Channel Name
const nameEl = document.getElementById("channelName");
if (nameEl) {
nameEl.textContent = meta.display_name || meta.name || "Unknown User";
}
// Channel npub
const channelNpubEl = document.getElementById("channelNpub");
if (channelNpubEl) {
const userNpub = window.NostrTools.nip19.npubEncode(pubkey);
channelNpubEl.textContent = userNpub;
}
// About/Description
const aboutEl = document.getElementById("channelAbout");
if (aboutEl) {
aboutEl.textContent = meta.about || "";
}
// Website
const websiteEl = document.getElementById("channelWebsite");
if (websiteEl) {
if (meta.website) {
websiteEl.href = meta.website;
websiteEl.textContent = meta.website;
} else {
websiteEl.textContent = "";
websiteEl.removeAttribute("href");
}
}
// Lightning Address
const lnEl = document.getElementById("channelLightning");
if (lnEl) {
lnEl.textContent =
meta.lud16 || meta.lud06 || "No lightning address found.";
}
} else {
console.warn("No metadata found for this user.");
}
} catch (err) {
console.error("Failed to fetch user profile data:", err);
}
}
/**
* Fetches and displays this user's videos (kind=30078).
* Filters out older overshadowed notes, blacklisted, etc.
*/
async function loadUserVideos(pubkey) {
try {
// 1) Build filter for videos from this pubkey
const filter = {
kinds: [30078],
authors: [pubkey],
"#t": ["video"],
limit: 200,
};
// 2) Collect raw events from all relays
const events = [];
for (const url of nostrClient.relays) {
try {
const result = await nostrClient.pool.list([url], [filter]);
events.push(...result);
} catch (relayErr) {
console.error(`Relay error (${url}):`, relayErr);
}
}
// 3) Convert to "video" objects
let videos = [];
for (const evt of events) {
const vid = localConvertEventToVideo(evt);
if (!vid.invalid && !vid.deleted) {
videos.push(vid);
}
}
// 4) Deduplicate older overshadowed versions => newest only
videos = dedupeToNewestByRoot(videos);
// 5) Filter out blacklisted IDs / authors
videos = videos.filter((video) => {
// Event-level blacklisting
if (app.blacklistedEventIds.has(video.id)) return false;
// Author-level
const authorNpub = app.safeEncodeNpub(video.pubkey) || video.pubkey;
if (initialBlacklist.includes(authorNpub)) return false;
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
return false;
}
return true;
});
// 6) Sort newest first
videos.sort((a, b) => b.created_at - a.created_at);
// 7) Render them
const container = document.getElementById("channelVideoList");
if (!container) {
console.warn("channelVideoList element not found in DOM.");
return;
}
container.innerHTML = "";
if (!videos.length) {
container.innerHTML = `<p class="text-gray-500">No videos to display.</p>`;
return;
}
const fragment = document.createDocumentFragment();
const allKnownEventsArray = Array.from(nostrClient.allEvents.values());
videos.forEach((video, index) => {
// Decrypt if user owns a private video
if (
video.isPrivate &&
video.pubkey === nostrClient.pubkey &&
!video.alreadyDecrypted
) {
video.magnet = fakeDecrypt(video.magnet);
video.alreadyDecrypted = true;
}
// Check if user can edit
const canEdit = video.pubkey === app.pubkey;
let hasOlder = false;
if (canEdit && video.videoRootId) {
hasOlder = app.hasOlderVersion(video, allKnownEventsArray);
}
const revertButton = hasOlder
? `
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400
hover:bg-red-700 hover:text-white"
data-revert-index="${index}"
>
Revert
</button>
`
: "";
let gearMenu = "";
if (canEdit) {
gearMenu = `
<div class="relative inline-block ml-3 overflow-visible">
<button
type="button"
class="inline-flex items-center justify-center
w-10 h-10 p-2
rounded-full text-gray-400 hover:text-gray-200 hover:bg-gray-800
focus:outline-none focus:ring-2 focus:ring-blue-500"
data-settings-dropdown="${index}"
>
<img
src="assets/svg/video-settings-gear.svg"
alt="Settings"
class="w-5 h-5 object-contain"
/>
</button>
<div
id="settingsDropdown-${index}"
class="hidden absolute right-0 bottom-full mb-2 w-32
rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
>
<div class="py-1">
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100
hover:bg-gray-700"
data-edit-index="${index}"
>
Edit
</button>
${revertButton}
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400
hover:bg-red-700 hover:text-white"
data-delete-all-index="${index}"
>
Delete All
</button>
</div>
</div>
</div>
`;
}
// Fallback thumbnail
const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg";
const safeThumb = video.thumbnail || fallbackThumb;
const safeTitle = escapeHTML(video.title);
const cardEl = document.createElement("div");
cardEl.classList.add(
"bg-gray-900",
"rounded-lg",
"overflow-hidden",
"shadow-lg",
"hover:shadow-2xl",
"transition-all",
"duration-300"
);
cardEl.innerHTML = `
<div class="cursor-pointer relative group">
<div class="ratio-16-9">
<img
src="${fallbackThumb}"
data-lazy="${escapeHTML(safeThumb)}"
alt="${safeTitle}"
/>
</div>
</div>
<div class="p-4 flex items-center justify-between">
<div>
<h3
class="text-lg font-bold text-white mb-2 line-clamp-2"
data-play-magnet="${encodeURIComponent(video.magnet)}"
>
${safeTitle}
</h3>
<p class="text-sm text-gray-500">
${new Date(video.created_at * 1000).toLocaleString()}
</p>
</div>
${gearMenu}
</div>
`;
// Clicking the card => open the video modal
cardEl.addEventListener("click", () => {
app.playVideoByEventId(video.id);
});
fragment.appendChild(cardEl);
});
container.appendChild(fragment);
// Lazy-load images
const lazyEls = container.querySelectorAll("[data-lazy]");
lazyEls.forEach((el) => app.mediaLoader.observe(el));
// Gear menu toggles
const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
gearButtons.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = btn.getAttribute("data-settings-dropdown");
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
if (dropdown) {
dropdown.classList.toggle("hidden");
}
});
});
// Edit handler
const editBtns = container.querySelectorAll("[data-edit-index]");
editBtns.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = parseInt(btn.getAttribute("data-edit-index"), 10);
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
if (dropdown) dropdown.classList.add("hidden");
app.handleEditVideo(idx);
});
});
// Revert handler
const revertBtns = container.querySelectorAll("[data-revert-index]");
revertBtns.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = parseInt(btn.getAttribute("data-revert-index"), 10);
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
if (dropdown) dropdown.classList.add("hidden");
app.handleRevertVideo(idx);
});
});
// Delete All handler
const deleteAllBtns = container.querySelectorAll("[data-delete-all-index]");
deleteAllBtns.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = parseInt(btn.getAttribute("data-delete-all-index"), 10);
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
if (dropdown) dropdown.classList.add("hidden");
app.handleFullDeleteVideo(idx);
});
});
} catch (err) {
console.error("Error loading user videos:", err);
}
}
/**
* Minimal placeholder for private video decryption.
*/
function fakeDecrypt(str) {
return str.split("").reverse().join("");
}
/**
* Keep only the newest version of each video root.
*/
function dedupeToNewestByRoot(videos) {
const map = new Map();
for (const vid of videos) {
const rootId = vid.videoRootId || vid.id;
const existing = map.get(rootId);
if (!existing || vid.created_at > existing.created_at) {
map.set(rootId, vid);
}
}
return Array.from(map.values());
}
/**
* Convert raw event => "video" object.
*/
function localConvertEventToVideo(event) {
try {
const content = JSON.parse(event.content || "{}");
const isSupportedVersion = content.version >= 2;
const hasRequiredFields = !!(content.title && content.magnet);
if (!isSupportedVersion) {
return { id: event.id, invalid: true, reason: "version <2" };
}
if (!hasRequiredFields) {
return { id: event.id, invalid: true, reason: "missing title/magnet" };
}
return {
id: event.id,
videoRootId: content.videoRootId || event.id,
version: content.version,
isPrivate: content.isPrivate ?? false,
title: content.title ?? "",
magnet: content.magnet ?? "",
thumbnail: content.thumbnail ?? "",
description: content.description ?? "",
mode: content.mode ?? "live",
deleted: content.deleted === true,
pubkey: event.pubkey,
created_at: event.created_at,
tags: event.tags,
invalid: false,
};
} catch (err) {
return { id: event.id, invalid: true, reason: "json parse error" };
}
}
/**
* Basic escaping to avoid XSS.
*/
function escapeHTML(unsafe = "") {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

View File

@@ -297,9 +297,14 @@ function handleQueryParams() {
*/
function handleHashChange() {
console.log("handleHashChange called, current hash =", window.location.hash);
const hash = window.location.hash || "";
const match = hash.match(/^#view=(.+)/);
// Use a regex that captures up to the first ampersand or end of string.
// E.g. "#view=channel-profile&npub=..." => viewName = "channel-profile"
const match = hash.match(/^#view=([^&]+)/);
if (!match || !match[1]) {
// No valid "#view=..." => default to "most-recent-videos"
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
loadView("views/most-recent-videos.html").then(() => {
const initFn = viewInitRegistry["most-recent-videos"];
@@ -310,8 +315,11 @@ function handleHashChange() {
});
return;
}
const viewName = match[1];
const viewName = match[1]; // only the chunk before any '&'
const viewUrl = `views/${viewName}.html`;
// Now dynamically load that partial, then call its init function
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
loadView(viewUrl).then(() => {
const initFn = viewInitRegistry[viewName];

View File

@@ -12,6 +12,7 @@ const npubs = [
"npub1rcr8h76csgzhdhea4a7tq5w5gydcpg9clgf0cffu6z45rnc6yp5sj7cfuz", // djmeistro
"npub1m5s9w4t03znyetxswhgq0ud7fq8ef8y3l4kscn2e8wkvmv42hh3qujgjl3", // mister_monster
"npub13qexjtmajssuhz8gdchgx65dwsnr705drse294zz5vt4e78ya2vqzyg8lv", // SatoshiSignal
"npub1da7m2ksdj24995hm8afv88pjpvzt6t9vh70mg8t52yjwtxza3vjszyar58", // GoblinBox
];
console.log("DEBUG: lists.js loaded, npubs:", npubs);

523
js/subscriptions.js Normal file
View File

@@ -0,0 +1,523 @@
// js/subscriptions.js
import { nostrClient } from "./nostr.js";
/**
* Manages the user's subscription list (kind=30002) *privately*,
* using NIP-04 encryption for the content field.
* Also handles fetching and rendering subscribed channels' videos
* in the same card style as your home page.
*/
class SubscriptionsManager {
constructor() {
this.subscribedPubkeys = new Set();
this.subsEventId = null;
this.loaded = false;
}
/**
* Decrypt the subscription list from kind=30002 (d="subscriptions").
*/
async loadSubscriptions(userPubkey) {
if (!userPubkey) {
console.warn("[SubscriptionsManager] No pubkey => cannot load subs.");
return;
}
try {
const filter = {
kinds: [30002],
authors: [userPubkey],
"#d": ["subscriptions"],
limit: 1,
};
const events = [];
for (const url of nostrClient.relays) {
try {
const result = await nostrClient.pool.list([url], [filter]);
if (result && result.length) {
events.push(...result);
}
} catch (err) {
console.error(`[SubscriptionsManager] Relay error at ${url}`, err);
}
}
if (!events.length) {
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.loaded = true;
return;
}
// Sort by created_at desc, pick newest
events.sort((a, b) => b.created_at - a.created_at);
const newest = events[0];
this.subsEventId = newest.id;
let decryptedStr = "";
try {
decryptedStr = await window.nostr.nip04.decrypt(
userPubkey,
newest.content
);
} catch (errDecrypt) {
console.error("[SubscriptionsManager] Decryption failed:", errDecrypt);
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.loaded = true;
return;
}
const parsed = JSON.parse(decryptedStr);
const subArray = Array.isArray(parsed.subPubkeys)
? parsed.subPubkeys
: [];
this.subscribedPubkeys = new Set(subArray);
this.loaded = true;
} catch (err) {
console.error("[SubscriptionsManager] Failed to load subs:", err);
}
}
isSubscribed(channelHex) {
return this.subscribedPubkeys.has(channelHex);
}
async addChannel(channelHex, userPubkey) {
if (!userPubkey) {
throw new Error("No user pubkey => cannot addChannel.");
}
if (this.subscribedPubkeys.has(channelHex)) {
console.log("Already subscribed to", channelHex);
return;
}
this.subscribedPubkeys.add(channelHex);
await this.publishSubscriptionList(userPubkey);
}
async removeChannel(channelHex, userPubkey) {
if (!userPubkey) {
throw new Error("No user pubkey => cannot removeChannel.");
}
if (!this.subscribedPubkeys.has(channelHex)) {
console.log("Channel not found in subscription list:", channelHex);
return;
}
this.subscribedPubkeys.delete(channelHex);
await this.publishSubscriptionList(userPubkey);
}
/**
* Encrypt (NIP-04) + publish the updated subscription set
* as kind=30002 with ["d", "subscriptions"] to be replaceable.
*/
async publishSubscriptionList(userPubkey) {
if (!userPubkey) {
throw new Error("No pubkey => cannot publish subscription list.");
}
const plainObj = { subPubkeys: Array.from(this.subscribedPubkeys) };
const plainStr = JSON.stringify(plainObj);
let cipherText = "";
try {
cipherText = await window.nostr.nip04.encrypt(userPubkey, plainStr);
} catch (err) {
console.error("Encryption failed:", err);
throw err;
}
const evt = {
kind: 30002,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["d", "subscriptions"]],
content: cipherText,
};
try {
const signedEvent = await window.nostr.signEvent(evt);
await Promise.all(
nostrClient.relays.map(async (relay) => {
try {
await nostrClient.pool.publish([relay], signedEvent);
} catch (e) {
console.error(
`[SubscriptionsManager] Failed to publish to ${relay}`,
e
);
}
})
);
this.subsEventId = signedEvent.id;
console.log("Subscription list published, event id:", signedEvent.id);
} catch (signErr) {
console.error("Failed to sign/publish subscription list:", signErr);
}
}
/**
* If not loaded, load subs, then fetch + render videos
* in #subscriptionsVideoList with the same style as app.renderVideoList.
*/
async showSubscriptionVideos(
userPubkey,
containerId = "subscriptionsVideoList"
) {
if (!userPubkey) {
const c = document.getElementById(containerId);
if (c) c.innerHTML = "<p class='text-gray-500'>Please log in first.</p>";
return;
}
if (!this.loaded) {
await this.loadSubscriptions(userPubkey);
}
const channelHexes = Array.from(this.subscribedPubkeys);
const container = document.getElementById(containerId);
if (!container) return;
if (!channelHexes.length) {
container.innerHTML =
"<p class='text-gray-500'>No subscriptions found.</p>";
return;
}
// Gather all videos
const videos = await this.fetchSubscribedVideos(channelHexes);
this.renderSameGridStyle(videos, containerId);
}
/**
* Pull all events from subscribed authors, convert, dedupe => newest
*/
async fetchSubscribedVideos(authorPubkeys) {
try {
const filter = {
kinds: [30078],
"#t": ["video"],
authors: authorPubkeys,
limit: 500,
};
const allEvents = [];
for (const relay of nostrClient.relays) {
try {
const res = await nostrClient.pool.list([relay], [filter]);
allEvents.push(...res);
} catch (rErr) {
console.error(`[SubscriptionsManager] Error at ${relay}`, rErr);
}
}
const videos = [];
for (const evt of allEvents) {
const vid = this.convertEventToVideo(evt);
if (!vid.invalid && !vid.deleted) videos.push(vid);
}
const deduped = this.dedupeToNewestByRoot(videos);
deduped.sort((a, b) => b.created_at - a.created_at);
return deduped;
} catch (err) {
console.error("fetchSubscribedVideos error:", err);
return [];
}
}
/**
* Renders the feed in the same style as home.
* This includes gear menu, time-ago, lazy load, clickable authors, etc.
*/
renderSameGridStyle(videos, containerId) {
const container = document.getElementById(containerId);
if (!container) return;
if (!videos.length) {
container.innerHTML = `
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
No videos available yet.
</p>`;
return;
}
// Sort newest first
videos.sort((a, b) => b.created_at - a.created_at);
const fullAllEventsArray = Array.from(nostrClient.allEvents.values());
const fragment = document.createDocumentFragment();
// Only declare localAuthorSet once
const localAuthorSet = new Set();
videos.forEach((video, index) => {
if (!video.id || !video.title) {
console.error("Missing ID or title:", video);
return;
}
localAuthorSet.add(video.pubkey);
const nevent = window.NostrTools.nip19.neventEncode({ id: video.id });
const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(
nevent
)}`;
const canEdit = window.app?.pubkey === video.pubkey;
const highlightClass =
video.isPrivate && canEdit
? "border-2 border-yellow-500"
: "border-none";
const timeAgo = window.app?.formatTimeAgo
? window.app.formatTimeAgo(video.created_at)
: new Date(video.created_at * 1000).toLocaleString();
let hasOlder = false;
if (canEdit && video.videoRootId && window.app?.hasOlderVersion) {
hasOlder = window.app.hasOlderVersion(video, fullAllEventsArray);
}
const revertButton = hasOlder
? `
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-revert-index="${index}"
>
Revert
</button>
`
: "";
const gearMenu = canEdit
? `
<div class="relative inline-block ml-3 overflow-visible">
<button
type="button"
class="inline-flex items-center p-2 rounded-full text-gray-400 hover:text-gray-200 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
data-settings-dropdown="${index}"
>
<img
src="assets/svg/video-settings-gear.svg"
alt="Settings"
class="w-5 h-5"
/>
</button>
<div
id="settingsDropdown-${index}"
class="hidden absolute right-0 bottom-full mb-2 w-32 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
>
<div class="py-1">
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-edit-index="${index}"
>
Edit
</button>
${revertButton}
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-delete-all-index="${index}"
>
Delete All
</button>
</div>
</div>
</div>
`
: "";
const safeTitle = window.app?.escapeHTML(video.title) || "Untitled";
const safeThumb = window.app?.escapeHTML(video.thumbnail) || "";
const cardHtml = `
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
<a
href="${shareUrl}"
data-play-magnet="${encodeURIComponent(video.magnet)}"
class="block cursor-pointer relative group"
>
<div class="ratio-16-9">
<img
src="assets/jpg/video-thumbnail-fallback.jpg"
data-lazy="${safeThumb}"
alt="${safeTitle}"
/>
</div>
</a>
<div class="p-4">
<h3
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
data-play-magnet="${encodeURIComponent(video.magnet)}"
>
${safeTitle}
</h3>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 rounded-full bg-gray-700 overflow-hidden flex items-center justify-center">
<img
class="author-pic"
data-pubkey="${video.pubkey}"
src="assets/svg/default-profile.svg"
alt="Placeholder"
/>
</div>
<div class="min-w-0">
<p
class="text-sm text-gray-400 author-name"
data-pubkey="${video.pubkey}"
>
Loading name...
</p>
<div class="flex items-center text-xs text-gray-500 mt-1">
<span>${timeAgo}</span>
</div>
</div>
</div>
${gearMenu}
</div>
</div>
</div>
`;
const t = document.createElement("template");
t.innerHTML = cardHtml.trim();
const cardEl = t.content.firstElementChild;
fragment.appendChild(cardEl);
});
container.appendChild(fragment);
// Lazy-load
const lazyEls = container.querySelectorAll("[data-lazy]");
lazyEls.forEach((el) => window.app?.mediaLoader.observe(el));
// Gear menus
const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
gearButtons.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = btn.getAttribute("data-settings-dropdown");
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
if (dropdown) dropdown.classList.toggle("hidden");
});
});
// Edit button
const editButtons = container.querySelectorAll("[data-edit-index]");
editButtons.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = btn.getAttribute("data-edit-index");
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
if (dropdown) dropdown.classList.add("hidden");
window.app?.handleEditVideo(idx);
});
});
// Revert
const revertButtons = container.querySelectorAll("[data-revert-index]");
revertButtons.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = btn.getAttribute("data-revert-index");
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
if (dropdown) dropdown.classList.add("hidden");
window.app?.handleRevertVideo(idx);
});
});
// Delete All
const deleteAllButtons = container.querySelectorAll(
"[data-delete-all-index]"
);
deleteAllButtons.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = btn.getAttribute("data-delete-all-index");
const dd = document.getElementById(`settingsDropdown-${idx}`);
if (dd) dd.classList.add("hidden");
window.app?.handleFullDeleteVideo(idx);
});
});
// Now fetch author profiles
const authorPics = container.querySelectorAll(".author-pic");
const authorNames = container.querySelectorAll(".author-name");
// We only declare localAuthorSet once at the top
// so we don't cause a "duplicate" variable error.
authorPics.forEach((pic) => {
localAuthorSet.add(pic.getAttribute("data-pubkey"));
});
authorNames.forEach((nameEl) => {
localAuthorSet.add(nameEl.getAttribute("data-pubkey"));
});
if (window.app?.batchFetchProfiles && localAuthorSet.size > 0) {
window.app.batchFetchProfiles(localAuthorSet);
}
// Make author name/pic clickable => open channel
authorPics.forEach((pic) => {
pic.style.cursor = "pointer";
pic.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
const pubkey = pic.getAttribute("data-pubkey");
window.app?.goToProfile(pubkey);
});
});
authorNames.forEach((nameEl) => {
nameEl.style.cursor = "pointer";
nameEl.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
const pubkey = nameEl.getAttribute("data-pubkey");
window.app?.goToProfile(pubkey);
});
});
}
convertEventToVideo(evt) {
try {
const content = JSON.parse(evt.content || "{}");
const hasFields = !!(content.title && content.magnet);
const versionOk = content.version >= 2;
if (!versionOk || !hasFields) {
return { id: evt.id, invalid: true };
}
return {
id: evt.id,
pubkey: evt.pubkey,
created_at: evt.created_at,
videoRootId: content.videoRootId || evt.id,
version: content.version,
deleted: content.deleted === true,
isPrivate: content.isPrivate === true,
title: content.title || "",
magnet: content.magnet || "",
thumbnail: content.thumbnail || "",
description: content.description || "",
tags: evt.tags || [],
invalid: false,
};
} catch (err) {
return { id: evt.id, invalid: true };
}
}
dedupeToNewestByRoot(videos) {
const map = new Map();
for (const v of videos) {
const rootId = v.videoRootId || v.id;
const existing = map.get(rootId);
if (!existing || v.created_at > existing.created_at) {
map.set(rootId, v);
}
}
return Array.from(map.values());
}
}
export const subscriptions = new SubscriptionsManager();

View File

@@ -1,7 +1,10 @@
// js/viewManager.js
import { initChannelProfileView } from "./channelProfile.js";
import { subscriptions } from "./subscriptions.js";
// Load a partial view by URL into the #viewContainer
// js/viewManager.js
/**
* Load a partial view by URL into the #viewContainer.
*/
export async function loadView(viewUrl) {
try {
const res = await fetch(viewUrl);
@@ -10,14 +13,14 @@ export async function loadView(viewUrl) {
}
const text = await res.text();
// DOMParser, parse out the body, inject
// Use a DOMParser to extract the body contents
const parser = new DOMParser();
const doc = parser.parseFromString(text, "text/html");
const container = document.getElementById("viewContainer");
container.innerHTML = doc.body.innerHTML;
// Now copy and execute each script
// Copy and execute any inline scripts
const scriptTags = doc.querySelectorAll("script");
scriptTags.forEach((oldScript) => {
const newScript = document.createElement("script");
@@ -34,13 +37,16 @@ export async function loadView(viewUrl) {
}
}
/**
* Registry of view-specific initialization functions.
*/
export const viewInitRegistry = {
"most-recent-videos": () => {
if (window.app && window.app.loadVideos) {
window.app.videoList = document.getElementById("videoList");
window.app.loadVideos();
}
// Force the profiles to update after the new view is in place.
// Force profile updates after the new view is in place.
if (window.app && window.app.forceRefreshAllProfiles) {
window.app.forceRefreshAllProfiles();
}
@@ -48,8 +54,33 @@ export const viewInitRegistry = {
explore: () => {
console.log("Explore view loaded.");
},
subscriptions: () => {
/**
* Subscriptions view:
* - If user is logged in, calls subscriptions.showSubscriptionVideos
* which loads subs if needed and renders the video grid in #subscriptionsVideoList
*/
subscriptions: async () => {
console.log("Subscriptions view loaded.");
if (!window.app.pubkey) {
const container = document.getElementById("subscriptionsVideoList");
if (container) {
container.innerHTML =
"<p class='text-gray-500'>Please log in to see your subscriptions.</p>";
}
return;
}
// If user is logged in, let the SubscriptionsManager do everything:
await subscriptions.showSubscriptionVideos(
window.app.pubkey,
"subscriptionsVideoList"
);
},
"channel-profile": () => {
// Call the initialization function from channelProfile.js
initChannelProfileView();
},
// Add additional view-specific functions here as needed.
};

View File

@@ -0,0 +1,58 @@
<section class="channel-profile-container">
<!-- Modified banner wrapper for taller, rounded style -->
<div
class="profile-banner relative mb-6 rounded-lg overflow-hidden shadow-lg"
>
<!-- Increase the default height classes -->
<img
id="channelBanner"
class="w-full h-48 md:h-64 object-cover"
src=""
alt="Banner"
/>
<div class="absolute inset-0 bg-black bg-opacity-50"></div>
<div class="absolute bottom-4 left-4 flex items-center">
<div class="w-16 h-16 rounded-full overflow-hidden border-2 border-white">
<img
id="channelAvatar"
src="assets/svg/default-profile.svg"
alt="Avatar"
class="w-full h-full object-cover"
/>
</div>
<div class="ml-4 text-white">
<h2 id="channelName" class="text-xl font-bold">User Name</h2>
<p id="channelNpub" class="text-sm opacity-80">npub...</p>
</div>
</div>
</div>
<!-- Basic profile details: about text, website, LN address -->
<div class="profile-details px-4 mt-4">
<p id="channelAbout" class="mb-2 text-gray-700"></p>
<a
id="channelWebsite"
href="#"
target="_blank"
class="block text-blue-500 hover:underline mb-2"
>Website</a
>
<p id="channelLightning" class="text-sm text-gray-500"></p>
</div>
<!-- Area for Subscribe/Unsubscribe button -->
<div class="px-4 my-4">
<div id="subscribeBtnArea" class="hidden"></div>
</div>
<hr class="my-6" />
<h3 class="text-lg font-bold mb-4 text-gray-700">Videos by This User</h3>
<div
id="channelVideoList"
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8"
>
<!-- Users videos will go here -->
</div>
</section>

View File

@@ -1,5 +1,13 @@
<!-- views/subscriptions.html -->
<section>
<h2 class="text-xl mb-4 text-gray-700">Subscriptions</h2>
<p class="text-gray-600">Coming Soon...</p>
<h2 class="text-xl mb-4">Subscriptions</h2>
<div
id="subscriptionsVideoList"
style="
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
padding: 1.5rem 0;
"
></div>
</section>