diff --git a/src/css/style.css b/src/css/style.css index 3dfe0412..ea88a9e8 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1864,6 +1864,13 @@ container-link { gap: 4px; } +@media (hover: hover) { + .small-post .repost-label a:hover, + .reply-to-author a:hover { + text-decoration: underline; + } +} + .small-post .repost-label .icon { height: 17px; width: 17px; @@ -3307,6 +3314,22 @@ image-carousel { font-weight: 600; } +.notification-others-button { + background: none; + border: none; + padding: 0; + margin: 0; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +@media (hover: hover) { + .notification-others-button:hover { + text-decoration: underline; + } +} + .notification-time { color: var(--text-color-muted); font-weight: normal; @@ -10103,6 +10126,12 @@ p.setting-item-desc { } } +.bottom-sheet.profile-list-modal .profile-list-modal-body { + max-height: 60vh; + overflow-y: auto; + margin: 0 -24px; +} + .bottom-sheet.welcome-modal { width: 90%; max-width: 410px; diff --git a/src/js/api.js b/src/js/api.js index 23f1fadd..a8a6478b 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -632,11 +632,14 @@ export class Api { } async sendInteractions(interactions, feedProxyUrl) { + // Without an explicit feed generator to proxy to (e.g. a manual + // moderation action with no feed context), fall back to the main + // AppView, which implements sendInteractions; the PDS itself doesn't. await this.request(`app.bsky.feed.sendInteractions`, { method: "POST", body: { interactions }, headers: { - "atproto-proxy": feedProxyUrl, + "atproto-proxy": feedProxyUrl || this.bskyAppViewServiceDid, }, parseJson: false, // third-party feed might not return JSON }); diff --git a/src/js/dataHelpers.js b/src/js/dataHelpers.js index 5651b90f..99a235e8 100644 --- a/src/js/dataHelpers.js +++ b/src/js/dataHelpers.js @@ -51,6 +51,17 @@ export function valueForPinnedItem(item) { return item.type === "timeline" ? FOLLOWING_FEED_URI : item.data.uri; } +// The atproto-proxy value that routes app.bsky.feed.sendInteractions (and +// post-seen tracking) to a specific feed generator's own service, so +// algorithmic feeds actually receive the signal instead of it silently +// going nowhere useful. +export function getFeedGeneratorProxyUrl(feedGenerator) { + if (!feedGenerator?.did) { + return null; + } + return `${feedGenerator.did}#bsky_fg`; +} + export function getRKey(record) { return record.uri.split("/").pop(); } diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index dabd3e88..42941a74 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -333,7 +333,7 @@ export class Mutations { const showLessInteraction = { item: postURI, event: "app.bsky.feed.defs#requestLess", - feedContext, + ...(feedContext ? { feedContext } : {}), }; this.dataStore.$showLessInteractions.set([ ...this.dataStore.$showLessInteractions.get(), @@ -351,7 +351,7 @@ export class Mutations { const showMoreInteraction = { item: postURI, event: "app.bsky.feed.defs#requestMore", - feedContext, + ...(feedContext ? { feedContext } : {}), }; // Note, we don't really need to store this interaction because we don't use it in the UI (yet). // But, let's do it anyway for consistency. diff --git a/src/js/modals/profileList.modal.js b/src/js/modals/profileList.modal.js new file mode 100644 index 00000000..da14167a --- /dev/null +++ b/src/js/modals/profileList.modal.js @@ -0,0 +1,52 @@ +import { html } from "/js/lib/lit-html.js"; +import { Modal } from "/js/modals/modal.js"; +import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; + +class ProfileListModal extends Modal { + get className() { + return "bottom-sheet text-modal profile-list-modal"; + } + + get attributes() { + return { "data-testid": "profile-list-modal" }; + } + + render({ + props: { + title, + profiles, + isAuthenticated, + currentUserDid, + profileInteractionHandler, + }, + }) { + return html` + + `; + } +} + +// Shows a fixed, already-fetched list of profiles (e.g. everyone in a +// notification group, or a full known-followers list) in a scrollable +// modal. Unlike postLikes/postReposts, this doesn't paginate against the +// network — pass the complete list you already have. +export async function profileListModal(profiles, options = {}) { + return ProfileListModal.open({ profiles, ...options }); +} diff --git a/src/js/templates/postActionBar.template.js b/src/js/templates/postActionBar.template.js index 48fc0c21..582f7b4a 100644 --- a/src/js/templates/postActionBar.template.js +++ b/src/js/templates/postActionBar.template.js @@ -14,7 +14,11 @@ import { repostIconTemplate } from "/js/templates/icons/repostIcon.template.js"; import { replyIconTemplate } from "/js/templates/icons/replyIcon.template.js"; import { heartIconTemplate } from "/js/templates/icons/heartIcon.template.js"; import { bookmarkIconTemplate } from "/js/templates/icons/bookmarkIcon.template.js"; -import { getRKey, canReplyToPost } from "/js/dataHelpers.js"; +import { + getRKey, + canReplyToPost, + getFeedGeneratorProxyUrl, +} from "/js/dataHelpers.js"; import { richTextToString } from "/js/facetHelpers.js"; import { SignInModal } from "/js/modals/signIn.modal.js"; import "/js/components/context-menu.js"; @@ -53,6 +57,7 @@ function postContextMenuTemplate({ isUserPost, isPinnedToProfile, enableFeedFeedback, + feedContext, pluginItems, onClickShowMore, onClickShowLess, @@ -118,13 +123,13 @@ function postContextMenuTemplate({ onClickShowMore(post)} + @click=${() => onClickShowMore(post, feedContext)} > Show more like this onClickShowLess(post)} + @click=${() => onClickShowLess(post, feedContext)} > Show less like this @@ -227,6 +232,10 @@ function postContextMenuTemplate({ async function openPostContextMenu(event, props) { const pluginItems = await props.pluginService.getPostContextMenuItems( props.post, + { + feedContext: props.feedContext ?? null, + feedProxyUrl: getFeedGeneratorProxyUrl(props.feedGenerator), + }, ); const menu = document.createElement("context-menu"); menu.classList.add("post-context-menu"); @@ -245,6 +254,8 @@ export function postActionBarTemplate({ isAuthenticated, currentUser, isUserPost, + feedContext = null, + feedGenerator = null, onClickReply = noop, onClickRepost = noop, onClickQuotePost = noop, @@ -408,6 +419,8 @@ export function postActionBarTemplate({ isUserPost, isPinnedToProfile, enableFeedFeedback, + feedContext, + feedGenerator, pluginService, onClickShowMore, onClickShowLess, diff --git a/src/js/templates/postFeed.template.js b/src/js/templates/postFeed.template.js index 2084d701..978104c0 100644 --- a/src/js/templates/postFeed.template.js +++ b/src/js/templates/postFeed.template.js @@ -110,6 +110,7 @@ function feedItemTemplate({ feedItem, currentUser, isAuthenticated, + feedGenerator, hiddenPostUris, postInteractionHandler, onClickShowLess, @@ -156,6 +157,8 @@ function feedItemTemplate({ currentUser, isAuthenticated, isPinned, + feedContext, + feedGenerator, hiddenPostUris, isUserPost: currentUser?.did === post.author?.did, replyContext: showReplyContext ? "reply" : null, @@ -233,6 +236,7 @@ export function postFeedTemplate({ feedItem, currentUser, isAuthenticated, + feedGenerator, hiddenPostUris, postInteractionHandler, onClickShowLess, diff --git a/src/js/templates/postHeaderText.template.js b/src/js/templates/postHeaderText.template.js index b18de55b..50b45973 100644 --- a/src/js/templates/postHeaderText.template.js +++ b/src/js/templates/postHeaderText.template.js @@ -26,9 +26,16 @@ export function postHeaderTextTemplate({ profile: author, })}${automatedAccountBadgeTemplate({ profile: author })} ${includeHandle - ? html`@${author.handle}` + ? enableProfileLink + ? html`@${author.handle}` + : html`@${author.handle}` : ""} ${includeTime ? html`·${getDisplayName(repostAuthor)}`} ` : ""} ${postHeaderTextTemplate({ @@ -145,7 +150,9 @@ export function smallPostTemplate({ ${replyToAuthor ? replyToAuthor.did === currentUser?.did ? " you" - : html` ${getDisplayName(replyToAuthor)}` + : html` ${getDisplayName(replyToAuthor)}` : " user"} ` : ""} @@ -191,6 +198,8 @@ export function smallPostTemplate({ isUserPost, isAuthenticated, currentUser, + feedContext, + feedGenerator, onClickReply: () => { window.router.go(linkToPost(post)); }, diff --git a/src/js/views/home.view.js b/src/js/views/home.view.js index 80712eda..10e9fde2 100644 --- a/src/js/views/home.view.js +++ b/src/js/views/home.view.js @@ -16,6 +16,7 @@ import { bindToPage, pageEffect } from "/js/router.js"; import { showToast } from "/js/toasts.js"; import { Signal, ReactiveStore } from "/js/signals.js"; import { WelcomeModal } from "/js/modals/welcome.modal.js"; +import { getFeedGeneratorProxyUrl } from "/js/dataHelpers.js"; class HomeView extends View { async render({ @@ -74,13 +75,6 @@ class HomeView extends View { }); } - function getProxyUrl(feedGenerator) { - if (!feedGenerator.did) { - return null; - } - return `${feedGenerator.did}#bsky_fg`; - } - const postSeenObservers = new Map(); // Initialize post seen observers for feeds with proxy URLs @@ -96,7 +90,7 @@ class HomeView extends View { } postSeenObservers.clear(); for (const item of interactableItems) { - const proxyUrl = getProxyUrl(item); + const proxyUrl = getFeedGeneratorProxyUrl(item); if (proxyUrl) { postSeenObservers.set(item.uri, new PostSeenObserver(api, proxyUrl)); } @@ -123,7 +117,7 @@ class HomeView extends View { dataLayer.mutations.sendShowLessInteraction( post.uri, feedContext, - getProxyUrl(feedGenerator), + getFeedGeneratorProxyUrl(feedGenerator), ); // Scroll to keep the feedback message in view (it might be hidden by the header, but that's okay) const feedFeedbackMessageElement = document.querySelector( @@ -138,7 +132,7 @@ class HomeView extends View { dataLayer.mutations.sendShowMoreInteraction( post.uri, feedContext, - getProxyUrl(feedGenerator), + getFeedGeneratorProxyUrl(feedGenerator), ); showToast("Feedback sent to feed operator"); } diff --git a/src/js/views/notifications.view.js b/src/js/views/notifications.view.js index c8cf7107..a40c5117 100644 --- a/src/js/views/notifications.view.js +++ b/src/js/views/notifications.view.js @@ -28,6 +28,7 @@ import { getTimestampFromRkey } from "/js/atproto.js"; import { notificationsIconTemplate } from "/js/templates/icons/notificationsIcon.template.js"; import { verifiedCheckIconTemplate } from "/js/templates/icons/verifiedCheckIcon.template.js"; import { contactsIconTemplate } from "/js/templates/icons/contactsIcon.template.js"; +import { profileListModal } from "/js/modals/profileList.modal.js"; import "/js/components/tab-bar.js"; import { NOTIFICATIONS_PAGE_SIZE } from "/js/config.js"; import "/js/components/infinite-scroll-container.js"; @@ -242,7 +243,7 @@ class NotificationsView extends View { `; } - function notificationProfileNamesTemplate({ notificationGroup }) { + function notificationProfileNamesTemplate({ notificationGroup, title }) { const { notifications } = notificationGroup; const firstNotif = notifications[0]; const displayName = getDisplayName(firstNotif.author); @@ -255,9 +256,26 @@ class NotificationsView extends View { })}${otherCount > 0 ? html` and - ${otherCount} ${otherCount === 1 ? "other" : "others"} { + event.stopPropagation(); + profileListModal( + notifications.map((notif) => notif.author), + { + title, + isAuthenticated, + currentUserDid: dataLayer.derived.$currentUser.get()?.did, + profileInteractionHandler: + interactionHandlers.profileInteractionHandler, + }, + ); + }} + > + ${otherCount} ${otherCount === 1 ? "other" : "others"} + ` : ""} `; @@ -276,7 +294,10 @@ class NotificationsView extends View {
${notificationAvatarsTemplate({ notifications })}
- ${notificationProfileNamesTemplate({ notificationGroup })} + ${notificationProfileNamesTemplate({ + notificationGroup, + title: "Followed you", + })} ${notificationGroup.type === "follow-back" ? "followed you back" : "followed you"} @@ -337,8 +358,11 @@ class NotificationsView extends View {
${notificationAvatarsTemplate({ notifications })}
- ${notificationProfileNamesTemplate({ notificationGroup })} liked - ${isRepost ? "your repost" : "your post"} + ${notificationProfileNamesTemplate({ + notificationGroup, + title: isRepost ? "Liked your repost" : "Liked your post", + })} + liked ${isRepost ? "your repost" : "your post"} · ${timeAgo}
${postPreviewTemplate({ post: likedPost })} @@ -366,7 +390,10 @@ class NotificationsView extends View {
${notificationAvatarsTemplate({ notifications })}
- ${notificationProfileNamesTemplate({ notificationGroup })} + ${notificationProfileNamesTemplate({ + notificationGroup, + title: isRepost ? "Reposted your repost" : "Reposted your post", + })} ${isRepost ? "reposted your repost" : "reposted your post"} · ${timeAgo}
@@ -419,8 +446,11 @@ class NotificationsView extends View {
${notificationAvatarsTemplate({ notifications })}
- ${notificationProfileNamesTemplate({ notificationGroup })} liked - your custom feed + ${notificationProfileNamesTemplate({ + notificationGroup, + title: "Liked your custom feed", + })} + liked your custom feed · ${timeAgo}
@@ -448,8 +478,11 @@ class NotificationsView extends View {
${notificationAvatarsTemplate({ notifications })}
- ${notificationProfileNamesTemplate({ notificationGroup })} signed - up with your starter pack + ${notificationProfileNamesTemplate({ + notificationGroup, + title: "Joined via your starter pack", + })} + signed up with your starter pack · ${timeAgo}
@@ -471,7 +504,10 @@ class NotificationsView extends View {
${notificationAvatarsTemplate({ notifications })}
- ${notificationProfileNamesTemplate({ notificationGroup })} + ${notificationProfileNamesTemplate({ + notificationGroup, + title: "Verified you", + })} verified you · ${timeAgo}
@@ -495,9 +531,12 @@ class NotificationsView extends View {
${notificationAvatarsTemplate({ notifications })}
- ${notificationProfileNamesTemplate({ notificationGroup })} removed - their ${otherCount > 0 ? "verifications" : "verification"} from - your account + ${notificationProfileNamesTemplate({ + notificationGroup, + title: "Removed verification", + })} + removed their ${otherCount > 0 ? "verifications" : "verification"} + from your account · ${timeAgo}