popover link for users

This commit is contained in:
Dylan Knutson
2025-03-01 23:10:17 +00:00
parent 954c825c9a
commit 93d304cdf8
22 changed files with 470 additions and 102 deletions

View File

@@ -33,3 +33,4 @@
- [ ] Make unified Avatar file job
- [ ] ko-fi domain icon
- [ ] Do PCA on user factors table to display a 2D plot of users
- [ ] Use links found in descriptions to indicate re-scanning a post? (e.g. for comic next/prev links)

View File

@@ -27,30 +27,29 @@ module Domain::PostsHelper
DOMAIN_DATA =
T.let(
{
Domain::Post::FaPost =>
Domain::DomainType::Fa =>
DomainData.new(
domain_icon_path: "domain-icons/fa.png",
domain_icon_title: "Furaffinity",
),
Domain::Post::E621Post =>
Domain::DomainType::E621 =>
DomainData.new(
domain_icon_path: "domain-icons/e621.png",
domain_icon_title: "E621",
),
Domain::Post::InkbunnyPost =>
Domain::DomainType::Inkbunny =>
DomainData.new(
domain_icon_path: "domain-icons/inkbunny.png",
domain_icon_title: "Inkbunny",
),
},
T::Hash[T.class_of(Domain::Post), DomainData],
T::Hash[Domain::DomainType, DomainData],
)
sig { params(post: Domain::Post).returns(String) }
def domain_post_domain_icon_path(post)
post_class = post.class
sig { params(model: HasDomainType).returns(String) }
def domain_model_icon_path(model)
path =
if (domain_data = DOMAIN_DATA[post_class])
if (domain_data = DOMAIN_DATA[model.domain_type])
domain_data.domain_icon_path
else
DEFAULT_DOMAIN_DATA.domain_icon_path
@@ -58,16 +57,6 @@ module Domain::PostsHelper
asset_path(path)
end
sig { params(post: Domain::Post).returns(String) }
def domain_post_domain_icon_title(post)
post_class = post.class
if (domain_data = DOMAIN_DATA[post_class])
domain_data.domain_icon_title
else
DEFAULT_DOMAIN_DATA.domain_icon_title
end
end
sig { params(post: Domain::Post).returns(T.nilable(Domain::PostFile)) }
def gallery_file_for_post(post)
file = post.primary_file_for_view

View File

