added channel/profile view

This commit is contained in:
2025-02-09 14:51:20 -05:00
parent 8939f52f20
commit f9d81ecb24
7 changed files with 573 additions and 95 deletions

156
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) {
@@ -349,6 +349,56 @@ class bitvidApp {
this.showError("Could not generate link.");
}
});
}
// 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.");
}
}
@@ -1045,7 +1095,7 @@ 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,30 +1103,25 @@ 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
)}`;
const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(nevent)}`;
const canEdit = video.pubkey === this.pubkey;
const highlightClass =
video.isPrivate && canEdit
@@ -1084,7 +1129,7 @@ class bitvidApp {
: "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);
@@ -1101,7 +1146,6 @@ class bitvidApp {
`
: "";
// Gear menu (only shown if canEdit)
const gearMenu = canEdit
? `
<div class="relative inline-block ml-3 overflow-visible">
@@ -1140,9 +1184,9 @@ class bitvidApp {
`
: "";
// 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 +1201,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 +1227,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>
@@ -1191,15 +1237,13 @@ class bitvidApp {
</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);
@@ -1207,14 +1251,8 @@ class bitvidApp {
const lazyEls = this.videoList.querySelectorAll("[data-lazy]");
lazyEls.forEach((el) => this.mediaLoader.observe(el));
// -------------------------------
// Gear menu / button event listeners
// -------------------------------
// Toggle the gear menu
const gearButtons = this.videoList.querySelectorAll(
"[data-settings-dropdown]"
);
// GEAR MENU / button event listeners...
const gearButtons = this.videoList.querySelectorAll("[data-settings-dropdown]");
gearButtons.forEach((button) => {
button.addEventListener("click", () => {
const index = button.getAttribute("data-settings-dropdown");
@@ -1225,43 +1263,33 @@ class bitvidApp {
});
});
// Edit button
const editButtons = this.videoList.querySelectorAll("[data-edit-index]");
editButtons.forEach((button) => {
button.addEventListener("click", () => {
const index = button.getAttribute("data-edit-index");
const dropdown = document.getElementById(`settingsDropdown-${index}`);
if (dropdown) dropdown.classList.add("hidden");
this.handleEditVideo(index);
});
});
// Revert button
const revertButtons = this.videoList.querySelectorAll("[data-revert-index]");
revertButtons.forEach((button) => {
button.addEventListener("click", () => {
const index = button.getAttribute("data-revert-index");
const dropdown = document.getElementById(`settingsDropdown-${index}`);
if (dropdown) dropdown.classList.add("hidden");
this.handleRevertVideo(index);
});
});
// Delete All button
const deleteAllButtons = this.videoList.querySelectorAll(
"[data-delete-all-index]"
);
deleteAllButtons.forEach((button) => {
button.addEventListener("click", () => {
const index = button.getAttribute("data-delete-all-index");
const dropdown = document.getElementById(`settingsDropdown-${index}`);
if (dropdown) dropdown.classList.add("hidden");
this.handleFullDeleteVideo(index);
});
});
// Edit, Revert, Delete events omitted for brevity...
// 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);
});
});
}
/**

403
js/channelProfile.js Normal file
View File

@@ -0,0 +1,403 @@
// js/channelProfile.js
import { nostrClient } from "./nostr.js";
import { app } from "./app.js";
import { initialBlacklist, initialWhitelist } from "./lists.js";
import { isWhitelistEnabled } from "./config.js";
/**
* Initialize the channel profile view.
* Called when #view=channel-profile is active.
*/
export async function initChannelProfileView() {
// 1) Get npub from hash (e.g. #view=channel-profile&npub=...)
const hashParams = new URLSearchParams(window.location.hash.slice(1));
const npub = hashParams.get("npub");
if (!npub) {
console.error("No npub found in hash. Example: #view=channel-profile&npub=npub1...");
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) Load users profile (banner, avatar, etc.)
await loadUserProfile(hexPub);
// 4) Load users videos (filtered and rendered like home feed)
await loadUserVideos(hexPub);
}
/**
* Fetches and displays the users 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),
* filtering out older/overshadowed events and blacklisted/nonwhitelisted entries.
* Renders the video cards using the same `.ratio-16-9` approach as the home view.
*/
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 events 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 event IDs and authors
videos = videos.filter((video) => {
// Event-level blacklisting
if (app.blacklistedEventIds.has(video.id)) {
return false;
}
// Author-level checks
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 videos by newest first
videos.sort((a, b) => b.created_at - a.created_at);
// 7) Render the videos in #channelVideoList
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();
// We'll store them in a local array so gear handlers match the indexes
const channelVideos = videos;
channelVideos.forEach((video, index) => {
// If private + the user is the owner => decrypt
if (
video.isPrivate &&
video.pubkey === nostrClient.pubkey &&
!video.alreadyDecrypted
) {
video.magnet = fakeDecrypt(video.magnet);
video.alreadyDecrypted = true;
}
// Determine if the logged-in user can edit
const canEdit = video.pubkey === app.pubkey;
let gearMenu = "";
if (canEdit) {
gearMenu = `
<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>
<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>
`;
}
// Reuse the `.ratio-16-9` approach from home feed
const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg";
const safeThumb = video.thumbnail || fallbackThumb;
// Build the card
const cardEl = document.createElement("div");
cardEl.classList.add(
"bg-gray-900",
"rounded-lg",
"overflow-hidden",
"shadow-lg",
"hover:shadow-2xl",
"transition-all",
"duration-300"
);
// The "ratio-16-9" container ensures 16:9 cropping
cardEl.innerHTML = `
<div class="cursor-pointer relative group">
<div class="ratio-16-9">
<img
src="${fallbackThumb}"
data-lazy="${escapeHTML(safeThumb)}"
alt="${escapeHTML(video.title)}"
/>
</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)}"
>
${escapeHTML(video.title)}
</h3>
<p class="text-sm text-gray-500">
${new Date(video.created_at * 1000).toLocaleString()}
</p>
</div>
${gearMenu}
</div>
`;
// Clicking the card (except gear) => open video
cardEl.addEventListener("click", () => {
app.playVideoByEventId(video.id);
});
fragment.appendChild(cardEl);
});
container.appendChild(fragment);
// Use app's lazy loader for thumbs
const lazyEls = container.querySelectorAll("[data-lazy]");
lazyEls.forEach((el) => 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 editBtns = container.querySelectorAll("[data-edit-index]");
editBtns.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = parseInt(btn.getAttribute("data-edit-index"), 10);
app.handleEditVideo(idx);
});
});
// "Delete All" button
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);
app.handleFullDeleteVideo(idx);
});
});
} catch (err) {
console.error("Error loading user videos:", err);
}
}
/**
* Minimal placeholder decryption for private videos.
*/
function fakeDecrypt(str) {
return str.split("").reverse().join("");
}
/**
* Deduplicate older overshadowed versions return only the newest for each 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 a raw Nostr 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" };
}
}
/**
* Escape HTML to prevent injection or 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

@@ -1,7 +1,9 @@
// js/viewManager.js
import { initChannelProfileView } from "./channelProfile.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 +12,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 +36,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();
}
@@ -51,5 +56,8 @@ export const viewInitRegistry = {
subscriptions: () => {
console.log("Subscriptions view loaded.");
},
// Add additional view-specific functions here as needed.
"channel-profile": () => {
// Call the initialization function from channelProfile.js
initChannelProfileView();
}
};