import { html, render } from "/js/lib/lit-html.js";
import { resolveDidFromHandleOrDid } from "/js/atproto.js";
import { wait } from "/js/utils.js";
import { Signal, ReactiveStore } from "/js/signals.js";
import {
doHideAuthorOnUnauthenticated,
isLabelerProfile,
} from "/js/dataHelpers.js";
import { profileCardTemplate } from "/js/templates/profileCard.template.js";
import { floatingComposeButtonTemplate } from "/js/templates/floatingComposeButton.template.js";
import { postFeedTemplate } from "/js/templates/postFeed.template.js";
import { labelerSettingsTemplate } from "/js/templates/labelerSettings.template.js";
import { ApiError } from "/js/api.js";
import { bindToPage, pageEffect, bindPageTitle } from "/js/router.js";
import { AUTHOR_FEED_PAGE_SIZE, BSKY_LABELER_DID } from "/js/config.js";
import { showToast } from "/js/toasts.js";
import "/js/components/tab-bar.js";
import "/js/components/app-icon.js";
import { feedGeneratorListItemTemplate } from "/js/templates/feedGeneratorListItem.template.js";
import { feedsFeedTemplate } from "/js/templates/feedsFeed.template.js";
import { listFeedTemplate } from "/js/templates/listFeed.template.js";
import "/js/components/edit-profile-dialog.js";
import "/js/components/add-to-lists-dialog.js";
import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js";
export default async function profileView({
root,
router,
params,
layout,
context: {
identityResolver,
dataLayer,
postComposerService,
isAuthenticated,
pluginService,
interactionHandlers,
},
}) {
function getAuthorFeeds({ isCurrentUser, isLabeler }) {
return [
{ feedType: "posts", name: "Posts" },
isAuthenticated ? { feedType: "replies", name: "Replies" } : null,
isLabeler ? null : { feedType: "media", name: "Media" },
isCurrentUser ? { feedType: "likes", name: "Likes" } : null,
].filter(Boolean);
}
const state = new ReactiveStore("profileView");
state.$activeTab = new Signal.State("posts");
const { handleOrDid } = params;
const profileDid = await resolveDidFromHandleOrDid(
handleOrDid,
identityResolver,
);
const { postInteractionHandler, profileInteractionHandler } =
interactionHandlers;
async function handleAddToLists(profile) {
const dialog = document.createElement("add-to-lists-dialog");
dialog.profile = profile;
dialog.dataLayer = dataLayer;
dialog.profileInteractionHandler = profileInteractionHandler;
dialog.addEventListener("dialog-closed", () => {
dialog.remove();
});
document.body.appendChild(dialog);
dialog.open();
}
async function handleEditProfile(profile) {
const dialog = document.createElement("edit-profile-dialog");
dialog.addEventListener("profile-save", (event) =>
handleSaveProfile(
profile,
event.detail.profileUpdates,
event.detail.successCallback,
event.detail.errorCallback,
),
);
dialog.addEventListener("edit-profile-closed", () => {
dialog.remove();
});
root.querySelector("main").appendChild(dialog);
dialog.setProfile(profile);
dialog.open();
}
async function handleSaveProfile(
profile,
profileUpdates,
successCallback,
errorCallback,
) {
try {
await dataLayer.mutations.updateProfile(profile, profileUpdates);
showToast("Profile updated");
successCallback();
} catch (error) {
errorCallback(error);
}
}
async function scrollAndReloadFeed() {
if (window.scrollY > 0) {
window.scrollTo({ top: -1, behavior: "smooth" });
}
await loadAuthorFeed({ reload: true });
}
async function handleTabClick(tab) {
const currentTab = state.$activeTab.get();
if (tab === currentTab) {
if (tab === "feeds") {
scrollAndReloadActorFeeds();
} else if (tab === "lists") {
scrollAndReloadActorLists();
} else {
scrollAndReloadFeed();
}
return;
}
// switch tab
state.$activeTab.set(tab);
// Load feed if needed
if (tab === "feeds") {
if (!dataLayer.derived.$actorFeeds.get(profileDid)) {
await loadActorFeeds();
}
} else if (tab === "lists") {
if (!dataLayer.derived.$actorLists.get(profileDid)) {
await loadActorLists();
}
} else {
const isFeedTab = tab !== "labeler-settings";
if (isFeedTab && !dataLayer.hasCachedAuthorFeed(profileDid, tab)) {
await loadAuthorFeed();
}
}
}
async function handleLabelerSettingsClick(labelerDid, label, visibility) {
try {
await dataLayer.mutations.updateLabelerSetting({
labelerDid,
label,
visibility,
});
} catch (error) {
console.error(error);
showToast("Failed to update labeler setting", { style: "error" });
}
}
function actorFeedsTemplate({ actorFeeds, onLoadMore, currentUserDid }) {
return feedsFeedTemplate({
items: actorFeeds?.feeds,
renderItem: (feedGenerator) =>
feedGeneratorListItemTemplate({ feedGenerator, currentUserDid }),
emptyMessage: "No custom feeds.",
hasMore: !!actorFeeds?.cursor,
onLoadMore,
});
}
function isNotFoundError(error) {
return error instanceof ApiError && error.status === 400;
}
function getNotFoundMessage(notFoundError) {
if (
notFoundError.data.message ===
"Error: actor must be a valid did or a handle"
)
return "Invalid handle";
if (notFoundError.data.error === "AccountTakedown")
return "Account has been suspended";
if (notFoundError.data.error === "AccountDeactivated")
return "Account is deactivated";
return "Profile not found";
}
function profileErrorTemplate({ error }) {
if (isNotFoundError(error)) {
const message = getNotFoundMessage(error);
return html`
Not Found
${message}
${tryAgainButtonTemplate()}
`;
}
console.error(error);
return html`
There was an error loading the profile.
${tryAgainButtonTemplate()}
`;
}
function profileUnavailableTemplate() {
return html`
Sign-In Required
This account has requested that users sign in to view their profile.
`;
}
function profileTemplate({
profile,
isLabeler,
labelerInfo,
currentUser,
activeTab,
}) {
try {
if (!isAuthenticated && doHideAuthorOnUnauthenticated(profile)) {
return profileUnavailableTemplate();
}
const isBlocking = !!profile.viewer?.blocking;
const isBlockedBy = !!profile.viewer?.blockedBy;
const profileChatStatus = dataLayer.derived.$profileChatStatus.get(
profile.did,
);
const isCurrentUser = currentUser?.did === profile.did;
const liveStatus = dataLayer.derived.$actorLiveStatus.get(profile.did);
let authorFeedsToShow = getAuthorFeeds({ isCurrentUser, isLabeler });
const feedGenCount = profile.associated?.feedgens || 0;
if (feedGenCount > 0) {
authorFeedsToShow = [
...authorFeedsToShow,
{ feedType: "feeds", name: "Feeds" },
];
}
const listsCount = profile.associated?.lists || 0;
if (listsCount > 0) {
authorFeedsToShow = [
...authorFeedsToShow,
{ feedType: "lists", name: "Lists" },
];
}
let isDefaultLabeler = profile.did === BSKY_LABELER_DID;
let isSubscribed = false;
let labelerSettings = null;
if (isLabeler) {
const preferences = dataLayer.derived.$preferences.get();
isSubscribed = isDefaultLabeler
? true
: preferences?.isSubscribedToLabeler(profile.did);
labelerSettings = dataLayer.derived.$labelerSettings.get(profile.did);
}
return html`
${profileCardTemplate({
profile,
identityResolver,
isAuthenticated,
isCurrentUser,
profileChatStatus,
isLabeler,
showSubscribeButton: !isDefaultLabeler,
labelerInfo,
isSubscribed,
activitySubscription: profile.viewer?.activitySubscription ?? null,
onClickPostNotifications: (profile) =>
profileInteractionHandler.handlePostNotificationSubscription(
profile,
),
onClickChat: async (profile) => {
if (!profileChatStatus || !profileChatStatus.canChat) {
// This should never happen
return;
}
if (profileChatStatus.convo) {
window.router.go(`/messages/${profileChatStatus.convo.id}`);
} else {
const convo = await dataLayer.declarative.ensureConvoForProfile(
profile.did,
);
window.router.go(`/messages/${convo.id}`);
}
},
onClickFollow: (profile, doFollow) =>
profileInteractionHandler.handleFollow(profile, doFollow),
onClickMute: (profile, doMute) =>
profileInteractionHandler.handleMute(profile, doMute),
onClickBlock: async (profile, doBlock) => {
await profileInteractionHandler.handleBlock(profile, doBlock);
if (!doBlock) {
// wait for the app view to process that the block has been lifted, then reload the feed
// We could do some fancier logic here but this is a good enough solution for now.
await wait(2000);
loadAuthorFeed();
preloadHiddenFeeds({ isCurrentUser, isLabeler });
}
},
onClickSubscribe: (profile, doSubscribe, labelerInfo) =>
profileInteractionHandler.handleSubscribe(
profile,
doSubscribe,
labelerInfo,
),
onClickReport: (profile) =>
profileInteractionHandler.handleReport(profile),
onClickAddToLists: (profile) => handleAddToLists(profile),
onClickEditProfile: () => handleEditProfile(profile),
liveStatus,
pluginService,
isFollowPending: dataLayer.derived.$isFollowPending.get(
profile.did,
),
isBlockPending: dataLayer.derived.$isBlockPending.get(profile.did),
})}
${isBlocking || isBlockedBy
? html`
`
: html`
({
value: feedInfo.feedType,
label: feedInfo.name,
})),
]}
active-tab=${activeTab}
@tab-click=${(event) => handleTabClick(event.detail)}
>
${isLabeler
? html`
${labelerSettingsTemplate({
labelerInfo,
profile,
isSubscribed,
labelerSettings,
onClick: (label, visibility) =>
handleLabelerSettingsClick(
profile.did,
label,
visibility,
),
})}
`
: null}
${authorFeedsToShow.map((feedInfo) => {
if (feedInfo.feedType === "feeds") {
const actorFeeds =
dataLayer.derived.$actorFeeds.get(profileDid);
return html`
${actorFeedsTemplate({
actorFeeds,
onLoadMore: () => loadActorFeeds(),
currentUserDid: currentUser?.did,
})}
`;
}
if (feedInfo.feedType === "lists") {
const actorLists =
dataLayer.derived.$actorLists.get(profileDid);
return html`
${listFeedTemplate({
lists: actorLists?.lists,
cursor: actorLists?.cursor,
onLoadMore: () => loadActorLists(),
})}
`;
}
const feedURI = `${profileDid}-${feedInfo.feedType}`;
const authorFeed =
dataLayer.derived.$hydratedAuthorFeeds.get(feedURI);
return html`
${postFeedTemplate({
feed: authorFeed,
currentUser,
isAuthenticated,
postInteractionHandler,
onLoadMore: () => loadAuthorFeed(),
pluginService,
showEndMessage: true,
})}
`;
})}
`}
`;
} catch (error) {
console.error("error", error);
return profileErrorTemplate({ error });
}
}
function profileSkeletonTemplate() {
return html``;
}
bindToPage(root, layout, "active-nav-click", (event) => {
event.preventDefault();
scrollAndReloadFeed();
});
bindPageTitle(root, () => {
const profile = dataLayer.derived.$hydratedDetailedProfiles.get(profileDid);
if (!profile) return null;
if (profile.displayName) {
return `${profile.displayName} (@${profile.handle})`;
}
return `@${profile.handle}`;
});
pageEffect(root, () => {
const profile = dataLayer.derived.$hydratedDetailedProfiles.get(profileDid);
const currentUser = dataLayer.derived.$currentUser.get();
const profileRequestStatus = dataLayer.requests.statusStore.$statuses.get(
"loadDetailedProfile-" + profileDid,
);
const isLabeler = profile && isLabelerProfile(profile);
const labelerInfo = isLabeler
? dataLayer.derived.$labelerInfo.get(profile.did)
: null;
// If labeler, require labeler info to be loaded
const isLoaded = profile && (isLabeler ? !!labelerInfo : true);
const activeTab = state.$activeTab.get();
render(
html`
${(() => {
if (profileRequestStatus.error) {
return profileErrorTemplate({
error: profileRequestStatus.error,
});
} else if (isLoaded) {
return profileTemplate({
profile,
isLabeler,
labelerInfo,
currentUser,
activeTab,
});
} else {
return profileSkeletonTemplate();
}
})()}
${currentUser
? floatingComposeButtonTemplate({
onClick: () => postComposerService.composePost({ currentUser }),
})
: ""}
`,
root,
);
});
async function loadAuthorFeed({ reload = false } = {}) {
const activeTab = state.$activeTab.get();
if (
activeTab === "labeler-settings" ||
activeTab === "feeds" ||
activeTab === "lists"
) {
return;
}
await dataLayer.requests.loadNextAuthorFeedPage(profileDid, activeTab, {
reload,
limit: AUTHOR_FEED_PAGE_SIZE + 1,
});
}
async function loadActorFeeds({ reload = false } = {}) {
await dataLayer.requests.loadActorFeeds(profileDid, { reload });
}
async function scrollAndReloadActorFeeds() {
if (window.scrollY > 0) {
window.scrollTo({ top: -1, behavior: "smooth" });
}
await loadActorFeeds({ reload: true });
}
async function loadActorLists({ reload = false } = {}) {
await dataLayer.requests.loadActorLists(profileDid, { reload });
}
async function scrollAndReloadActorLists() {
if (window.scrollY > 0) {
window.scrollTo({ top: -1, behavior: "smooth" });
}
await loadActorLists({ reload: true });
}
async function preloadHiddenFeeds({ isCurrentUser, isLabeler }) {
const activeTab = state.$activeTab.get();
const feedsToPreload = getAuthorFeeds({
isCurrentUser,
isLabeler,
}).filter((feed) => feed.feedType !== activeTab);
for (const feed of feedsToPreload) {
await dataLayer.requests.loadNextAuthorFeedPage(
profileDid,
feed.feedType,
{
limit: AUTHOR_FEED_PAGE_SIZE + 1,
},
);
}
}
async function loadProfile() {
await dataLayer.requests.loadDetailedProfile(profileDid);
return dataLayer.derived.$hydratedDetailedProfiles.get(profileDid);
}
async function loadPageData() {
const profile = await loadProfile();
if (!profile) {
return;
}
const isLabeler = isLabelerProfile(profile);
if (isLabelerProfile(profile)) {
state.$activeTab.set("labeler-settings");
dataLayer.requests.loadLabelerInfo(profile.did);
}
if (!profile.viewer?.blocking && !profile.viewer?.blockedBy) {
const isCurrentUser =
profile.did === dataLayer.derived.$currentUser.get()?.did;
loadAuthorFeed({ reload: true });
preloadHiddenFeeds({ isCurrentUser, isLabeler });
}
// Load chat status
if (
isAuthenticated &&
profile.did !== dataLayer.derived.$currentUser.get()?.did
) {
dataLayer.requests.loadProfileChatStatus(profile.did);
}
}
loadPageData().catch((error) => console.error(error));
}