@@ -19,6 +19,24 @@ module Domain::UsersHelper
end
end
sig { params(user: Domain::User).returns(T.nilable(String)) }
def domain_user_registered_at_string_for_view(user)
ts = domain_user_registered_at_ts_for_view(user)
ts ? time_ago_in_words(ts) : nil
end
sig do
params(user: Domain::User).returns(T.nilable(ActiveSupport::TimeWithZone))
end
def domain_user_registered_at_ts_for_view(user)
case user
when Domain::User::FaUser, Domain::User::E621User
user.registered_at
else
nil
end
end
sig do
params(
avatar: T.nilable(Domain::UserAvatar),

View File

@@ -0,0 +1,280 @@
import * as React from 'react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
interface UserHoverPreviewProps {
children: React.ReactNode;
userName: string;
userAvatarPath: string;
userAvatarAlt: string;
userDomainIcon?: string;
userRegisteredAt?: string;
userNumPosts?: number;
userNumFollowedBy?: number;
userNumFollowed?: number;
}
export const UserHoverPreview: React.FC<UserHoverPreviewProps> = ({
children,
userName,
userAvatarPath,
userAvatarAlt,
userDomainIcon,
userRegisteredAt,
userNumPosts,
userNumFollowedBy,
userNumFollowed,
}) => {
// State and refs
const [showPreview, setShowPreview] = useState(false);
const [previewStyle, setPreviewStyle] = useState<React.CSSProperties>({});
const [imageLoaded, setImageLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
// Create portal container once
const portalContainer = useMemo(() => document.createElement('div'), []);
// Preload the avatar image when component mounts
useEffect(() => {
if (userAvatarPath) {
const img = new Image();
img.onload = () => setImageLoaded(true);
img.src = userAvatarPath;
}
}, [userAvatarPath]);
// Setup portal container
useEffect(() => {
document.body.appendChild(portalContainer);
return () => {
document.body.removeChild(portalContainer);
};
}, [portalContainer]);
// Position and display the preview
const updatePosition = () => {
if (!containerRef.current || !previewRef.current) return;
// Get dimensions
const linkRect = containerRef.current.getBoundingClientRect();
const previewRect = previewRef.current.getBoundingClientRect();
const viewport = {
width: window.innerWidth,
height: window.innerHeight,
};
// Determine horizontal position
const spaceRight = viewport.width - linkRect.right;
const spaceLeft = linkRect.left;
const showOnRight = spaceRight >= 250 || spaceRight > spaceLeft;
// Calculate vertical center alignment
const linkCenter = linkRect.top + linkRect.height / 2;
const padding =
parseInt(getComputedStyle(document.documentElement).fontSize) * 2; // 2em
// Calculate preferred vertical position (centered with link)
let top = linkCenter - previewRect.height / 2;
// Adjust if too close to viewport edges
if (top < padding) top = padding;
if (top + previewRect.height > viewport.height - padding) {
top = viewport.height - previewRect.height - padding;
}
// Set position
setPreviewStyle({
position: 'fixed',
zIndex: 9999,
top: `${top}px`,
overflow: 'auto',
transition: 'opacity 0.2s ease-in-out',
...(showOnRight
? { left: `${linkRect.right + 10}px` }
: { right: `${viewport.width - linkRect.left + 10}px` }),
});
};
// Two-step approach: first measure offscreen, then show
const handleMouseEnter = () => {
// Step 1: Render offscreen for measurement
setPreviewStyle({
position: 'fixed',
left: '-9999px',
top: '0',
opacity: '0',
transition: 'opacity 0.2s ease-in-out',
});
setShowPreview(true);
// Step 2: Position properly after a very short delay
setTimeout(() => {
updatePosition();
}, 20);
};
const handleMouseLeave = () => {
setShowPreview(false);
};
// Handle image load to reposition if needed
const handleImageLoad = () => {
if (showPreview) updatePosition();
};
// Handle window resize
useEffect(() => {
if (!showPreview) return;
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('resize', updatePosition);
};
}, [showPreview]);
const sectionClassName = [
'flex items-center overflow-hidden',
'border-slate-200 bg-gradient-to-r from-white to-slate-50 p-2',
'dark:border-slate-600 dark:from-slate-800 dark:to-slate-700',
].join(' ');
return (
<div
ref={containerRef}
className="relative inline-flex"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
{showPreview &&
createPortal(
<div
ref={previewRef}
className={[
'max-h-[280px] max-w-[270px] rounded-lg border',
'border-slate-100 bg-white dark:border-slate-600',
'divide-y divide-slate-100 dark:divide-slate-600',
'shadow-xl shadow-slate-700/50',
].join(' ')}
style={{
...previewStyle,
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Header: User Name and Domain Icon */}
<div className={`${sectionClassName} justify-between`}>
<span
className="mr-2 min-w-0 truncate text-sm font-semibold tracking-tight text-slate-800 dark:text-slate-300"
title={userName}
>
{userName}
</span>
{userDomainIcon && (
<img
src={userDomainIcon}
alt="Domain"
className="h-5 w-5 rounded-md bg-slate-500 p-1 shadow-sm ring-sky-100 dark:ring-sky-900"
/>
)}
</div>
{/* Profile Content */}
<div className="bg-slate-50 p-3 dark:bg-slate-900">
<div className="flex gap-4">
{/* Avatar */}
<div className="flex-shrink-0">
{userAvatarPath ? (
<div className="relative block overflow-hidden rounded-md">
{!imageLoaded && (
<div className="flex h-[80px] w-[80px] items-center justify-center rounded-md border border-slate-200 bg-slate-100 dark:border-slate-600 dark:bg-slate-800">
<span className="text-xs text-slate-500 dark:text-slate-400">
Loading...
</span>
</div>
)}
<img
src={userAvatarPath}
alt={userAvatarAlt}
className={`h-[80px] w-[80px] rounded-md border border-slate-200 object-cover shadow-sm dark:border-slate-600 ${
!imageLoaded ? 'hidden' : ''
}`}
loading="eager"
onLoad={() => {
setImageLoaded(true);
handleImageLoad();
}}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
parent.innerHTML =
'<span class="text-xs italic text-slate-500 dark:text-slate-400">Avatar error</span>';
}
}}
/>
</div>
) : (
<span className="block h-[80px] w-[80px] rounded-md border border-slate-200 bg-slate-100 p-2 text-xs italic text-slate-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-400">
No avatar
</span>
)}
</div>
{/* User Stats */}
<div className="flex-grow">
<div className="grid grid-cols-2 gap-3">
<div className="text-center">
<span className="block text-sm font-semibold text-slate-700 dark:text-slate-300">
{userNumPosts ?? '-'}
</span>
<span className="text-2xs text-slate-500 dark:text-slate-400">
Posts
</span>
</div>
<div className="text-center">
<span className="block text-sm font-semibold text-slate-700 dark:text-slate-300">
{userNumFollowedBy ?? '-'}
</span>
<span className="text-2xs text-slate-500 dark:text-slate-400">
Followers
</span>
</div>
<div className="text-center">
<span className="block text-sm font-semibold text-slate-700 dark:text-slate-300">
{userNumFollowed ?? '-'}
</span>
<span className="text-2xs text-slate-500 dark:text-slate-400">
Following
</span>
</div>
<div className="text-center">
{userRegisteredAt && (
<>
<span className="text-2xs block font-medium text-slate-700 dark:text-slate-300">
Member since
</span>
<span className="text-2xs leading-tight text-slate-500 dark:text-slate-400">
{userRegisteredAt}
</span>
</>
)}
</div>
</div>
</div>
</div>
</div>
</div>,
portalContainer,
)}
</div>
);
};
export default UserHoverPreview;

View File

@@ -0,0 +1,64 @@
import * as React from 'react';
import { UserHoverPreview } from './UserHoverPreview';
interface UserHoverPreviewWrapperProps {
linkText: string;
userId: string;
userName: string;
userPath: string;
userAvatarPath: string;
userAvatarAlt: string;
userDomainIcon?: string;
userSmallAvatarPath?: string;
userRegisteredAt?: string;
userNumPosts?: number;
userNumFollowedBy?: number;
userNumFollowed?: number;
}
export const UserHoverPreviewWrapper: React.FC<
UserHoverPreviewWrapperProps
> = ({
linkText,
userName,
userPath,
userAvatarPath,
userAvatarAlt,
userDomainIcon,
userSmallAvatarPath,
userRegisteredAt,
userNumPosts,
userNumFollowedBy,
userNumFollowed,
}) => {
return (
<UserHoverPreview
userName={userName}
userAvatarPath={userAvatarPath}
userAvatarAlt={userAvatarAlt}
userDomainIcon={userDomainIcon}
userRegisteredAt={userRegisteredAt}
userNumPosts={userNumPosts}
userNumFollowedBy={userNumFollowedBy}
userNumFollowed={userNumFollowed}
>
<a
href={userPath}
className="inline-flex items-center gap-1 rounded-md px-1 text-sky-200 transition-all hover:bg-gray-100 hover:text-sky-800"
>
{userSmallAvatarPath ? (
<img
src={userSmallAvatarPath}
alt={userAvatarAlt}
className="h-4 w-4 flex-shrink-0 rounded-sm"
/>
) : (
<i className="fa-regular fa-user h-4 w-4 flex-shrink-0"></i>
)}
{linkText}
</a>
</UserHoverPreview>
);
};
export default UserHoverPreviewWrapper;

View File

@@ -3,6 +3,7 @@ import ReactOnRails from 'react-on-rails';
import UserSearchBar from '../bundles/Main/components/UserSearchBar';
import { UserMenu } from '../bundles/Main/components/UserMenu';
import { PostHoverPreviewWrapper } from '../bundles/Main/components/PostHoverPreviewWrapper';
import { UserHoverPreviewWrapper } from '../bundles/Main/components/UserHoverPreviewWrapper';
import { initCollapsibleSections } from '../bundles/UI/collapsibleSections';
// This is how react_on_rails can see the components in the browser.
@@ -10,6 +11,7 @@ ReactOnRails.register({
UserSearchBar,
UserMenu,
PostHoverPreviewWrapper,
UserHoverPreviewWrapper,
});
// Initialize collapsible sections

View File

@@ -3,10 +3,12 @@ import ReactOnRails from 'react-on-rails';
import UserSearchBar from '../bundles/Main/components/UserSearchBarServer';
import { UserMenu } from '../bundles/Main/components/UserMenu';
import { PostHoverPreviewWrapper } from '../bundles/Main/components/PostHoverPreviewWrapper';
import { UserHoverPreviewWrapper } from '../bundles/Main/components/UserHoverPreviewWrapper';
// This is how react_on_rails can see the UserSearchBar in the browser.
ReactOnRails.register({
UserMenu,
UserSearchBar,
PostHoverPreviewWrapper,
UserHoverPreviewWrapper,
});

View File

@@ -189,3 +189,8 @@ class Domain::Post < ReduxApplicationRecord
nil
end
end
# eager load all subclasses
Dir[Rails.root.join("app/models/domain/post/**/*.rb")].each do |file|
require_dependency file
end

View File

@@ -17,3 +17,8 @@ class Domain::PostGroup < ReduxApplicationRecord
nil
end
end
# eager load all subclasses
Dir[Rails.root.join("app/models/domain/post_group/**/*.rb")].each do |file|
require_dependency file
end

View File

@@ -9,3 +9,8 @@ class Domain::PostGroupJoin < ReduxApplicationRecord
belongs_to :post, class_name: "::Domain::Post"
belongs_to :group, class_name: "::Domain::PostGroup"
end
# eager load all subclasses
Dir[Rails.root.join("app/models/domain/post_group_join/**/*.rb")].each do |file|
require_dependency file
end

View File

@@ -164,10 +164,6 @@ class Domain::User < ReduxApplicationRecord
def account_status_for_view
end
sig { abstract.returns(T.nilable(ActiveSupport::TimeWithZone)) }
def registered_at_for_view
end
sig { abstract.returns(T.nilable(String)) }
def external_url_for_view
end
@@ -181,3 +177,8 @@ class Domain::User < ReduxApplicationRecord
[]
end
end
# eager load all subclasses
Dir[Rails.root.join("app/models/domain/user/**/*.rb")].each do |file|
require_dependency file
end

View File

@@ -50,11 +50,6 @@ class Domain::User::E621User < Domain::User
"ok"
end
sig { override.returns(T.nilable(ActiveSupport::TimeWithZone)) }
def registered_at_for_view
registered_at
end
sig { override.returns(T.nilable(String)) }
def external_url_for_view
"https://e621.net/users/#{e621_id}" if e621_id.present?

View File

@@ -154,11 +154,6 @@ class Domain::User::FaUser < Domain::User
"https://www.furaffinity.net/user/#{url_name}" if url_name.present?
end
sig { override.returns(T.nilable(ActiveSupport::TimeWithZone)) }
def registered_at_for_view
registered_at
end
sig { override.returns(T.nilable(String)) }
def name_for_view
name || url_name

View File

@@ -58,12 +58,6 @@ class Domain::User::InkbunnyUser < Domain::User
"inkbunny.net"
end
# TODO - can we get this from the API? or scrape the user page?
sig { override.returns(T.nilable(ActiveSupport::TimeWithZone)) }
def registered_at_for_view
nil
end
sig { override.returns(T.nilable(String)) }
def external_url_for_view
"https://inkbunny.net/#{name}" if name.present?

View File

@@ -1,23 +1,23 @@
<%
post_thumbnail_path = policy(post).view_file? ? thumbnail_for_post_path(post) : nil
%>
<%= react_component(
"PostHoverPreviewWrapper",
{
prerender: false,
props: {
linkText: link_text,
postId: post.to_param,
postTitle: post.title,
postPath: domain_post_path(post),
postThumbnailPath: post_thumbnail_path,
postThumbnailAlt: "View on #{domain_name_for_model(post)}",
postDomainIcon: domain_post_domain_icon_path(post),
creatorName: post.primary_creator_for_view&.name_for_view,
creatorAvatarPath: post.primary_creator_for_view ? user_avatar_path_for_view(post.primary_creator_for_view) : nil,
},
html_options: {
style: "display:inline-flex"
<% cache [post, "popover_inline_link_domain_post"] do %>
<% post_thumbnail_path = policy(post).view_file? ? thumbnail_for_post_path(post) : nil %>
<%= react_component(
"PostHoverPreviewWrapper",
{
prerender: false,
props: {
linkText: link_text,
postId: post.to_param,
postTitle: post.title,
postPath: domain_post_path(post),
postThumbnailPath: post_thumbnail_path,
postThumbnailAlt: "View on #{domain_name_for_model(post)}",
postDomainIcon: domain_model_icon_path(post),
creatorName: post.primary_creator_for_view&.name_for_view,
creatorAvatarPath: post.primary_creator_for_view ? user_avatar_path_for_view(post.primary_creator_for_view) : nil,
},
html_options: {
style: "display:inline-flex"
}
}
}
) %>
) %>
<% end %>

View File

@@ -1,6 +1,30 @@
<%= link_to domain_user_path(user),
class:
"text-sky-200 transition-all hover:text-sky-800 inline-flex items-center hover:bg-gray-100 rounded-md gap-1 px-1 align-bottom" do %>
<%= domain_user_avatar_img_tag(user.avatar, thumb: "32-avatar") %>
<span><%= user.name %></span>
<% cache [user, "popover_inline_link_domain_user"] do %>
<% num_posts = user.has_created_posts? ? user.user_post_creations.count : nil %>
<% registered_at = domain_user_registered_at_string_for_view(user) %>
<% num_followed_by = user.has_followed_by_users? ? user.user_user_follows_to.count : nil %>
<% num_followed = user.has_followed_users? ? user.user_user_follows_from.count : nil %>
<%= react_component(
"UserHoverPreviewWrapper",
{
prerender: false,
props: {
linkText: user.name,
userId: user.to_param,
userName: user.name,
userPath: domain_user_path(user),
userSmallAvatarPath: domain_user_avatar_img_src_path(user.avatar, thumb: "32-avatar"),
userAvatarPath: domain_user_avatar_img_src_path(user.avatar),
userAvatarAlt: "View #{user.name}'s profile",
userDomainIcon: domain_model_icon_path(user),
userNumPosts: num_posts,
userRegisteredAt: registered_at,
userNumFollowedBy: num_followed_by,
userNumFollowed: num_followed,
},
html_options: {
style: "display:inline-flex",
class: 'inline-flex items-center align-bottom'
}
}
) %>
<% end %>

View File

@@ -1,7 +1,7 @@
<div class="flex w-full items-center justify-between gap-2">
<%= image_tag domain_post_domain_icon_path(post),
<%= image_tag domain_model_icon_path(post),
class: "w-6 h-6",
title: domain_post_domain_icon_title(post) %>
title: domain_model_icon_path(post) %>
<% link_class =
"flex items-center text-slate-500 hover:text-slate-700 decoration-dotted underline" %>
<% url = post.external_url_for_view %>

View File

@@ -1,7 +1,7 @@
<section class="rounded-md border border-slate-300 bg-slate-50 p-4">
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-1">
<%= image_tag domain_post_domain_icon_path(post), class: "w-6 h-6" %>
<%= image_tag domain_model_icon_path(post), class: "w-6 h-6" %>
<span class="truncate text-lg font-medium">
<%= link_to title_for_post_model(post),
post.external_url_for_view.to_s,

View File

@@ -18,13 +18,8 @@
<%= render_for_model user, "overview_details", as: :user %>
<div class="flex flex-col">
<span class="font-medium italic text-slate-500">Registered</span>
<span class="">
<% if registered_at = user.registered_at_for_view %>
<%= time_ago_in_words(registered_at) %>
ago
<% else %>
unknown
<% end %>
<span>
<%= domain_user_registered_at_string_for_view(user) %>
</span>
</div>
</div>

View File

@@ -1,4 +1,5 @@
<% fav_posts = user.faved_posts.limit(5) %>
<%# nasty hack, otherwise postgres uses a bad query plan %>
<% fav_posts = user.faved_posts.limit(50)[0..5] %>
<section class="animated-shadow-sky sky-section">
<h2 class="section-header">
<span class="font-medium text-slate-900">Favorited Posts</span>

View File

@@ -29,9 +29,9 @@
<i class="fa-solid <%= user.account_status_for_view == "ok" ? "fa-check" : "fa-exclamation-triangle" %> me-1"></i>
<%= user.account_status_for_view %>
</span>
<% if user.registered_at_for_view.present? %>
<span class="badge bg-light text-dark" title="<%= time_ago_in_words(user.registered_at_for_view) %> ago">
<i class="fa-regular fa-clock me-1"></i><%= user.registered_at_for_view.strftime("%Y-%m-%d") %>
<% if registered_at = domain_user_registered_at_string_for_view(user) %>
<span class="badge bg-light text-dark" title="<%= registered_at %>">
<i class="fa-regular fa-clock me-1"></i><%= registered_at %>
</span>
<% end %>
<% if user.avatar.present? %>

View File

@@ -26,13 +26,6 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
)
end
let(:domain_post_link_partial) do
"domain/has_description_html/inline_link_domain_post"
end
let(:domain_user_link_partial) do
"domain/has_description_html/inline_link_domain_user"
end
# Mock the policy for posts to avoid Devise authentication errors
before do
# Create a fake policy result that allows view_file?
@@ -68,8 +61,8 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
sanitize_description_html(
"my mate &lt;<a href=\"http://www.furaffinity.net/user/starringer/&gt;.\" title=\"http://www.furaffinity.net/user/starringer/&gt;.\" class=\"auto_link\">http://www.furaffinity.net/user/starringer/&gt;.</a> It was fun.",
)
expect(sanitized).to include_html(
render(partial: domain_user_link_partial, locals: { user: user }),
expect(sanitized).to include(
/UserHoverPreviewWrapper.+#{user.to_param}/,
)
end
end
@@ -178,8 +171,8 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
html = '<a href="/user/artistone/">Relative Link</a>'
sanitized = sanitize_description_html(html)
expect(sanitized).to eq_html(
render(partial: domain_user_link_partial, locals: { user: user1 }),
expect(sanitized).to include(
/UserHoverPreviewWrapper.+#{user1.to_param}/,
)
end
end
@@ -257,14 +250,14 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
expect(sanitized).to include(
/PostHoverPreviewWrapper.+#{post1.to_param}/,
)
expect(sanitized).to include_html(
render(partial: domain_user_link_partial, locals: { user: user1 }),
expect(sanitized).to include(
/UserHoverPreviewWrapper.+#{user1.to_param}/,
)
expect(sanitized).to include("FA Post")
expect(sanitized).not_to include("FA User")
expect(sanitized).to include("Google")
expect(sanitized).to include("No href")
expect(sanitized.scan(/<a/).length).to eq(1)
expect(sanitized.scan(/<a/).length).to eq(0)
end
end
@@ -291,12 +284,11 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
'
sanitized = sanitize_description_html(html)
expect(sanitized).to include_html(
render(partial: domain_user_link_partial, locals: { user: user1 }),
expect(sanitized).to include(
/UserHoverPreviewWrapper.+#{user1.to_param}/,
)
expect(sanitized).to include_html(
render(partial: domain_user_link_partial, locals: { user: user2 }),
expect(sanitized).to include(
/UserHoverPreviewWrapper.+#{user2.to_param}/,
)
end
@@ -318,8 +310,8 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
'<a href="https://www.furaffinity.net/user/artistone/"><b>Bold</b> <i>Text</i></a>'
sanitized = sanitize_description_html(html)
expect(sanitized).to include_html(
render(partial: domain_user_link_partial, locals: { user: user1 }),
expect(sanitized).to include(
/UserHoverPreviewWrapper.+#{user1.to_param}/,
)
end
@@ -337,8 +329,8 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
it "processes #{url}" do
html = %(<a href="#{url}">FA User Link</a>)
sanitized = sanitize_description_html(html)
expect(sanitized).to eq_html(
render(partial: domain_user_link_partial, locals: { user: user1 }),
expect(sanitized).to include(
/UserHoverPreviewWrapper.+#{user1.to_param}/,
)
end
end