0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-01 02:41:39 -05:00

Merge branch 'main' into get-back-in

This commit is contained in:
Ronald Langeveld 2024-12-16 13:52:41 +07:00 committed by GitHub
commit b8963c9db6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
566 changed files with 11847 additions and 7792 deletions

View file

@ -78,7 +78,7 @@ const adminXApps = '@tryghost/admin-x-demo,@tryghost/admin-x-settings,@tryghost/
const COMMANDS_ADMINX = [{
name: 'adminXDeps',
command: 'while [ 1 ]; do nx watch --projects=apps/admin-x-design-system,apps/admin-x-framework -- nx run \\$NX_PROJECT_NAME:build; done',
command: 'while [ 1 ]; do nx watch --projects=apps/admin-x-design-system,apps/admin-x-framework,apps/shade -- nx run \\$NX_PROJECT_NAME:build; done',
cwd: path.resolve(__dirname, '../..'),
prefixColor: '#C72AF7',
env: {}

View file

@ -103,6 +103,7 @@ jobs:
admin-x-settings:
- *shared
- 'apps/admin-x-settings/**'
- 'apps/admin-x-design-system/**'
announcement-bar:
- *shared
- 'apps/announcement-bar/**'

View file

@ -30,6 +30,7 @@ jobs:
### General requirements
- [ ] :warning: Tested on the staging database servers
- [ ] Satisfies idempotency requirement (both `up()` and `down()`)
- [ ] Does not reference models
- [ ] Filename is in the correct format (and correctly ordered)

View file

@ -21,7 +21,7 @@ jobs:
If weve missed reviewing your PR & youre still interested in working on it, please let us know. Otherwise this PR will be closed shortly, but can always be reopened later. Thank you for understanding 🙂
exempt-issue-labels: 'feature,pinned'
exempt-pr-labels: 'feature,pinned'
days-before-stale: 120
days-before-stale: 113
days-before-pr-stale: -1
stale-issue-label: 'stale'
stale-pr-label: 'stale'

View file

@ -1,6 +1,6 @@
{
"name": "@tryghost/admin-x-activitypub",
"version": "0.3.24",
"version": "0.3.38",
"license": "MIT",
"repository": {
"type": "git",

View file

@ -2,62 +2,22 @@ import Activities from './components/Activities';
import Inbox from './components/Inbox';
import Profile from './components/Profile';
import Search from './components/Search';
import {ActivityPubAPI} from './api/activitypub';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useQuery} from '@tanstack/react-query';
import {useRouting} from '@tryghost/admin-x-framework/routing';
export function useBrowseInboxForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`inbox:${handle}`],
async queryFn() {
return api.getInbox();
}
});
}
export function useFollowersForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`followers:${handle}`],
async queryFn() {
return api.getFollowers();
}
});
}
const MainContent = () => {
const {route} = useRouting();
const mainRoute = route.split('/')[0];
switch (mainRoute) {
case 'search':
return <Search />;
break;
case 'activity':
return <Activities />;
break;
case 'profile':
return <Profile />;
break;
default:
const layout = (mainRoute === 'inbox' || mainRoute === '') ? 'inbox' : 'feed';
return <Inbox layout={layout} />;
break;
}
};

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Actor = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Activity = any;
@ -9,13 +10,18 @@ export interface Profile {
followerCount: number;
followingCount: number;
isFollowing: boolean;
posts: Activity[];
}
export interface SearchResults {
profiles: Profile[];
}
export interface ActivityThread {
items: Activity[];
}
export type ActivityPubCollectionResponse<T> = {data: T[], next: string | null};
export interface GetFollowersForProfileResponse {
followers: {
actor: Actor;
@ -32,8 +38,9 @@ export interface GetFollowingForProfileResponse {
next: string | null;
}
export interface ActivityThread {
items: Activity[];
export interface GetPostsForProfileResponse {
posts: Activity[];
next: string | null;
}
export class ActivityPubAPI {
@ -73,39 +80,52 @@ export class ActivityPubAPI {
return json;
}
private async getActivityPubCollection<T>(collectionUrl: URL): Promise<T[]> {
const fetchPage = async (pageUrl: URL): Promise<T[]> => {
const json = await this.fetchJSON(pageUrl);
private async getActivityPubCollection<T>(collectionUrl: URL, cursor?: string): Promise<ActivityPubCollectionResponse<T>> {
const url = new URL(collectionUrl);
url.searchParams.set('cursor', cursor || '0');
if (json === null) {
return [];
}
const json = await this.fetchJSON(url);
let items: T[] = [];
if ('orderedItems' in json) {
items = Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems];
}
if ('next' in json && typeof json.next === 'string') {
const nextPageUrl = new URL(json.next);
const nextPageItems = await fetchPage(nextPageUrl);
items = items.concat(nextPageItems);
}
return items;
};
const initialJson = await this.fetchJSON(collectionUrl);
if (initialJson === null || !('first' in initialJson) || typeof initialJson.first !== 'string') {
return [];
if (json === null) {
return {
data: [],
next: null
};
}
const firstPageUrl = new URL(initialJson.first);
if (!('orderedItems' in json)) {
return {
data: [],
next: null
};
}
return fetchPage(firstPageUrl);
const data = Array.isArray(json.orderedItems) ? json.orderedItems : [];
let next = 'next' in json && typeof json.next === 'string' ? json.next : null;
if (next !== null) {
const nextUrl = new URL(next);
next = nextUrl.searchParams.get('cursor') || null;
}
return {
data,
next
};
}
private async getActivityPubCollectionCount(collectionUrl: URL): Promise<number> {
const json = await this.fetchJSON(collectionUrl);
if (json === null) {
return 0;
}
if ('totalItems' in json && typeof json.totalItems === 'number') {
return json.totalItems;
}
return 0;
}
get inboxApiUrl() {
@ -130,108 +150,32 @@ export class ActivityPubAPI {
return new URL(`.ghost/activitypub/outbox/${this.handle}`, this.apiUrl);
}
async getOutbox(): Promise<Activity[]> {
return this.getActivityPubCollection<Activity>(this.outboxApiUrl);
async getOutbox(cursor?: string): Promise<ActivityPubCollectionResponse<Activity>> {
return this.getActivityPubCollection<Activity>(this.outboxApiUrl, cursor);
}
get followingApiUrl() {
return new URL(`.ghost/activitypub/following/${this.handle}`, this.apiUrl);
}
async getFollowing(): Promise<Actor[]> {
return this.getActivityPubCollection<Actor>(this.followingApiUrl);
async getFollowing(cursor?: string): Promise<ActivityPubCollectionResponse<Actor>> {
return this.getActivityPubCollection<Actor>(this.followingApiUrl, cursor);
}
async getFollowingCount(): Promise<number> {
const json = await this.fetchJSON(this.followingApiUrl);
if (json === null) {
return 0;
}
if ('totalItems' in json && typeof json.totalItems === 'number') {
return json.totalItems;
}
return 0;
return this.getActivityPubCollectionCount(this.followingApiUrl);
}
get followersApiUrl() {
return new URL(`.ghost/activitypub/followers/${this.handle}`, this.apiUrl);
}
async getFollowers(): Promise<Actor[]> {
return this.getActivityPubCollection<Actor>(this.followersApiUrl);
async getFollowers(cursor?: string): Promise<ActivityPubCollectionResponse<Actor>> {
return this.getActivityPubCollection<Actor>(this.followersApiUrl, cursor);
}
async getFollowersCount(): Promise<number> {
const json = await this.fetchJSON(this.followersApiUrl);
if (json === null) {
return 0;
}
if ('totalItems' in json && typeof json.totalItems === 'number') {
return json.totalItems;
}
return 0;
}
async getFollowersForProfile(handle: string, next?: string): Promise<GetFollowersForProfileResponse> {
const url = new URL(`.ghost/activitypub/profile/${handle}/followers`, this.apiUrl);
if (next) {
url.searchParams.set('next', next);
}
const json = await this.fetchJSON(url);
if (json === null) {
return {
followers: [],
next: null
};
}
if (!('followers' in json)) {
return {
followers: [],
next: null
};
}
const followers = Array.isArray(json.followers) ? json.followers : [];
const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null;
return {
followers,
next: nextPage
};
}
async getFollowingForProfile(handle: string, next?: string): Promise<GetFollowingForProfileResponse> {
const url = new URL(`.ghost/activitypub/profile/${handle}/following`, this.apiUrl);
if (next) {
url.searchParams.set('next', next);
}
const json = await this.fetchJSON(url);
if (json === null) {
return {
following: [],
next: null
};
}
if (!('following' in json)) {
return {
following: [],
next: null
};
}
const following = Array.isArray(json.following) ? json.following : [];
const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null;
return {
following,
next: nextPage
};
return this.getActivityPubCollectionCount(this.followersApiUrl);
}
async follow(username: string): Promise<Actor> {
@ -240,17 +184,16 @@ export class ActivityPubAPI {
return json as Actor;
}
async getActor(url: string): Promise<Actor> {
const json = await this.fetchJSON(new URL(url));
return json as Actor;
}
get likedApiUrl() {
return new URL(`.ghost/activitypub/liked/${this.handle}`, this.apiUrl);
}
async getLiked() {
return this.getActivityPubCollection<Activity>(this.likedApiUrl);
async getLiked(cursor?: string): Promise<ActivityPubCollectionResponse<Activity>> {
return this.getActivityPubCollection<Activity>(this.likedApiUrl, cursor);
}
async getLikedCount(): Promise<number> {
return this.getActivityPubCollectionCount(this.likedApiUrl);
}
async like(id: string): Promise<void> {
@ -270,7 +213,6 @@ export class ActivityPubAPI {
async getActivities(
includeOwn: boolean = false,
includeReplies: boolean = false,
excludeNonFollowers: boolean = false,
filter: {type?: string[]} | null = null,
cursor?: string
): Promise<{data: Activity[], next: string | null}> {
@ -284,9 +226,6 @@ export class ActivityPubAPI {
if (includeReplies) {
url.searchParams.set('includeReplies', includeReplies.toString());
}
if (excludeNonFollowers) {
url.searchParams.set('excludeNonFollowers', excludeNonFollowers.toString());
}
if (filter) {
url.searchParams.set('filter', JSON.stringify(filter));
}
@ -366,6 +305,99 @@ export class ActivityPubAPI {
return json as Profile;
}
async getFollowersForProfile(handle: string, next?: string): Promise<GetFollowersForProfileResponse> {
const url = new URL(`.ghost/activitypub/profile/${handle}/followers`, this.apiUrl);
if (next) {
url.searchParams.set('next', next);
}
const json = await this.fetchJSON(url);
if (json === null) {
return {
followers: [],
next: null
};
}
if (!('followers' in json)) {
return {
followers: [],
next: null
};
}
const followers = Array.isArray(json.followers) ? json.followers : [];
const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null;
return {
followers,
next: nextPage
};
}
async getFollowingForProfile(handle: string, next?: string): Promise<GetFollowingForProfileResponse> {
const url = new URL(`.ghost/activitypub/profile/${handle}/following`, this.apiUrl);
if (next) {
url.searchParams.set('next', next);
}
const json = await this.fetchJSON(url);
if (json === null) {
return {
following: [],
next: null
};
}
if (!('following' in json)) {
return {
following: [],
next: null
};
}
const following = Array.isArray(json.following) ? json.following : [];
const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null;
return {
following,
next: nextPage
};
}
async getPostsForProfile(handle: string, next?: string): Promise<GetPostsForProfileResponse> {
const url = new URL(`.ghost/activitypub/profile/${handle}/posts`, this.apiUrl);
if (next) {
url.searchParams.set('next', next);
}
const json = await this.fetchJSON(url);
if (json === null) {
return {
posts: [],
next: null
};
}
if (!('posts' in json)) {
return {
posts: [],
next: null
};
}
const posts = Array.isArray(json.posts) ? json.posts : [];
const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null;
return {
posts,
next: nextPage
};
}
async getThread(id: string): Promise<ActivityThread> {
const url = new URL(`.ghost/activitypub/thread/${encodeURIComponent(id)}`, this.apiUrl);
const json = await this.fetchJSON(url);

View file

@ -1,95 +1,168 @@
import React, {useEffect, useRef} from 'react';
import NiceModal from '@ebay/nice-modal-react';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {LoadingIndicator, NoValueLabel} from '@tryghost/admin-x-design-system';
import {Activity, ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, LoadingIndicator, NoValueLabel} from '@tryghost/admin-x-design-system';
import APAvatar, {AvatarBadge} from './global/APAvatar';
import ActivityItem, {type Activity} from './activities/ActivityItem';
import APAvatar from './global/APAvatar';
import ArticleModal from './feed/ArticleModal';
import MainNavigation from './navigation/MainNavigation';
import NotificationItem from './activities/NotificationItem';
import Separator from './global/Separator';
import ViewProfileModal from './global/ViewProfileModal';
import getUsername from '../utils/get-username';
import stripHtml from '../utils/strip-html';
import {useActivitiesForUser} from '../hooks/useActivityPubQueries';
import truncate from '../utils/truncate';
import {GET_ACTIVITIES_QUERY_KEY_NOTIFICATIONS, useActivitiesForUser} from '../hooks/useActivityPubQueries';
import {type NotificationType} from './activities/NotificationIcon';
import {handleProfileClick} from '../utils/handle-profile-click';
interface ActivitiesProps {}
// eslint-disable-next-line no-shadow
enum ACTVITY_TYPE {
enum ACTIVITY_TYPE {
CREATE = 'Create',
LIKE = 'Like',
FOLLOW = 'Follow'
}
const getActivityDescription = (activity: Activity): string => {
switch (activity.type) {
case ACTVITY_TYPE.CREATE:
if (activity.object?.inReplyTo && typeof activity.object?.inReplyTo !== 'string') {
return `Replied to your article "${activity.object.inReplyTo.name}"`;
}
interface GroupedActivity {
type: ACTIVITY_TYPE;
actors: ActorProperties[];
object: ObjectProperties;
id?: string;
}
return '';
case ACTVITY_TYPE.FOLLOW:
return 'Followed you';
case ACTVITY_TYPE.LIKE:
if (activity.object && activity.object.type === 'Article') {
return `Liked your article "${activity.object.name}"`;
} else if (activity.object && activity.object.type === 'Note') {
return `${stripHtml(activity.object.content)}`;
}
}
return '';
};
const getExtendedDescription = (activity: Activity): JSX.Element | null => {
const getExtendedDescription = (activity: GroupedActivity): JSX.Element | null => {
// If the activity is a reply
if (Boolean(activity.type === ACTVITY_TYPE.CREATE && activity.object?.inReplyTo)) {
if (Boolean(activity.type === ACTIVITY_TYPE.CREATE && activity.object?.inReplyTo)) {
return (
<div
dangerouslySetInnerHTML={{__html: activity.object?.content || ''}}
className='mt-2'
dangerouslySetInnerHTML={{__html: stripHtml(activity.object?.content || '')}}
className='ap-note-content mt-1 line-clamp-2 text-pretty text-grey-700'
/>
);
} else if (activity.type === ACTIVITY_TYPE.LIKE && !activity.object?.name && activity.object?.content) {
return (
<div
dangerouslySetInnerHTML={{__html: stripHtml(activity.object?.content || '')}}
className='ap-note-content mt-1 line-clamp-2 text-pretty text-grey-700'
></div>
);
}
return null;
};
const getActivityUrl = (activity: Activity): string | null => {
if (activity.object) {
return activity.object.url || null;
}
return null;
};
const getActorUrl = (activity: Activity): string | null => {
if (activity.actor) {
return activity.actor.url;
}
return null;
};
const getActivityBadge = (activity: Activity): AvatarBadge => {
const getActivityBadge = (activity: GroupedActivity): NotificationType => {
switch (activity.type) {
case ACTVITY_TYPE.CREATE:
return 'comment-fill';
case ACTVITY_TYPE.FOLLOW:
return 'user-fill';
case ACTVITY_TYPE.LIKE:
case ACTIVITY_TYPE.CREATE:
return 'reply';
case ACTIVITY_TYPE.FOLLOW:
return 'follow';
case ACTIVITY_TYPE.LIKE:
if (activity.object) {
return 'heart-fill';
return 'like';
}
}
return 'like';
};
const groupActivities = (activities: Activity[]): GroupedActivity[] => {
const groups: {[key: string]: GroupedActivity} = {};
// Activities are already sorted by time from the API
activities.forEach((activity) => {
let groupKey = '';
switch (activity.type) {
case ACTIVITY_TYPE.FOLLOW:
// Group follows that are next to each other in the array
groupKey = `follow_${activity.type}`;
break;
case ACTIVITY_TYPE.LIKE:
if (activity.object?.id) {
// Group likes by the target object
groupKey = `like_${activity.object.id}`;
}
break;
case ACTIVITY_TYPE.CREATE:
// Don't group creates/replies
groupKey = `create_${activity.id}`;
break;
}
if (!groups[groupKey]) {
groups[groupKey] = {
type: activity.type as ACTIVITY_TYPE,
actors: [],
object: activity.object,
id: activity.id
};
}
// Add actor if not already in the group
if (!groups[groupKey].actors.find(a => a.id === activity.actor.id)) {
groups[groupKey].actors.push(activity.actor);
}
});
// Return in same order as original activities
return Object.values(groups);
};
const getGroupDescription = (group: GroupedActivity): JSX.Element => {
const [firstActor, secondActor, ...otherActors] = group.actors;
const hasOthers = otherActors.length > 0;
const actorClass = 'cursor-pointer font-semibold hover:underline';
const actorText = (
<>
<span
className={actorClass}
onClick={e => handleProfileClick(firstActor, e)}
>{firstActor.name}</span>
{secondActor && (
<>
{hasOthers ? ', ' : ' and '}
<span
className={actorClass}
onClick={e => handleProfileClick(secondActor, e)}
>{secondActor.name}</span>
</>
)}
{hasOthers && ' and others'}
</>
);
switch (group.type) {
case ACTIVITY_TYPE.FOLLOW:
return <>{actorText} started following you</>;
case ACTIVITY_TYPE.LIKE:
return <>{actorText} liked your post <span className='font-semibold'>{group.object?.name || ''}</span></>;
case ACTIVITY_TYPE.CREATE:
if (group.object?.inReplyTo && typeof group.object?.inReplyTo !== 'string') {
const content = stripHtml(group.object.inReplyTo.name);
return <>{actorText} replied to your post <span className='font-semibold'>{truncate(content, 80)}</span></>;
}
}
return <></>;
};
const Activities: React.FC<ActivitiesProps> = ({}) => {
const user = 'index';
const [openStates, setOpenStates] = React.useState<{[key: string]: boolean}>({});
const toggleOpen = (groupId: string) => {
setOpenStates(prev => ({
...prev,
[groupId]: !prev[groupId]
}));
};
const maxAvatars = 5;
const {getActivitiesQuery} = useActivitiesForUser({
handle: user,
@ -97,10 +170,16 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
includeReplies: true,
filter: {
type: ['Follow', 'Like', `Create:Note:isReplyToOwn`]
}
},
key: GET_ACTIVITIES_QUERY_KEY_NOTIFICATIONS
});
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = getActivitiesQuery;
const activities = (data?.pages.flatMap(page => page.data) ?? []);
const groupedActivities = (data?.pages.flatMap((page) => {
const filtered = page.data.filter((activity, index, self) => index === self.findIndex(a => a.id === activity.id));
return groupActivities(filtered);
}) ?? []);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
@ -127,43 +206,34 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
};
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// Retrieve followers for the user
// const {data: followers = []} = useFollowersForUser(user);
// const isFollower = (id: string): boolean => {
// return followers.includes(id);
// };
const handleActivityClick = (activity: Activity) => {
switch (activity.type) {
case ACTVITY_TYPE.CREATE:
const handleActivityClick = (group: GroupedActivity, index: number) => {
switch (group.type) {
case ACTIVITY_TYPE.CREATE:
NiceModal.show(ArticleModal, {
activityId: activity.id,
object: activity.object,
actor: activity.actor,
activityId: group.id,
object: group.object,
actor: group.actors[0],
focusReplies: true,
width: typeof activity.object?.inReplyTo === 'object' && activity.object?.inReplyTo?.type === 'Article' ? 'wide' : 'narrow'
width: typeof group.object?.inReplyTo === 'object' && group.object?.inReplyTo?.type === 'Article' ? 'wide' : 'narrow'
});
break;
case ACTVITY_TYPE.LIKE:
case ACTIVITY_TYPE.LIKE:
NiceModal.show(ArticleModal, {
activityId: activity.id,
object: activity.object,
actor: activity.object.attributedTo as ActorProperties,
width: 'wide'
activityId: group.id,
object: group.object,
actor: group.object.attributedTo as ActorProperties,
width: group.object?.type === 'Article' ? 'wide' : 'narrow'
});
break;
case ACTVITY_TYPE.FOLLOW:
NiceModal.show(ViewProfileModal, {
profile: getUsername(activity.actor),
onFollow: () => {},
onUnfollow: () => {}
});
case ACTIVITY_TYPE.FOLLOW:
if (group.actors.length > 1) {
toggleOpen(group.id || `${group.type}_${index}`);
} else {
handleProfileClick(group.actors[0]);
}
break;
default:
}
};
return (
<>
<MainNavigation page='activities'/>
@ -174,7 +244,7 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
</div>)
}
{
isLoading === false && activities.length === 0 && (
isLoading === false && groupedActivities.length === 0 && (
<div className='mt-8'>
<NoValueLabel icon='bell'>
When other Fediverse users interact with you, you&apos;ll see it here.
@ -183,26 +253,75 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
)
}
{
(isLoading === false && activities.length > 0) && (
(isLoading === false && groupedActivities.length > 0) && (
<>
<div className='mt-8 flex w-full max-w-[560px] flex-col'>
{activities?.map((activity, index) => (
<React.Fragment key={activity.id}>
<ActivityItem
url={getActivityUrl(activity) || getActorUrl(activity)}
onClick={() => handleActivityClick(activity)}
<div className='my-8 flex w-full max-w-[560px] flex-col'>
{groupedActivities.map((group, index) => (
<React.Fragment key={group.id || `${group.type}_${index}`}>
<NotificationItem
className='hover:bg-gray-100'
onClick={() => handleActivityClick(group, index)}
>
<APAvatar author={activity.actor} badge={getActivityBadge(activity)} />
<div className='min-w-0'>
<div className='truncate text-grey-600'>
<span className='mr-1 font-bold text-black'>{activity.actor.name}</span>
{getUsername(activity.actor)}
<NotificationItem.Icon type={getActivityBadge(group)} />
<NotificationItem.Avatars>
<div className='flex flex-col'>
<div className='mt-0.5 flex items-center gap-1.5'>
{!openStates[group.id || `${group.type}_${index}`] && group.actors.slice(0, maxAvatars).map(actor => (
<APAvatar
key={actor.id}
author={actor}
size='notification'
/>
))}
{group.actors.length > maxAvatars && (!openStates[group.id || `${group.type}_${index}`]) && (
<div
className='flex h-9 w-5 items-center justify-center text-sm text-grey-700'
>
{`+${group.actors.length - maxAvatars}`}
</div>
)}
{group.actors.length > 1 && (
<Button
className={`transition-color flex h-9 items-center rounded-full bg-transparent text-grey-700 hover:opacity-60 ${openStates[group.id || `${group.type}_${index}`] ? 'w-full justify-start pl-1' : '-ml-2 w-9 justify-center'}`}
hideLabel={!openStates[group.id || `${group.type}_${index}`]}
icon='chevron-down'
iconColorClass={`w-[12px] h-[12px] ${openStates[group.id || `${group.type}_${index}`] ? 'rotate-180' : ''}`}
label={`${openStates[group.id || `${group.type}_${index}`] ? 'Hide' : 'Show all'}`}
unstyled
onClick={(event) => {
event?.stopPropagation();
toggleOpen(group.id || `${group.type}_${index}`);
}}/>
)}
</div>
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openStates[group.id || `${group.type}_${index}`] ? 'mb-2 max-h-[1384px] opacity-100' : 'max-h-0 opacity-0'}`}>
{openStates[group.id || `${group.type}_${index}`] && group.actors.length > 1 && (
<div className='flex flex-col gap-2 pt-4'>
{group.actors.map(actor => (
<div
key={actor.id}
className='flex items-center hover:opacity-80'
onClick={e => handleProfileClick(actor, e)}
>
<APAvatar author={actor} size='xs' />
<span className='ml-2 text-base font-semibold'>{actor.name}</span>
<span className='ml-1 text-base text-grey-700'>{getUsername(actor)}</span>
</div>
))}
</div>
)}
</div>
</div>
<div className=''>{getActivityDescription(activity)}</div>
{getExtendedDescription(activity)}
</div>
</ActivityItem>
{index < activities.length - 1 && <Separator />}
</NotificationItem.Avatars>
<NotificationItem.Content>
<div className='line-clamp-2 text-pretty text-black'>
{getGroupDescription(group)}
</div>
{getExtendedDescription(group)}
</NotificationItem.Content>
</NotificationItem>
{index < groupedActivities.length - 1 && <Separator />}
</React.Fragment>
))}
</div>

View file

@ -7,14 +7,19 @@ import NewPostModal from './modals/NewPostModal';
import NiceModal from '@ebay/nice-modal-react';
import React, {useEffect, useRef} from 'react';
import Separator from './global/Separator';
import ViewProfileModal from './global/ViewProfileModal';
import getName from '../utils/get-name';
import getUsername from '../utils/get-username';
import useSuggestedProfiles from '../hooks/useSuggestedProfiles';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Heading, LoadingIndicator} from '@tryghost/admin-x-design-system';
import {
GET_ACTIVITIES_QUERY_KEY_FEED,
GET_ACTIVITIES_QUERY_KEY_INBOX,
useActivitiesForUser,
useSuggestedProfiles,
useUserDataForUser
} from '../hooks/useActivityPubQueries';
import {handleProfileClick} from '../utils/handle-profile-click';
import {handleViewContent} from '../utils/content-handlers';
import {useActivitiesForUser, useUserDataForUser} from '../hooks/useActivityPubQueries';
import {useRouting} from '@tryghost/admin-x-framework/routing';
type Layout = 'inbox' | 'feed';
@ -24,6 +29,9 @@ interface InboxProps {
}
const Inbox: React.FC<InboxProps> = ({layout}) => {
const {updateRoute} = useRouting();
// Initialise activities for the inbox or feed
const typeFilter = layout === 'inbox'
? ['Create:Article']
: ['Create:Note', 'Announce:Note'];
@ -31,23 +39,27 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
const {getActivitiesQuery, updateActivity} = useActivitiesForUser({
handle: 'index',
includeOwn: true,
excludeNonFollowers: true,
filter: {
type: typeFilter
}
},
key: layout === 'inbox' ? GET_ACTIVITIES_QUERY_KEY_INBOX : GET_ACTIVITIES_QUERY_KEY_FEED
});
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = getActivitiesQuery;
const {updateRoute} = useRouting();
const activities = (data?.pages.flatMap(page => page.data) ?? [])
// If there somehow are duplicate activities, filter them out so the list rendering doesn't break
.filter((activity, index, self) => index === self.findIndex(a => a.id === activity.id))
// Filter out replies
.filter((activity) => {
return !activity.object.inReplyTo;
});
const {suggested, isLoadingSuggested} = useSuggestedProfiles();
// Initialise suggested profiles
const {suggestedProfilesQuery} = useSuggestedProfiles('index', 3);
const {data: suggestedData, isLoading: isLoadingSuggested} = suggestedProfilesQuery;
const suggested = suggestedData || [];
const activities = (data?.pages.flatMap(page => page.data) ?? []).filter((activity) => {
return !activity.object.inReplyTo;
});
// Intersection observer to fetch more activities when the user scrolls
// to the bottom of the page
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
@ -86,7 +98,7 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
</div>
) : activities.length > 0 ? (
<>
<div className={`mx-auto flex min-h-[calc(100dvh_-_117px)] items-start gap-9`}>
<div className={`mx-auto flex min-h-[calc(100dvh_-_117px)] items-start gap-11`}>
<div className='flex w-full min-w-0 flex-col items-center'>
<div className={`flex w-full min-w-0 flex-col items-start ${layout === 'inbox' ? 'xxxl:max-w-[800px]' : 'max-w-[500px]'}`}>
{layout === 'feed' && <div className='relative mx-[-12px] mb-4 mt-10 flex w-[calc(100%+24px)] items-center p-3'>
@ -124,8 +136,7 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
</ul>
</div>
</div>
<div className='sticky top-[133px] ml-auto w-full max-w-[300px] max-lg:hidden xxxl:sticky xxxl:right-[40px]'>
{/* <Icon className='mb-2' colorClass='text-blue-500' name='comment' size='md' /> */}
<div className={`sticky top-[133px] ml-auto w-full max-w-[300px] max-lg:hidden xxxl:sticky xxxl:right-[40px]`}>
<h2 className='mb-2 text-lg font-semibold'>This is your {layout === 'inbox' ? 'inbox' : 'feed'}</h2>
<p className='mb-6 border-b border-grey-200 pb-6 text-grey-700'>You&apos;ll find {layout === 'inbox' ? 'long-form content' : 'short posts and updates'} from the accounts you follow here.</p>
<h2 className='mb-2 text-lg font-semibold'>You might also like</h2>
@ -135,28 +146,17 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
<ul className='grow'>
{suggested.map((profile, index) => {
const actor = profile.actor;
// const isFollowing = profile.isFollowing;
return (
<React.Fragment key={actor.id}>
<li key={actor.id}>
<ActivityItem url={actor.url} onClick={() => NiceModal.show(ViewProfileModal, {
profile: getUsername(actor),
onFollow: () => {},
onUnfollow: () => {}
})}>
<ActivityItem
onClick={() => handleProfileClick(actor)}
>
<APAvatar author={actor} />
<div className='flex min-w-0 flex-col'>
<span className='block w-full truncate font-bold text-black'>{getName(actor)}</span>
<span className='block w-full truncate text-sm text-grey-600'>{getUsername(actor)}</span>
</div>
{/* <FollowButton
className='ml-auto'
following={isFollowing}
handle={getUsername(actor)}
type='link'
onFollow={() => updateSuggestedProfile(actor.id!, {isFollowing: true})}
onUnfollow={() => updateSuggestedProfile(actor.id!, {isFollowing: false})}
/> */}
</ActivityItem>
</li>
{index < suggested.length - 1 && <Separator />}

View file

@ -1,201 +1,199 @@
import APAvatar from './global/APAvatar';
import ActivityItem from './activities/ActivityItem';
import FeedItem from './feed/FeedItem';
import MainNavigation from './navigation/MainNavigation';
import NiceModal from '@ebay/nice-modal-react';
import React, {useEffect, useRef, useState} from 'react';
import NiceModal from '@ebay/nice-modal-react';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Heading, List, LoadingIndicator, NoValueLabel, Tab, TabView} from '@tryghost/admin-x-design-system';
import getName from '../utils/get-name';
import getUsername from '../utils/get-username';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import Separator from './global/Separator';
import ViewProfileModal from './global/ViewProfileModal';
import {Button, Heading, List, LoadingIndicator, NoValueLabel, Tab, TabView} from '@tryghost/admin-x-design-system';
import {handleViewContent} from '../utils/content-handlers';
import {
type ActivityPubCollectionQueryResult,
useFollowersCountForUser,
useFollowersForUser,
useFollowingCountForUser,
useFollowingForUser,
useLikedCountForUser,
useLikedForUser,
useOutboxForUser,
useUserDataForUser
} from '../hooks/useActivityPubQueries';
import {handleViewContent} from '../utils/content-handlers';
interface ProfileProps {}
import APAvatar from './global/APAvatar';
import ActivityItem from './activities/ActivityItem';
import FeedItem from './feed/FeedItem';
import MainNavigation from './navigation/MainNavigation';
import Separator from './global/Separator';
import ViewProfileModal from './modals/ViewProfileModal';
import {type Activity} from '../components/activities/ActivityItem';
const Profile: React.FC<ProfileProps> = ({}) => {
const {data: followersCount = 0, isLoading: isLoadingFollowersCount} = useFollowersCountForUser('index');
const {data: followingCount = 0, isLoading: isLoadingFollowingCount} = useFollowingCountForUser('index');
const {data: following = [], isLoading: isLoadingFollowing} = useFollowingForUser('index');
const {data: followers = [], isLoading: isLoadingFollowers} = useFollowersForUser('index');
const {data: liked = [], isLoading: isLoadingLiked} = useLikedForUser('index');
const {data: outboxPosts = [], isLoading: isLoadingOutbox} = useOutboxForUser('index');
const {data: userProfile, isLoading: isLoadingProfile} = useUserDataForUser('index') as {data: ActorProperties | null, isLoading: boolean};
interface UseInfiniteScrollTabProps<TData> {
useDataHook: (key: string) => ActivityPubCollectionQueryResult<TData>;
emptyStateLabel: string;
emptyStateIcon: string;
}
const isInitialLoading = isLoadingProfile || isLoadingOutbox;
/**
* Hook to abstract away the common logic for infinite scroll in tabs
*/
const useInfiniteScrollTab = <TData,>({useDataHook, emptyStateLabel, emptyStateIcon}: UseInfiniteScrollTabProps<TData>) => {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading
} = useDataHook('index');
const posts = outboxPosts.filter(post => post.type === 'Create' && !post.object.inReplyTo);
const items = (data?.pages.flatMap(page => page.data) ?? []);
type ProfileTab = 'posts' | 'likes' | 'following' | 'followers';
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const [selectedTab, setSelectedTab] = useState<ProfileTab>('posts');
useEffect(() => {
if (observerRef.current) {
observerRef.current.disconnect();
}
const layout = 'feed';
const INCREMENT_VISIBLE_POSTS = 40;
const INCREMENT_VISIBLE_LIKES = 40;
const INCREMENT_VISIBLE_FOLLOWING = 40;
const INCREMENT_VISIBLE_FOLLOWERS = 40;
const [visiblePosts, setVisiblePosts] = useState(INCREMENT_VISIBLE_POSTS);
const [visibleLikes, setVisibleLikes] = useState(INCREMENT_VISIBLE_LIKES);
const [visibleFollowing, setVisibleFollowing] = useState(INCREMENT_VISIBLE_FOLLOWING);
const [visibleFollowers, setVisibleFollowers] = useState(INCREMENT_VISIBLE_FOLLOWERS);
const loadMorePosts = () => {
setVisiblePosts(prev => prev + INCREMENT_VISIBLE_POSTS);
};
const loadMoreLikes = () => {
setVisibleLikes(prev => prev + INCREMENT_VISIBLE_LIKES);
};
const loadMoreFollowing = () => {
setVisibleFollowing(prev => prev + INCREMENT_VISIBLE_FOLLOWING);
};
const loadMoreFollowers = () => {
setVisibleFollowers(prev => prev + INCREMENT_VISIBLE_FOLLOWERS);
};
const handleUserClick = (actor: ActorProperties) => {
NiceModal.show(ViewProfileModal, {
profile: getUsername(actor),
onFollow: () => {},
onUnfollow: () => {}
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
});
};
const renderPostsTab = () => {
if (posts.length === 0) {
return (
<NoValueLabel icon='pen'>
You haven&apos;t posted anything yet.
</NoValueLabel>
);
if (loadMoreRef.current) {
observerRef.current.observe(loadMoreRef.current);
}
return (
<>
<ul className='mx-auto flex max-w-[640px] flex-col'>
{posts.slice(0, visiblePosts).map((activity, index) => (
<li
key={activity.id}
data-test-view-article
>
<FeedItem
actor={activity.object?.attributedTo || activity.actor}
layout={layout}
object={activity.object}
type={activity.type}
onClick={() => handleViewContent(activity, false)}
onCommentClick={() => handleViewContent(activity, true)}
/>
{index < posts.length - 1 && <Separator />}
</li>
))}
</ul>
{visiblePosts < posts.length && (
<Button
className={`mt-3 self-start text-grey-900 transition-all hover:opacity-60`}
color='grey'
fullWidth={true}
label='Show more'
size='md'
onClick={loadMorePosts}
/>
)}
</>
);
};
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const renderLikesTab = () => {
if (isLoadingLiked) {
return (
<div className='flex h-40 items-center justify-center'>
<LoadingIndicator size='md' />
</div>
);
}
const EmptyState = () => (
hasNextPage === false && items.length === 0 && (
<NoValueLabel icon={emptyStateIcon}>
{emptyStateLabel}
</NoValueLabel>
)
);
if (liked.length === 0) {
return (
<NoValueLabel icon='heart'>
You haven&apos;t liked anything yet.
</NoValueLabel>
);
}
const LoadingState = () => (
<>
<div ref={loadMoreRef} className='h-1'></div>
{
(isLoading || isFetchingNextPage) && (
<div className='mt-6 flex flex-col items-center justify-center space-y-4 text-center'>
<LoadingIndicator size='md' />
</div>
)
}
</>
);
return (
<>
<ul className='mx-auto flex max-w-[640px] flex-col'>
{liked.slice(0, visibleLikes).map((activity, index) => (
<li
key={activity.id}
data-test-view-article
>
<FeedItem
actor={activity.object?.attributedTo || activity.actor}
layout={layout}
object={Object.assign({}, activity.object, {liked: true})}
type={activity.type}
onClick={() => handleViewContent(activity, false)}
onCommentClick={() => handleViewContent(activity, true)}
/>
{index < liked.length - 1 && <Separator />}
</li>
))}
</ul>
{visibleLikes < liked.length && (
<Button
className={`mt-3 self-start text-grey-900 transition-all hover:opacity-60`}
color='grey'
fullWidth={true}
label='Show more'
size='md'
onClick={loadMoreLikes}
/>
)}
</>
);
};
return {items, EmptyState, LoadingState};
};
const renderFollowingTab = () => {
if (isLoadingFollowing || isLoadingFollowingCount) {
return (
<div className='flex h-40 items-center justify-center'>
<LoadingIndicator size='md' />
</div>
);
}
const PostsTab: React.FC = () => {
const {items, EmptyState, LoadingState} = useInfiniteScrollTab<Activity>({
useDataHook: useOutboxForUser,
emptyStateLabel: 'You haven\'t posted anything yet.',
emptyStateIcon: 'pen'
});
if (following.length === 0) {
return (
<NoValueLabel icon='user-add'>
You haven&apos;t followed anyone yet.
</NoValueLabel>
);
}
const posts = items.filter(post => post.type === 'Create' && !post.object?.inReplyTo);
return (
<>
return (
<>
<EmptyState />
{
posts.length > 0 && (
<ul className='mx-auto flex max-w-[640px] flex-col'>
{posts.map((activity, index) => (
<li
key={activity.id}
data-test-view-article
>
<FeedItem
actor={activity.actor}
layout='feed'
object={activity.object}
type={activity.type}
onClick={() => handleViewContent(activity, false)}
onCommentClick={() => handleViewContent(activity, true)}
/>
{index < posts.length - 1 && <Separator />}
</li>
))}
</ul>
)
}
<LoadingState />
</>
);
};
const LikesTab: React.FC = () => {
const {items: liked, EmptyState, LoadingState} = useInfiniteScrollTab<Activity>({
useDataHook: useLikedForUser,
emptyStateLabel: 'You haven\'t liked anything yet.',
emptyStateIcon: 'heart'
});
return (
<>
<EmptyState />
{
liked.length > 0 && (
<ul className='mx-auto flex max-w-[640px] flex-col'>
{liked.map((activity, index) => (
<li
key={activity.id}
data-test-view-article
>
<FeedItem
actor={activity.object?.attributedTo as ActorProperties || activity.actor}
layout='feed'
object={Object.assign({}, activity.object, {liked: true})}
type={activity.type}
onClick={() => handleViewContent(activity, false)}
onCommentClick={() => handleViewContent(activity, true)}
/>
{index < liked.length - 1 && <Separator />}
</li>
))}
</ul>
)
}
<LoadingState />
</>
);
};
const handleUserClick = (actor: ActorProperties) => {
NiceModal.show(ViewProfileModal, {
profile: getUsername(actor)
});
};
const FollowingTab: React.FC = () => {
const {items: following, EmptyState, LoadingState} = useInfiniteScrollTab<ActorProperties>({
useDataHook: useFollowingForUser,
emptyStateLabel: 'You aren\'t following anyone yet.',
emptyStateIcon: 'user-add'
});
return (
<>
<EmptyState />
{
<List>
{following.slice(0, visibleFollowing).map((item, index) => (
{following.map((item, index) => (
<React.Fragment key={item.id}>
<ActivityItem
key={item.id}
url={item.url}
onClick={() => handleUserClick(item)}
>
<APAvatar author={item} />
@ -210,45 +208,28 @@ const Profile: React.FC<ProfileProps> = ({}) => {
</React.Fragment>
))}
</List>
{visibleFollowing < following.length && (
<Button
className={`mt-3 self-start text-grey-900 transition-all hover:opacity-60`}
color='grey'
fullWidth={true}
label='Show more'
size='md'
onClick={loadMoreFollowing}
/>
)}
</>
);
};
}
<LoadingState />
</>
);
};
const renderFollowersTab = () => {
if (isLoadingFollowers || isLoadingFollowersCount) {
return (
<div className='flex h-40 items-center justify-center'>
<LoadingIndicator size='md' />
</div>
);
}
const FollowersTab: React.FC = () => {
const {items: followers, EmptyState, LoadingState} = useInfiniteScrollTab<ActorProperties>({
useDataHook: useFollowersForUser,
emptyStateLabel: 'Nobody\'s following you yet. Their loss!',
emptyStateIcon: 'user-add'
});
if (followers.length === 0) {
return (
<NoValueLabel icon='user-add'>
Nobody&apos;s following you yet. Their loss!
</NoValueLabel>
);
}
return (
<>
return (
<>
<EmptyState />
{
<List>
{followers.slice(0, visibleFollowers).map((item, index) => (
{followers.map((item, index) => (
<React.Fragment key={item.id}>
<ActivityItem
key={item.id}
url={item.url}
onClick={() => handleUserClick(item)}
>
<APAvatar author={item} />
@ -263,43 +244,64 @@ const Profile: React.FC<ProfileProps> = ({}) => {
</React.Fragment>
))}
</List>
{visibleFollowers < followers.length && (
<Button
className={`mt-3 self-start text-grey-900 transition-all hover:opacity-60`}
color='grey'
fullWidth={true}
label='Show more'
size='md'
onClick={loadMoreFollowers}
/>
)}
</>
);
};
}
<LoadingState />
</>
);
};
type ProfileTab = 'posts' | 'likes' | 'following' | 'followers';
interface ProfileProps {}
const Profile: React.FC<ProfileProps> = ({}) => {
const {data: followersCount = 0, isLoading: isLoadingFollowersCount} = useFollowersCountForUser('index');
const {data: followingCount = 0, isLoading: isLoadingFollowingCount} = useFollowingCountForUser('index');
const {data: likedCount = 0, isLoading: isLoadingLikedCount} = useLikedCountForUser('index');
const {data: userProfile, isLoading: isLoadingProfile} = useUserDataForUser('index') as {data: ActorProperties | null, isLoading: boolean};
const isInitialLoading = isLoadingProfile || isLoadingFollowersCount || isLoadingFollowingCount || isLoadingLikedCount;
const [selectedTab, setSelectedTab] = useState<ProfileTab>('posts');
const tabs = [
{
id: 'posts',
title: 'Posts',
contents: <div className='ap-posts'>{renderPostsTab()}</div>,
counter: posts.length
contents: (
<div className='ap-posts'>
<PostsTab />
</div>
)
},
{
id: 'likes',
title: 'Likes',
contents: <div className='ap-likes'>{renderLikesTab()}</div>,
counter: liked.length
contents: (
<div className='ap-likes'>
<LikesTab />
</div>
),
counter: likedCount
},
{
id: 'following',
title: 'Following',
contents: <div>{renderFollowingTab()}</div>,
contents: (
<div className='ap-following'>
<FollowingTab />
</div>
),
counter: followingCount
},
{
id: 'followers',
title: 'Followers',
contents: <div>{renderFollowersTab()}</div>,
contents: (
<div className='ap-followers'>
<FollowersTab />
</div>
),
counter: followersCount
}
].filter(Boolean) as Tab<ProfileTab>[];
@ -321,79 +323,71 @@ const Profile: React.FC<ProfileProps> = ({}) => {
}
}, [isExpanded]);
const renderMainContent = () => {
if (isInitialLoading) {
return (
<div className='flex h-[calc(100vh-8rem)] items-center justify-center'>
<LoadingIndicator />
</div>
);
}
return (
<div className='z-0 mx-auto mt-8 flex w-full max-w-[580px] flex-col items-center pb-16'>
<div className='mx-auto w-full'>
{userProfile?.image && (
<div className='h-[200px] w-full overflow-hidden rounded-lg bg-gradient-to-tr from-grey-200 to-grey-100'>
<img
alt={userProfile?.name}
className='h-full w-full object-cover'
src={userProfile?.image.url}
/>
</div>
)}
<div className={`${userProfile?.image && '-mt-12'} px-4`}>
<div className='flex items-end justify-between'>
<div className='rounded-xl outline outline-4 outline-white'>
<APAvatar
author={userProfile as ActorProperties}
size='lg'
/>
</div>
</div>
<Heading className='mt-4' level={3}>{userProfile?.name}</Heading>
<span className='mt-1 text-[1.5rem] text-grey-800'>
<span>{userProfile && getUsername(userProfile)}</span>
</span>
{(userProfile?.summary || attachments.length > 0) && (
<div ref={contentRef} className={`ap-profile-content transition-max-height relative text-[1.5rem] duration-300 ease-in-out [&>p]:mb-3 ${isExpanded ? 'max-h-none pb-7' : 'max-h-[160px] overflow-hidden'} relative`}>
<div
dangerouslySetInnerHTML={{__html: userProfile?.summary ?? ''}}
className='ap-profile-content mt-3 text-[1.5rem] [&>p]:mb-3'
/>
{attachments.map(attachment => (
<span className='mt-3 line-clamp-1 flex flex-col text-[1.5rem]'>
<span className={`text-xs font-semibold`}>{attachment.name}</span>
<span dangerouslySetInnerHTML={{__html: attachment.value}} className='ap-profile-content truncate'/>
</span>
))}
{!isExpanded && isOverflowing && (
<div className='absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white via-white/90 via-60% to-transparent' />
)}
{isOverflowing && <Button
className='absolute bottom-0 text-pink'
label={isExpanded ? 'Show less' : 'Show all'}
link={true}
onClick={toggleExpand}
/>}
</div>
)}
<TabView<ProfileTab>
containerClassName='mt-6'
selectedTab={selectedTab}
tabs={tabs}
onTabChange={setSelectedTab}
/>
</div>
</div>
</div>
);
};
return (
<>
<MainNavigation page='profile' />
{renderMainContent()}
{isInitialLoading ? (
<div className='flex h-[calc(100vh-8rem)] items-center justify-center'>
<LoadingIndicator />
</div>
) : (
<div className='z-0 mx-auto mt-8 flex w-full max-w-[580px] flex-col items-center pb-16'>
<div className='mx-auto w-full'>
{userProfile?.image && (
<div className='h-[200px] w-full overflow-hidden rounded-lg bg-gradient-to-tr from-grey-200 to-grey-100'>
<img
alt={userProfile?.name}
className='h-full w-full object-cover'
src={userProfile?.image.url}
/>
</div>
)}
<div className={`${userProfile?.image && '-mt-12'} px-4`}>
<div className='flex items-end justify-between'>
<div className='rounded-xl outline outline-4 outline-white'>
<APAvatar
author={userProfile as ActorProperties}
size='lg'
/>
</div>
</div>
<Heading className='mt-4' level={3}>{userProfile?.name}</Heading>
<span className='mt-1 text-[1.5rem] text-grey-800'>
<span>{userProfile && getUsername(userProfile)}</span>
</span>
{(userProfile?.summary || attachments.length > 0) && (
<div ref={contentRef} className={`ap-profile-content transition-max-height relative text-[1.5rem] duration-300 ease-in-out [&>p]:mb-3 ${isExpanded ? 'max-h-none pb-7' : 'max-h-[160px] overflow-hidden'} relative`}>
<div
dangerouslySetInnerHTML={{__html: userProfile?.summary ?? ''}}
className='ap-profile-content mt-3 text-[1.5rem] [&>p]:mb-3'
/>
{attachments.map(attachment => (
<span className='mt-3 line-clamp-1 flex flex-col text-[1.5rem]'>
<span className={`text-xs font-semibold`}>{attachment.name}</span>
<span dangerouslySetInnerHTML={{__html: attachment.value}} className='ap-profile-content truncate'/>
</span>
))}
{!isExpanded && isOverflowing && (
<div className='absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white via-white/90 via-60% to-transparent' />
)}
{isOverflowing && <Button
className='absolute bottom-0 text-pink'
label={isExpanded ? 'Show less' : 'Show all'}
link={true}
onClick={toggleExpand}
/>}
</div>
)}
<TabView<ProfileTab>
containerClassName='mt-6'
selectedTab={selectedTab}
tabs={tabs}
onTabChange={setSelectedTab}
/>
</div>
</div>
</div>
)}
</>
);
};

View file

@ -1,6 +1,6 @@
import React, {useEffect, useRef, useState} from 'react';
import {Activity, ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Icon, LoadingIndicator, NoValueLabel, TextField} from '@tryghost/admin-x-design-system';
import {useDebounce} from 'use-debounce';
@ -10,11 +10,11 @@ import FollowButton from './global/FollowButton';
import MainNavigation from './navigation/MainNavigation';
import NiceModal from '@ebay/nice-modal-react';
import ViewProfileModal from './global/ViewProfileModal';
import ViewProfileModal from './modals/ViewProfileModal';
import Separator from './global/Separator';
import useSuggestedProfiles from '../hooks/useSuggestedProfiles';
import {useSearchForUser} from '../hooks/useActivityPubQueries';
import {useSearchForUser, useSuggestedProfiles} from '../hooks/useActivityPubQueries';
interface SearchResultItem {
actor: ActorProperties;
@ -22,7 +22,6 @@ interface SearchResultItem {
followerCount: number;
followingCount: number;
isFollowing: boolean;
posts: Activity[];
}
interface SearchResultProps {
@ -30,8 +29,6 @@ interface SearchResultProps {
update: (id: string, updated: Partial<SearchResultItem>) => void;
}
interface SearchProps {}
const SearchResult: React.FC<SearchResultProps> = ({result, update}) => {
const onFollow = () => {
update(result.actor.id!, {
@ -121,9 +118,13 @@ const SuggestedAccounts: React.FC<{
);
};
interface SearchProps {}
const Search: React.FC<SearchProps> = ({}) => {
// Initialise suggested profiles
const {suggested, isLoadingSuggested, updateSuggestedProfile} = useSuggestedProfiles(6);
const {suggestedProfilesQuery, updateSuggestedProfile} = useSuggestedProfiles('index', 6);
const {data: suggestedData, isLoading: isLoadingSuggested} = suggestedProfilesQuery;
const suggested = suggestedData || [];
// Initialise search query
const queryInputRef = useRef<HTMLInputElement>(null);
@ -151,6 +152,7 @@ const Search: React.FC<SearchProps> = ({}) => {
<div className='relative flex w-full items-center'>
<Icon className='absolute left-3 top-3 z-10' colorClass='text-grey-500' name='magnifying-glass' size='sm' />
<TextField
autoComplete='off'
className='mb-6 mr-12 flex h-10 w-full items-center rounded-lg border border-transparent bg-grey-100 px-[33px] py-1.5 transition-colors focus:border-green focus:bg-white focus:outline-2 dark:border-transparent dark:bg-grey-925 dark:text-white dark:placeholder:text-grey-800 dark:focus:border-green dark:focus:bg-grey-950 tablet:mr-0'
containerClassName='w-100'
inputRef={queryInputRef}

View file

@ -0,0 +1,41 @@
import React from 'react';
import {Icon} from '@tryghost/admin-x-design-system';
export type NotificationType = 'like' | 'follow' | 'reply';
interface NotificationIconProps {
notificationType: NotificationType;
className?: string;
}
const NotificationIcon: React.FC<NotificationIconProps> = ({notificationType, className}) => {
let icon = '';
let iconColor = '';
let badgeColor = '';
switch (notificationType) {
case 'follow':
icon = 'user';
iconColor = 'text-blue-500';
badgeColor = 'bg-blue-100/50';
break;
case 'like':
icon = 'heart';
iconColor = 'text-red-500';
badgeColor = 'bg-red-100/50';
break;
case 'reply':
icon = 'comment';
iconColor = 'text-purple-500';
badgeColor = 'bg-purple-100/50';
break;
}
return (
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${badgeColor} ${className && className}`}>
<Icon colorClass={iconColor} name={icon} size='sm' />
</div>
);
};
export default NotificationIcon;

View file

@ -0,0 +1,63 @@
import NotificationIcon, {NotificationType} from './NotificationIcon';
import React from 'react';
// Context to share common props between compound components
interface NotificationContextType {
onClick?: () => void;
url?: string;
}
const NotificationContext = React.createContext<NotificationContextType | undefined>(undefined);
// Root component
interface NotificationItemProps {
children: React.ReactNode;
onClick?: () => void;
url?: string;
className?: string;
}
const NotificationItem = ({children, onClick, url, className}: NotificationItemProps) => {
return (
<NotificationContext.Provider value={{onClick, url}}>
<div className={`relative -mx-4 -my-px grid cursor-pointer grid-cols-[auto_1fr] gap-x-3 gap-y-2 rounded-lg p-4 text-left hover:bg-grey-75 ${className}`}
role='button'
onClick={onClick}
>
{children}
</div>
</NotificationContext.Provider>
);
};
// Sub-components
const Icon = ({type}: {type: NotificationType}) => {
return (
<div className='col-start-1 row-start-1'>
<NotificationIcon notificationType={type} />
</div>
);
};
const Avatars = ({children}: {children: React.ReactNode}) => {
return (
<div className='col-start-2 row-start-1 flex gap-2'>
{children}
</div>
);
};
const Content = ({children}: {children: React.ReactNode}) => {
return (
<div className='col-start-2 row-start-2'>
{children}
</div>
);
};
// Attach sub-components to the main component
NotificationItem.Icon = Icon;
NotificationItem.Avatars = Avatars;
NotificationItem.Content = Content;
export default NotificationItem;

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,22 @@
import FeedItem from './FeedItem';
import FeedItemStats from './FeedItemStats';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import React, {useEffect, useRef, useState} from 'react';
import articleBodyStyles from '../articleBodyStyles';
import getUsername from '../../utils/get-username';
import {OptionProps, SingleValueProps, components} from 'react-select';
import {type Activity} from '../activities/ActivityItem';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, LoadingIndicator, Modal} from '@tryghost/admin-x-design-system';
import {Button, Icon, LoadingIndicator, Modal, Popover, Select, SelectOption} from '@tryghost/admin-x-design-system';
import {renderTimestamp} from '../../utils/render-timestamp';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useEffect, useRef, useState} from 'react';
import {useModal} from '@ebay/nice-modal-react';
import {useThreadForUser} from '../../hooks/useActivityPubQueries';
import APAvatar from '../global/APAvatar';
import APReplyBox from '../global/APReplyBox';
import getReadingTime from '../../utils/get-reading-time';
interface ArticleModalProps {
activityId: string;
@ -32,122 +33,193 @@ interface ArticleModalProps {
}[];
}
const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt: string|undefined, html: string}> = ({heading, image, excerpt, html}) => {
interface IframeWindow extends Window {
resizeIframe?: () => void;
}
const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt: string|undefined, html: string, fontSize: FontSize, lineHeight: string, fontFamily: SelectOption}> = ({
heading,
image,
excerpt,
html,
fontSize,
lineHeight,
fontFamily
}) => {
const site = useBrowseSite();
const siteData = site.data?.site;
const iframeRef = useRef<HTMLIFrameElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [iframeHeight, setIframeHeight] = useState('0px');
const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, ''));
const htmlContent = `
<html>
<head>
${cssContent}
<style>
body {
margin: 0;
padding: 0;
overflow-y: hidden;
}
</style>
<script>
let isFullyLoaded = false;
<html>
<head>
${cssContent}
<style>
:root {
--font-size: ${fontSize};
--line-height: ${lineHeight};
--font-family: ${fontFamily.value};
--letter-spacing: ${fontFamily.label === 'Clean sans-serif' ? '-0.013em' : '0'};
--content-spacing-factor: ${SPACING_FACTORS[FONT_SIZES.indexOf(fontSize)]};
}
body {
margin: 0;
padding: 0;
overflow-y: hidden;
}
</style>
<script>
let isFullyLoaded = false;
function resizeIframe() {
const finalHeight = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.scrollHeight
);
window.parent.postMessage({
type: 'resize',
height: finalHeight,
isLoaded: isFullyLoaded
}, '*');
}
function resizeIframe() {
const bodyHeight = document.body.offsetHeight;
function waitForImages() {
const images = document.getElementsByTagName('img');
const imagePromises = Array.from(images).map(img => {
if (img.complete) {
return Promise.resolve();
}
return new Promise(resolve => {
img.onload = resolve;
img.onerror = resolve;
});
});
return Promise.all(imagePromises);
}
window.parent.postMessage({
type: 'resize',
height: bodyHeight,
isLoaded: isFullyLoaded,
bodyHeight: bodyHeight
}, '*');
}
function initializeResize() {
resizeIframe();
function waitForImages() {
const images = document.getElementsByTagName('img');
const imagePromises = Array.from(images).map(img => {
if (img.complete) {
return Promise.resolve();
}
return new Promise(resolve => {
img.onload = resolve;
img.onerror = resolve;
});
});
return Promise.all(imagePromises);
}
waitForImages().then(() => {
isFullyLoaded = true;
resizeIframe();
});
}
function initializeResize() {
resizeIframe();
window.addEventListener('DOMContentLoaded', initializeResize);
window.addEventListener('load', resizeIframe);
window.addEventListener('resize', resizeIframe);
new MutationObserver(resizeIframe).observe(document.body, { subtree: true, childList: true });
</script>
</head>
<body>
<header class='gh-article-header gh-canvas'>
<h1 class='gh-article-title is-title' data-test-article-heading>${heading}</h1>
${excerpt ? `
<p class='gh-article-excerpt'>${excerpt}</p>
` : ''}
${image ? `
<figure class='gh-article-image'>
<img src='${image}' alt='${heading}' />
</figure>
` : ''}
</header>
<div class='gh-content gh-canvas is-body'>
${html}
</div>
</body>
</html>
`;
waitForImages().then(() => {
isFullyLoaded = true;
resizeIframe();
});
}
window.addEventListener('DOMContentLoaded', initializeResize);
window.addEventListener('load', resizeIframe);
window.addEventListener('resize', resizeIframe);
new MutationObserver(resizeIframe).observe(document.body, { subtree: true, childList: true });
window.addEventListener('message', (event) => {
if (event.data.type === 'triggerResize') {
resizeIframe();
}
});
</script>
</head>
<body>
<header class='gh-article-header gh-canvas'>
<h1 class='gh-article-title is-title' data-test-article-heading>${heading}</h1>
${excerpt ? `
<p class='gh-article-excerpt'>${excerpt}</p>
` : ''}
${image ? `
<figure class='gh-article-image'>
<img src='${image}' alt='${heading}' />
</figure>
` : ''}
</header>
<div class='gh-content gh-canvas is-body'>
${html}
</div>
</body>
</html>
`;
useEffect(() => {
const iframe = iframeRef.current;
if (iframe) {
iframe.srcdoc = htmlContent;
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'resize') {
setIframeHeight(`${event.data.height}px`);
iframe.style.height = `${event.data.height}px`;
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
if (!iframe) {
return;
}
if (!iframe.srcdoc) {
iframe.srcdoc = htmlContent;
}
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'resize') {
const newHeight = `${event.data.bodyHeight + 24}px`;
setIframeHeight(newHeight);
iframe.style.height = newHeight;
if (event.data.isLoaded) {
setIsLoading(false);
}
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [htmlContent]);
// Separate effect for style updates
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) {
return;
}
const iframeDocument = iframe.contentDocument || iframe.contentWindow?.document;
if (!iframeDocument) {
return;
}
const root = iframeDocument.documentElement;
root.style.setProperty('--font-size', fontSize);
root.style.setProperty('--line-height', lineHeight);
root.style.setProperty('--font-family', fontFamily.value);
root.style.setProperty('--letter-spacing', fontFamily.label === 'Clean sans-serif' ? '-0.013em' : '0');
root.style.setProperty('--content-spacing-factor', SPACING_FACTORS[FONT_SIZES.indexOf(fontSize)]);
const iframeWindow = iframe.contentWindow as IframeWindow;
if (iframeWindow && typeof iframeWindow.resizeIframe === 'function') {
iframeWindow.resizeIframe();
} else {
// Fallback: trigger a resize event
const resizeEvent = new Event('resize');
iframeDocument.dispatchEvent(resizeEvent);
}
}, [fontSize, lineHeight, fontFamily]);
return (
<div className='w-full pb-10'>
<iframe
ref={iframeRef}
id='gh-ap-article-iframe'
style={{
width: '100%',
border: 'none',
height: iframeHeight,
overflow: 'hidden'
}}
title='Embedded Content'
/>
<div className='w-full pb-6'>
<div className='relative'>
{isLoading && (
<div className='absolute inset-0 flex items-center justify-center bg-white/60'>
<LoadingIndicator />
</div>
)}
<iframe
ref={iframeRef}
id='gh-ap-article-iframe'
style={{
width: '100%',
border: 'none',
height: iframeHeight,
overflow: 'hidden',
opacity: isLoading ? 0 : 1,
transition: 'opacity 0.2s ease-in-out'
}}
title='Embedded Content'
/>
</div>
</div>
);
};
@ -156,6 +228,55 @@ const FeedItemDivider: React.FC = () => (
<div className="h-px bg-grey-200"></div>
);
const FONT_SIZES = ['1.5rem', '1.6rem', '1.7rem', '1.8rem', '2rem'] as const;
const LINE_HEIGHTS = ['1.3', '1.4', '1.5', '1.6', '1.7'] as const;
const SPACING_FACTORS = ['0.85', '1', '1.1', '1.2', '1.3'] as const;
type FontSize = typeof FONT_SIZES[number];
const STORAGE_KEYS = {
FONT_SIZE: 'ghost-ap-font-size',
LINE_HEIGHT: 'ghost-ap-line-height',
FONT_FAMILY: 'ghost-ap-font-family'
} as const;
const MAX_WIDTHS = {
'1.5rem': '544px',
'1.6rem': '644px',
'1.7rem': '684px',
'1.8rem': '724px',
'2rem': '764px'
} as const;
const SingleValue: React.FC<SingleValueProps<FontSelectOption, false>> = ({children, ...props}) => (
<components.SingleValue {...props}>
<div className='group' data-testid="select-current-option" data-value={props.data.value}>
<div className='flex items-center gap-2.5'>
<div className={`${props.data.className} flex h-8 w-8 items-center justify-center rounded-md bg-white text-[1.5rem] font-semibold dark:bg-black`}>Aa</div>
<span className={`text-md ${props.data.className}`}>{children}</span>
</div>
</div>
</components.SingleValue>
);
const Option: React.FC<OptionProps<FontSelectOption, false>> = ({children, ...props}) => (
<components.Option {...props}>
<div className={props.isSelected ? 'relative flex w-full items-center justify-between gap-2' : 'group'} data-testid="select-option" data-value={props.data.value}>
<div className='flex items-center gap-2.5'>
<div className='flex h-8 w-8 items-center justify-center rounded-md bg-grey-150 text-[1.5rem] font-semibold group-hover:bg-grey-250 dark:bg-grey-900 dark:group-hover:bg-grey-800'>Aa</div>
<span className={`text-md ${props.data.className}`}>{children}</span>
</div>
{props.isSelected && <span><Icon name='check' size='xs' /></span>}
</div>
</components.Option>
);
interface FontSelectOption {
value: string;
label: string;
className?: string;
}
const ArticleModal: React.FC<ArticleModalProps> = ({
activityId,
object,
@ -167,7 +288,7 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
history = []
}) => {
const MODAL_SIZE_SM = 640;
const MODAL_SIZE_LG = 840;
const MODAL_SIZE_LG = 1420;
const [isFocused] = useState(focusReply ? 1 : 0);
const {threadQuery, addToThread} = useThreadForUser('index', activityId);
@ -264,47 +385,240 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
}, 100);
}, [focusReply, focusReplies]);
const iframeRef = useRef<HTMLIFrameElement>(null);
// Initialize state with values from localStorage, falling back to defaults
const [currentFontSizeIndex, setCurrentFontSizeIndex] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEYS.FONT_SIZE);
return saved ? parseInt(saved) : 1;
});
const [currentLineHeightIndex, setCurrentLineHeightIndex] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEYS.LINE_HEIGHT);
return saved ? parseInt(saved) : 1;
});
const [fontFamily, setFontFamily] = useState<SelectOption>(() => {
const saved = localStorage.getItem(STORAGE_KEYS.FONT_FAMILY);
return saved ? JSON.parse(saved) : {
value: 'sans-serif',
label: 'Clean sans-serif'
};
});
// Update localStorage when values change
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.FONT_SIZE, currentFontSizeIndex.toString());
}, [currentFontSizeIndex]);
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.LINE_HEIGHT, currentLineHeightIndex.toString());
}, [currentLineHeightIndex]);
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.FONT_FAMILY, JSON.stringify(fontFamily));
}, [fontFamily]);
const increaseFontSize = () => {
setCurrentFontSizeIndex(prevIndex => Math.min(prevIndex + 1, FONT_SIZES.length - 1));
};
const decreaseFontSize = () => {
setCurrentFontSizeIndex(prevIndex => Math.max(prevIndex - 1, 0));
};
const increaseLineHeight = () => {
setCurrentLineHeightIndex(prevIndex => Math.min(prevIndex + 1, LINE_HEIGHTS.length - 1));
};
const decreaseLineHeight = () => {
setCurrentLineHeightIndex(prevIndex => Math.max(prevIndex - 1, 0));
};
useEffect(() => {
const iframe = iframeRef.current;
if (iframe) {
const iframeDocument = iframe.contentDocument || iframe.contentWindow?.document;
if (iframeDocument) {
iframeDocument.documentElement.style.setProperty('--font-size', FONT_SIZES[currentFontSizeIndex]);
iframeDocument.documentElement.style.setProperty('--line-height', LINE_HEIGHTS[currentLineHeightIndex]);
iframeDocument.documentElement.style.setProperty('--font-family', fontFamily.value);
iframeDocument.documentElement.style.setProperty('--letter-spacing', fontFamily.label === 'Clean sans-serif' ? '-0.013em' : '0');
iframeDocument.documentElement.style.setProperty('--content-spacing-factor', SPACING_FACTORS[FONT_SIZES.indexOf(FONT_SIZES[currentFontSizeIndex])]);
}
}
}, [currentFontSizeIndex, currentLineHeightIndex, fontFamily]);
// Get the current max width based on font size
const currentMaxWidth = MAX_WIDTHS[FONT_SIZES[currentFontSizeIndex]];
// Calculate the grid column width by subtracting 64px from the current max width
const currentGridWidth = `${parseInt(currentMaxWidth) - 64}px`;
const [readingProgress, setReadingProgress] = useState(0);
useEffect(() => {
const container = document.querySelector('.overflow-y-auto');
const article = document.getElementById('object-content');
const handleScroll = () => {
if (!container || !article) {
return;
}
const articleRect = article.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const scrolledPast = Math.max(0, containerRect.top - articleRect.top);
const totalHeight = (article as HTMLElement).offsetHeight - (container as HTMLElement).offsetHeight;
const rawProgress = Math.min(Math.max((scrolledPast / totalHeight) * 100, 0), 100);
const progress = Math.round(rawProgress / 5) * 5;
setReadingProgress(progress);
};
container?.addEventListener('scroll', handleScroll);
return () => container?.removeEventListener('scroll', handleScroll);
}, []);
return (
<Modal
align='right'
allowBackgroundInteraction={false}
animate={true}
backDrop={false}
backDropClick={true}
footer={<></>}
height={'full'}
padding={false}
scrolling={true}
size='bleed'
width={modalSize}
width={modalSize === MODAL_SIZE_LG ? 'toSidebar' : modalSize}
>
<div className='flex h-full flex-col'>
<div className='sticky top-0 z-50 border-b border-grey-200 bg-white py-8'>
<div className={`flex h-8 ${modalSize === MODAL_SIZE_LG ? 'mx-auto w-full max-w-[580px]' : 'px-8'}`}>
<div className='sticky top-0 z-50 flex h-[97px] items-center justify-center border-b border-grey-200 bg-white'>
<div
className={`w-full ${modalSize === MODAL_SIZE_LG ? 'grid px-8' : 'flex justify-between gap-2 px-8'}`}
style={modalSize === MODAL_SIZE_LG ? {
gridTemplateColumns: `1fr minmax(0,${currentGridWidth}) 1fr`
} : undefined}
>
{(canNavigateBack || (activityThreadParents.length > 0)) ? (
<div className='col-[1/2] flex items-center justify-between'>
<Button className='flex w-10 items-center justify-center' icon='chevron-left' size='sm' unstyled onClick={navigateBack}/>
<Button className='transition-color flex h-10 w-10 items-center justify-center rounded-full bg-white hover:bg-grey-100' icon='arrow-left' size='sm' unstyled onClick={navigateBack}/>
</div>
) : <div className='flex items-center gap-3'>
) : (<div className='col-[2/3] mx-auto flex w-full items-center gap-3'>
<div className='relative z-10 pt-[3px]'>
<APAvatar author={actor}/>
</div>
<div className='relative z-10 flex w-full min-w-0 flex-col overflow-visible text-[1.5rem]'>
<div className='flex w-full'>
<span className='min-w-0 truncate whitespace-nowrap font-bold after:mx-1 after:font-normal after:text-grey-700 after:content-["·"]'>{actor.name}</span>
<div>{renderTimestamp(object)}</div>
<span className='min-w-0 truncate whitespace-nowrap font-bold'>{actor.name}</span>
</div>
<div className='flex w-full'>
<span className='min-w-0 truncate text-grey-700'>{getUsername(actor)}</span>
<span className='text-grey-700 after:mx-1 after:font-normal after:text-grey-700 after:content-["·"]'>{getUsername(actor)}</span>
<span className='text-grey-700'>{renderTimestamp(object)}</span>
</div>
</div>
</div>}
<div className='col-[2/3] flex grow items-center justify-center px-8 text-center'>
</div>
<div className='col-[3/4] flex items-center justify-end space-x-6'>
<Button className='flex w-10 items-center justify-center' icon='close' size='sm' unstyled onClick={() => modal.remove()}/>
</div>)}
<div className='col-[3/4] flex items-center justify-end gap-2'>
{modalSize === MODAL_SIZE_LG && object.type === 'Article' && <Popover position='end' trigger={ <Button className='transition-color flex h-10 w-10 items-center justify-center rounded-full bg-white hover:bg-grey-100' icon='typography' size='sm' unstyled onClick={() => {}}/>
}>
<div className='flex min-w-[300px] flex-col p-5'>
<Select
className='mb-3'
components={{Option, SingleValue}}
controlClasses={{control: '!min-h-[40px] !py-0 !pl-1', option: '!pl-1 !py-[4px]'}}
options={[
{
value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
label: 'Clean sans-serif',
className: 'font-sans'
},
{
value: 'Georgia, Times, serif',
label: 'Elegant serif',
className: 'font-serif'
}
]}
title='Typeface'
value={fontFamily}
onSelect={option => setFontFamily(option || {
value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
label: 'Clean sans-serif',
className: 'font-sans'
})}
/>
<div className='mb-2 flex items-center justify-between'>
<span className='text-sm font-medium text-grey-900'>Font size</span>
<div className='flex items-center'>
<Button
className={`transition-color flex h-8 w-8 items-center justify-center rounded-full bg-white ${currentFontSizeIndex === 0 ? 'opacity-20 hover:bg-white' : 'hover:bg-grey-100'}`}
disabled={currentFontSizeIndex === 0}
hideLabel={true}
icon='substract'
iconSize='xs'
label='Decrease font size'
unstyled={true}
onClick={decreaseFontSize}
/>
<Button
className={`transition-color flex h-8 w-8 items-center justify-center rounded-full bg-white hover:bg-grey-100 ${currentFontSizeIndex === FONT_SIZES.length - 1 ? 'opacity-20 hover:bg-white' : 'hover:bg-grey-100'}`}
disabled={currentFontSizeIndex === FONT_SIZES.length - 1}
hideLabel={true}
icon='add'
iconSize='xs'
label='Increase font size'
unstyled={true}
onClick={increaseFontSize}
/>
</div>
</div>
<div className='mb-5 flex items-center justify-between'>
<span className='text-sm font-medium text-grey-900'>Line spacing</span>
<div className='flex items-center'>
<Button
className={`transition-color flex h-8 w-8 items-center justify-center rounded-full bg-white hover:bg-grey-100 ${currentLineHeightIndex === 0 ? 'opacity-20 hover:bg-white' : 'hover:bg-grey-100'}`}
disabled={currentLineHeightIndex === 0}
hideLabel={true}
icon='substract'
iconSize='xs'
label='Decrease line spacing'
unstyled={true}
onClick={decreaseLineHeight}
/>
<Button
className={`transition-color flex h-8 w-8 items-center justify-center rounded-full bg-white hover:bg-grey-100 ${currentLineHeightIndex === LINE_HEIGHTS.length - 1 ? 'opacity-20 hover:bg-white' : 'hover:bg-grey-100'}`}
disabled={currentLineHeightIndex === LINE_HEIGHTS.length - 1}
hideLabel={true}
icon='add'
iconSize='xs'
label='Increase line spacing'
unstyled={true}
onClick={increaseLineHeight}
/>
</div>
</div>
<Button
className="text-sm text-grey-600 hover:text-grey-700"
label="Reset to default"
link={true}
onClick={() => {
setCurrentFontSizeIndex(1); // Default font size
setCurrentLineHeightIndex(1); // Default line height
setFontFamily({
value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
label: 'Clean sans-serif'
});
}}
/>
</div>
</Popover>}
<Button className='transition-color flex h-10 w-10 items-center justify-center rounded-full bg-white hover:bg-grey-100' icon='close' size='sm' unstyled onClick={() => modal.remove()}/>
</div>
</div>
</div>
<div className='grow overflow-y-auto'>
<div className='mx-auto max-w-[580px] pb-10 pt-5'>
<div className={`mx-auto px-8 pb-10 pt-5`} style={{maxWidth: currentMaxWidth}}>
{activityThreadParents.map((item) => {
return (
<>
@ -344,26 +658,31 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
/>
)}
{object.type === 'Article' && (
<div className='border-b border-grey-200 pb-8'>
<div className='border-b border-grey-200 pb-8' id='object-content'>
<ArticleBody
excerpt={object?.preview?.content}
fontFamily={fontFamily}
fontSize={FONT_SIZES[currentFontSizeIndex]}
heading={object.name}
html={object.content}
image={object?.image}
/>
<FeedItemStats
commentCount={object.replyCount ?? 0}
layout={'modal'}
likeCount={1}
object={object}
onCommentClick={() => {
repliesRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}}
onLikeClick={onLikeClick}
image={typeof object.image === 'string' ? object.image : object.image?.url}
lineHeight={LINE_HEIGHTS[currentLineHeightIndex]}
/>
<div className='ml-[-7px]'>
<FeedItemStats
commentCount={object.replyCount ?? 0}
layout={'modal'}
likeCount={1}
object={object}
onCommentClick={() => {
repliesRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}}
onLikeClick={onLikeClick}
/>
</div>
</div>
)}
@ -406,6 +725,16 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
</div>
</div>
</div>
{modalSize === MODAL_SIZE_LG && object.type === 'Article' && (
<div className='pointer-events-none sticky bottom-0 flex items-end justify-between px-10 pb-[42px]'>
<div className='pointer-events-auto text-grey-600'>
{getReadingTime(object.content)}
</div>
<div className='pointer-events-auto text-grey-600 transition-all duration-200 ease-out'>
{readingProgress}%
</div>
</div>
)}
</Modal>
);
};

View file

@ -10,6 +10,7 @@ import getReadingTime from '../../utils/get-reading-time';
import getRelativeTimestamp from '../../utils/get-relative-timestamp';
import getUsername from '../../utils/get-username';
import stripHtml from '../../utils/strip-html';
import {handleProfileClick} from '../../utils/handle-profile-click';
import {renderTimestamp} from '../../utils/render-timestamp';
function getAttachment(object: ObjectProperties) {
@ -71,7 +72,7 @@ export function renderFeedAttachment(object: ObjectProperties, layout: string) {
case 'image/jpeg':
case 'image/png':
case 'image/gif':
return <img alt='attachment' className='mt-3 rounded-md outline outline-1 -outline-offset-1 outline-black/10' src={attachment.url} />;
return <img alt='attachment' className='mt-3 max-h-[420px] rounded-md outline outline-1 -outline-offset-1 outline-black/10' src={attachment.url} />;
case 'video/mp4':
case 'video/webm':
return <div className='relative mb-4 mt-3'>
@ -85,7 +86,7 @@ export function renderFeedAttachment(object: ObjectProperties, layout: string) {
</div>;
default:
if (object.image) {
return <img alt='attachment' className='my-3 max-h-[280px] w-full rounded-md object-cover outline outline-1 -outline-offset-1 outline-black/[0.05]' src={object.image} />;
return <img alt='attachment' className='my-3 max-h-[280px] w-full rounded-md object-cover outline outline-1 -outline-offset-1 outline-black/[0.05]' src={typeof object.image === 'string' ? object.image : object.image?.url} />;
}
return null;
}
@ -137,7 +138,7 @@ function renderInboxAttachment(object: ObjectProperties) {
);
default:
if (object.image) {
return <img className={imageAttachmentStyles} src={object.image} />;
return <img className={imageAttachmentStyles} src={typeof object.image === 'string' ? object.image : object.image?.url} />;
}
return null;
}
@ -163,7 +164,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
const date = new Date(object?.published ?? new Date());
const [isCopied, setIsCopied] = useState(false);
const [, setIsCopied] = useState(false);
const onLikeClick = () => {
// Do API req or smth
@ -218,12 +219,12 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
const UserMenuTrigger = (
<Button
className={`relative z-[9998] ml-auto flex h-5 w-5 items-center justify-center self-start hover:opacity-60 ${isCopied ? 'bump' : ''}`}
className={`transition-color relative z-[9998] flex h-[34px] w-[34px] items-center justify-center rounded-full bg-white hover:bg-grey-100 ${(layout === 'feed' || layout === 'modal') && 'ml-auto'}`}
hideLabel={true}
icon='dotdotdot'
iconColorClass={`${layout === 'inbox' ? 'text-grey-900' : 'text-grey-500'}`}
iconColorClass={`${layout === 'inbox' ? 'text-grey-900 w-[12px] h-[12px]' : 'text-grey-500 w-[16px] h-[16px]'}`}
id='more'
size='sm'
size='md'
unstyled={true}
/>
);
@ -232,28 +233,38 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
return (
<>
{object && (
<div className={`group/article relative cursor-pointer py-6`} data-layout='feed' data-object-id={object.id} onClick={onClick}>
<div className={`group/article relative -mx-6 -my-px cursor-pointer rounded-lg p-6 pb-[18px]`} data-layout='feed' data-object-id={object.id} onClick={onClick}>
{(type === 'Announce' && object.type === 'Note') && <div className='z-10 mb-2 flex items-center gap-3 text-grey-700'>
<div className='z-10 flex w-10 justify-end'><Icon colorClass='text-grey-700' name='reload' size={'sm'}></Icon></div>
<span className='z-10'>{actor.name} reposted</span>
</div>}
<div className={`border-1 flex flex-col gap-2.5`} data-test-activity>
<div className='flex min-w-0 items-center gap-3'>
<div className='relative z-30 flex min-w-0 items-center gap-3'>
<APAvatar author={author}/>
<div className='flex min-w-0 flex-col gap-0.5'>
<span className='min-w-0 truncate break-all font-semibold leading-[normal]' data-test-activity-heading>{author.name}</span>
<span className='min-w-0 truncate break-all font-semibold leading-[normal] hover:underline'
data-test-activity-heading
onClick={e => handleProfileClick(actor, e)}
>
{author.name}
</span>
<div className='flex w-full text-grey-700'>
<span className='truncate leading-tight'>{getUsername(author)}</span>
<span className='truncate leading-tight hover:underline'
onClick={e => handleProfileClick(actor, e)}
>
{getUsername(author)}
</span>
<div className='ml-1 leading-tight before:mr-1 before:content-["·"]' title={`${timestamp}`}>{renderTimestamp(object)}</div>
</div>
</div>
<Menu items={menuItems} open={menuIsOpen} position='end' setOpen={setMenuIsOpen} trigger={UserMenuTrigger}/>
</div>
<div className={`relative col-start-2 col-end-3 w-full gap-4`}>
<div className='flex flex-col'>
<div className=''>
{(object.type === 'Article') && renderFeedAttachment(object, layout)}
{object.name && <Heading className='my-1 text-pretty leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>}
{(object.preview && object.type === 'Article') ? <div className='line-clamp-3 leading-tight'>{object.preview.content}</div> : <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>}
{(object.preview && object.type === 'Article') ? <div className='line-clamp-3 leading-tight'>{object.preview.content}</div> : <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty leading-[1.4285714286] tracking-[-0.006em] text-grey-900'></div>}
{(object.type === 'Note') && renderFeedAttachment(object, layout)}
{(object.type === 'Article') && <Button
className={`mt-3 self-start text-grey-900 transition-all hover:opacity-60`}
@ -264,7 +275,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
size='md'
/>}
</div>
<div className='space-between relative z-[30] mt-5 flex'>
<div className='space-between relative z-[30] ml-[-7px] mt-1 flex'>
<FeedItemStats
commentCount={commentCount}
layout={layout}
@ -273,7 +284,6 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
onCommentClick={onCommentClick}
onLikeClick={onLikeClick}
/>
<Menu items={menuItems} open={menuIsOpen} position='end' setOpen={setMenuIsOpen} trigger={UserMenuTrigger}/>
</div>
</div>
</div>
@ -292,7 +302,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
<div className='z-10 flex w-10 justify-end'><Icon colorClass='text-grey-700' name='reload' size={'sm'}></Icon></div>
<span className='z-10'>{actor.name} reposted</span>
</div>}
<div className={`z-10 -my-1 grid grid-cols-[auto_1fr] grid-rows-[auto_1fr] gap-3 pb-4 pt-5`} data-test-activity>
<div className={`z-10 -my-1 grid grid-cols-[auto_1fr] grid-rows-[auto_1fr] gap-3 pb-3 pt-4`} data-test-activity>
{(showHeader) && <><div className='relative z-10 pt-[3px]'>
<APAvatar author={author}/>
</div>
@ -308,9 +318,9 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
<div className={`relative z-10 col-start-1 col-end-3 w-full gap-4`}>
<div className='flex flex-col'>
{object.name && <Heading className='mb-1 leading-tight' level={4} data-test-activity-heading>{object.name}</Heading>}
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.8rem] text-grey-900'></div>
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content-large text-pretty text-[1.6rem] tracking-[-0.011em] text-grey-900'></div>
{renderFeedAttachment(object, layout)}
<div className='space-between mt-5 flex'>
<div className='space-between ml-[-7px] mt-3 flex'>
<FeedItemStats
commentCount={commentCount}
layout={layout}
@ -341,43 +351,47 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
<div className='z-10 flex w-10 justify-end'><Icon colorClass='text-grey-700' name='reload' size={'sm'}></Icon></div>
<span className='z-10'>{actor.name} reposted</span>
</div>}
<div className={`border-1 z-10 -my-1 grid grid-cols-[auto_1fr] grid-rows-[auto_1fr] gap-x-3 gap-y-2 border-b-grey-200`} data-test-activity>
<div className='relative z-10 min-w-0 pt-[3px]'>
<div className={`border-1 z-10 flex items-start gap-3 border-b-grey-200`} data-test-activity>
<div className='relative z-10 pt-[3px]'>
<APAvatar author={author}/>
</div>
<div className='relative z-10 flex w-full min-w-0 flex-col overflow-visible text-[1.5rem]'>
<div className='flex'>
<span className='min-w-0 truncate whitespace-nowrap font-bold after:mx-1 after:font-normal after:text-grey-700 after:content-["·"]' data-test-activity-heading>{author.name}</span>
<div>{renderTimestamp(object)}</div>
<div className='flex w-full min-w-0 flex-col gap-2'>
<div className='flex w-full items-center justify-between'>
<div className='relative z-10 flex w-full min-w-0 flex-col overflow-visible'>
<div className='flex'>
<span className='min-w-0 truncate whitespace-nowrap font-bold after:mx-1 after:font-normal after:text-grey-700 after:content-["·"]' data-test-activity-heading>{author.name}</span>
<div>{renderTimestamp(object)}</div>
</div>
<div className='flex'>
<span className='truncate text-grey-700'>{getUsername(author)}</span>
</div>
</div>
<Menu items={menuItems} open={menuIsOpen} position='end' setOpen={setMenuIsOpen} trigger={UserMenuTrigger}/>
</div>
<div className='flex'>
<span className='truncate text-grey-700'>{getUsername(author)}</span>
</div>
</div>
<div className={`relative z-10 col-start-2 col-end-3 w-full gap-4`}>
<div className='flex flex-col'>
{(object.type === 'Article') && renderFeedAttachment(object, layout)}
{object.name && <Heading className='my-1 text-pretty leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>}
{(object.preview && object.type === 'Article') ? <div className='line-clamp-3 leading-tight'>{object.preview.content}</div> : <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>}
{(object.type === 'Note') && renderFeedAttachment(object, layout)}
{(object.type === 'Article') && <Button
className={`mt-3 self-start text-grey-900 transition-all hover:opacity-60`}
color='grey'
fullWidth={true}
id='read-more'
label='Read more'
size='md'
/>}
<div className='space-between mt-5 flex'>
<FeedItemStats
commentCount={commentCount}
layout={layout}
likeCount={1}
object={object}
onCommentClick={onCommentClick}
onLikeClick={onLikeClick}
/>
<Menu items={menuItems} open={menuIsOpen} position='end' setOpen={setMenuIsOpen} trigger={UserMenuTrigger}/>
<div className={`relative z-10 col-start-2 col-end-3 w-full gap-4`}>
<div className='flex flex-col'>
{(object.type === 'Article') && renderFeedAttachment(object, layout)}
{object.name && <Heading className='my-1 text-pretty leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>}
{(object.preview && object.type === 'Article') ? <div className='line-clamp-3 leading-tight'>{object.preview.content}</div> : <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty tracking-[-0.006em] text-grey-900'></div>}
{(object.type === 'Note') && renderFeedAttachment(object, layout)}
{(object.type === 'Article') && <Button
className={`mt-3 self-start text-grey-900 transition-all hover:opacity-60`}
color='grey'
fullWidth={true}
id='read-more'
label='Read more'
size='md'
/>}
<div className='space-between ml-[-7px] mt-2 flex'>
<FeedItemStats
commentCount={commentCount}
layout={layout}
likeCount={1}
object={object}
onCommentClick={onCommentClick}
onLikeClick={onLikeClick}
/>
</div>
</div>
</div>
</div>
@ -392,12 +406,17 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
return (
<>
{object && (
<div className='group/article relative -mx-6 -my-px flex min-h-[112px] min-w-0 cursor-pointer items-center justify-between rounded-md p-6 hover:bg-grey-75' data-layout='inbox' data-object-id={object.id} onClick={onClick}>
<div>
<div className='z-10 mb-1.5 flex w-full min-w-0 items-center gap-1.5 text-base group-hover/article:border-transparent'>
<div className='group/article relative -mx-6 -my-px flex min-h-[112px] min-w-0 cursor-pointer items-center justify-between rounded-lg p-6 hover:bg-grey-75' data-layout='inbox' data-object-id={object.id} onClick={onClick}>
<div className='min-w-0'>
<div className='z-10 mb-1.5 flex w-full min-w-0 items-center gap-1.5 text-sm group-hover/article:border-transparent'>
<APAvatar author={author} size='2xs'/>
<span className='min-w-0 truncate break-all font-medium text-grey-900' title={getUsername(author)} data-test-activity-heading>{author.name}</span>
<span className='shrink-0 whitespace-nowrap text-grey-700 before:mr-1 before:content-["·"]' title={`${timestamp}`}>{getRelativeTimestamp(date)}</span>
<span className='min-w-0 truncate break-all font-semibold text-grey-900 hover:underline'
title={getUsername(author)}
data-test-activity-heading
onClick={e => handleProfileClick(actor, e)}
>{author.name}
</span>
<span className='shrink-0 whitespace-nowrap text-grey-600 before:mr-1 before:content-["·"]' title={`${timestamp}`}>{getRelativeTimestamp(date)}</span>
</div>
<div className='flex'>
<div className='flex min-h-[73px] w-full min-w-0 flex-col items-start justify-start gap-1'>
@ -408,10 +427,10 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
}}></span>
)}
</Heading>
<div dangerouslySetInnerHTML={({__html: stripHtml(object.preview?.content ?? object.content)})} className='ap-note-content line-clamp-2 w-full max-w-[600px] text-pretty text-base leading-normal text-grey-700'></div>
<span className='mt-1 shrink-0 whitespace-nowrap leading-none text-grey-700'>{object.content && `${getReadingTime(object.content)}`}</span>
<div dangerouslySetInnerHTML={({__html: stripHtml(object.preview?.content ?? object.content)})} className='ap-note-content line-clamp-2 w-full max-w-[600px] text-pretty text-base leading-normal text-grey-800'></div>
<span className='mt-1 shrink-0 whitespace-nowrap text-sm leading-none text-grey-600'>{object.content && `${getReadingTime(object.content)}`}</span>
</div>
<div className='invisible absolute right-4 top-1/2 z-[49] flex -translate-y-1/2 flex-col gap-3 rounded-full bg-white px-2 py-3 shadow-md group-hover/article:visible'>
<div className='invisible absolute right-4 top-1/2 z-[49] flex -translate-y-1/2 flex-col rounded-full bg-white p-1 shadow-md group-hover/article:visible'>
<FeedItemStats
commentCount={commentCount}
layout={layout}

View file

@ -20,60 +20,54 @@ const FeedItemStats: React.FC<FeedItemStatsProps> = ({
onLikeClick,
onCommentClick
}) => {
const [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(object.liked);
const likeMutation = useLikeMutationForUser('index');
const unlikeMutation = useUnlikeMutationForUser('index');
const handleLikeClick = async (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
setIsClicked(true);
if (!isLiked) {
likeMutation.mutate(object.id);
} else {
unlikeMutation.mutate(object.id);
}
setIsLiked(!isLiked);
onLikeClick();
setTimeout(() => setIsClicked(false), 300);
};
return (<div className={`flex ${(layout === 'inbox') ? 'flex-col gap-3' : 'gap-5'}`}>
<div className='flex gap-1'>
<Button
className={`self-start text-grey-900 transition-opacity hover:opacity-60 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`}
hideLabel={true}
icon='heart'
id='like'
size='md'
unstyled={true}
onClick={(e?: React.MouseEvent<HTMLElement>) => {
e?.stopPropagation();
if (e) {
handleLikeClick(e);
}
}}
/>
{isLiked && (layout !== 'inbox') && <span className={`text-grey-900`}>{new Intl.NumberFormat().format(likeCount)}</span>}
</div>
<div className='flex gap-1'>
<Button
className={`self-start text-grey-900 hover:opacity-60 ${isClicked ? 'bump' : ''}`}
hideLabel={true}
icon='comment'
id='comment'
size='md'
unstyled={true}
onClick={(e?: React.MouseEvent<HTMLElement>) => {
e?.stopPropagation();
onCommentClick();
}}
/>
{commentCount > 0 && (layout !== 'inbox') && (
<span className={`text-grey-900`}>{new Intl.NumberFormat().format(commentCount)}</span>
)}
</div>
const buttonClassName = `transition-color flex p-2 items-center justify-center rounded-full bg-white leading-none text-grey-900 hover:bg-grey-100`;
return (<div className={`flex ${(layout === 'inbox') ? 'flex-col' : 'gap-1'}`}>
<Button
className={buttonClassName}
hideLabel={!isLiked || (layout === 'inbox')}
icon='heart'
iconColorClass={`w-[18px] h-[18px] ${isLiked && 'ap-red-heart text-red *:!fill-red hover:text-red'}`}
id='like'
label={new Intl.NumberFormat().format(likeCount)}
size='md'
unstyled={true}
onClick={(e?: React.MouseEvent<HTMLElement>) => {
e?.stopPropagation();
if (e) {
handleLikeClick(e);
}
}}
/>
<Button
className={buttonClassName}
hideLabel={commentCount === 0 || (layout === 'inbox')}
icon='comment'
iconColorClass='w-[18px] h-[18px]'
id='comment'
label={new Intl.NumberFormat().format(commentCount)}
size='md'
unstyled={true}
onClick={(e?: React.MouseEvent<HTMLElement>) => {
e?.stopPropagation();
onCommentClick();
}}
/>
</div>);
};

View file

@ -1,39 +1,30 @@
import NiceModal from '@ebay/nice-modal-react';
import React, {useEffect, useState} from 'react';
import ViewProfileModal from '../modals/ViewProfileModal';
import clsx from 'clsx';
import getUsername from '../../utils/get-username';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Icon} from '@tryghost/admin-x-design-system';
type AvatarSize = '2xs' | 'xs' | 'sm' | 'lg';
export type AvatarBadge = 'user-fill' | 'heart-fill' | 'comment-fill' | undefined;
type AvatarSize = '2xs' | 'xs' | 'sm' | 'lg' | 'notification';
interface APAvatarProps {
author?: ActorProperties;
author: ActorProperties | undefined;
size?: AvatarSize;
badge?: AvatarBadge;
}
const APAvatar: React.FC<APAvatarProps> = ({author, size, badge}) => {
const APAvatar: React.FC<APAvatarProps> = ({author, size}) => {
let iconSize = 18;
let containerClass = 'shrink-0 items-center justify-center relative z-10 flex';
let containerClass = `shrink-0 items-center justify-center relative cursor-pointer z-10 flex ${size === 'lg' ? '' : 'hover:opacity-80'}`;
let imageClass = 'z-10 rounded-md w-10 h-10 object-cover';
const badgeClass = `w-6 h-6 z-20 rounded-full absolute -bottom-2 -right-[0.6rem] border-2 border-white content-box flex items-center justify-center`;
let badgeColor = '';
const [iconUrl, setIconUrl] = useState(author?.icon?.url);
useEffect(() => {
setIconUrl(author?.icon?.url);
}, [author?.icon?.url]);
switch (badge) {
case 'user-fill':
badgeColor = 'bg-blue-500';
break;
case 'heart-fill':
badgeColor = 'bg-red-500';
break;
case 'comment-fill':
badgeColor = 'bg-purple-500';
break;
if (!author) {
return null;
}
switch (size) {
@ -44,8 +35,13 @@ const APAvatar: React.FC<APAvatarProps> = ({author, size, badge}) => {
break;
case 'xs':
iconSize = 12;
containerClass = clsx('h-5 w-5 rounded-md ', containerClass);
imageClass = 'z-10 rounded-md w-5 h-5 object-cover';
containerClass = clsx('h-6 w-6 rounded-md ', containerClass);
imageClass = 'z-10 rounded-md w-6 h-6 object-cover';
break;
case 'notification':
iconSize = 12;
containerClass = clsx('h-9 w-9 rounded-md', containerClass);
imageClass = 'z-10 rounded-xl w-9 h-9 object-cover';
break;
case 'sm':
containerClass = clsx('h-10 w-10 rounded-md', containerClass);
@ -63,37 +59,42 @@ const APAvatar: React.FC<APAvatarProps> = ({author, size, badge}) => {
containerClass = clsx(containerClass, 'bg-grey-100');
}
const BadgeElement = badge && (
<div className={clsx(badgeClass, badgeColor)}>
<Icon
colorClass='text-white'
name={badge}
size='xs'
/>
</div>
);
const onClick = (e: React.MouseEvent) => {
e.stopPropagation();
NiceModal.show(ViewProfileModal, {
profile: getUsername(author as ActorProperties)
});
};
const title = `${author?.name} ${getUsername(author as ActorProperties)}`;
if (iconUrl) {
return (
<a className={containerClass} href={author?.url} rel='noopener noreferrer' target='_blank'>
<div
className={containerClass}
title={title}
onClick={onClick}
>
<img
className={imageClass}
src={iconUrl}
onError={() => setIconUrl(undefined)}
/>
{BadgeElement}
</a>
</div>
);
}
return (
<div className={containerClass}>
<div
className={containerClass}
title={title}
onClick={onClick}
>
<Icon
colorClass='text-grey-600'
name='user'
size={iconSize}
/>
{BadgeElement}
</div>
);
};

View file

@ -137,7 +137,7 @@ const APReplyBox: React.FC<APTextAreaProps> = ({
{hint}
</div>
</FormPrimitive.Root>
<div className='absolute bottom-[3px] right-[9px] flex space-x-4 transition-[opacity] duration-150'>
<div className='absolute bottom-[3px] right-0 flex space-x-4 transition-[opacity] duration-150'>
<Button color='black' disabled={buttonDisabled} id='post' label='Post' loading={replyMutation.isLoading} size='md' onMouseDown={handleClick} />
</div>
</div>

View file

@ -1,27 +1,28 @@
import React, {useEffect, useRef, useState} from 'react';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import {Activity, ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Heading, Icon, List, LoadingIndicator, Modal, NoValueLabel, Tab,TabView} from '@tryghost/admin-x-design-system';
import {UseInfiniteQueryResult} from '@tanstack/react-query';
import {type GetFollowersForProfileResponse, type GetFollowingForProfileResponse} from '../../api/activitypub';
import {useFollowersForProfile, useFollowingForProfile, useProfileForUser} from '../../hooks/useActivityPubQueries';
import {useFollowersForProfile, useFollowingForProfile, usePostsForProfile, useProfileForUser} from '../../hooks/useActivityPubQueries';
import APAvatar from '../global/APAvatar';
import ActivityItem from '../activities/ActivityItem';
import FeedItem from '../feed/FeedItem';
import FollowButton from '../global/FollowButton';
import Separator from './Separator';
import Separator from '../global/Separator';
import getName from '../../utils/get-name';
import getUsername from '../../utils/get-username';
import {handleProfileClick} from '../../utils/handle-profile-click';
const noop = () => {};
type QueryPageData = GetFollowersForProfileResponse | GetFollowingForProfileResponse;
type QueryFn = (handle: string) => UseInfiniteQueryResult<QueryPageData, unknown>;
type QueryFn = (handle: string) => UseInfiniteQueryResult<QueryPageData>;
type ActorListProps = {
handle: string,
@ -44,10 +45,8 @@ const ActorList: React.FC<ActorListProps> = ({
isLoading
} = queryFn(handle);
const actorData = (data?.pages.flatMap(resolveDataFn) ?? []);
const actors = (data?.pages.flatMap(resolveDataFn) ?? []);
// Intersection observer to fetch more data when the user scrolls
// to the bottom of the list
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
@ -76,16 +75,18 @@ const ActorList: React.FC<ActorListProps> = ({
return (
<div>
{
actorData.length === 0 && !isLoading ? (
hasNextPage === false && actors.length === 0 ? (
<NoValueLabel icon='user-add'>
{noResultsMessage}
</NoValueLabel>
) : (
<List>
{actorData.map(({actor, isFollowing}, index) => {
{actors.map(({actor, isFollowing}, index) => {
return (
<React.Fragment key={actor.id}>
<ActivityItem key={actor.id} url={actor.url}>
<ActivityItem key={actor.id}
onClick={() => handleProfileClick(actor)}
>
<APAvatar author={actor} />
<div>
<div className='text-grey-600'>
@ -100,7 +101,7 @@ const ActorList: React.FC<ActorListProps> = ({
type='link'
/>
</ActivityItem>
{index < actorData.length - 1 && <Separator />}
{index < actors.length - 1 && <Separator />}
</React.Fragment>
);
})}
@ -119,14 +120,77 @@ const ActorList: React.FC<ActorListProps> = ({
);
};
const FollowersTab: React.FC<{handle: string}> = ({handle}) => {
const PostsTab: React.FC<{handle: string}> = ({handle}) => {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading
} = usePostsForProfile(handle);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (observerRef.current) {
observerRef.current.disconnect();
}
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
});
if (loadMoreRef.current) {
observerRef.current.observe(loadMoreRef.current);
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const posts = (data?.pages.flatMap(page => page.posts) ?? [])
.filter(post => post.type === 'Create' && !post.object.inReplyTo);
return (
<ActorList
handle={handle}
noResultsMessage={`${handle} has no followers yet`}
queryFn={useFollowersForProfile}
resolveDataFn={page => ('followers' in page ? page.followers : [])}
/>
<div>
{
hasNextPage === false && posts.length === 0 ? (
<NoValueLabel icon='pen'>
{handle} has not posted anything yet
</NoValueLabel>
) : (
<>
{posts.map((post, index) => (
<div>
<FeedItem
actor={post.actor}
commentCount={post.object.replyCount}
layout='feed'
object={post.object}
type={post.type}
onCommentClick={() => {}}
/>
{index < posts.length - 1 && <Separator />}
</div>
))}
</>
)
}
<div ref={loadMoreRef} className='h-1'></div>
{
(isFetchingNextPage || isLoading) && (
<div className='mt-6 flex flex-col items-center justify-center space-y-4 text-center'>
<LoadingIndicator size='md' />
</div>
)
}
</div>
);
};
@ -141,6 +205,17 @@ const FollowingTab: React.FC<{handle: string}> = ({handle}) => {
);
};
const FollowersTab: React.FC<{handle: string}> = ({handle}) => {
return (
<ActorList
handle={handle}
noResultsMessage={`${handle} has no followers yet`}
queryFn={useFollowersForProfile}
resolveDataFn={page => ('followers' in page ? page.followers : [])}
/>
);
};
interface ViewProfileModalProps {
profile: {
actor: ActorProperties;
@ -148,10 +223,9 @@ interface ViewProfileModalProps {
followerCount: number;
followingCount: number;
isFollowing: boolean;
posts: Activity[];
} | string;
onFollow: () => void;
onUnfollow: () => void;
onFollow?: () => void;
onUnfollow?: () => void;
}
type ProfileTab = 'posts' | 'following' | 'followers';
@ -173,30 +247,13 @@ const ViewProfileModal: React.FC<ViewProfileModalProps> = ({
}
const attachments = (profile?.actor.attachment || []);
const posts = (profile?.posts || []).filter(post => post.type !== 'Announce');
const tabs = isLoading === false && typeof profile !== 'string' && profile ? [
{
id: 'posts',
title: 'Posts',
contents: (
<div>
{posts.map((post, index) => (
<div>
<FeedItem
actor={profile.actor}
commentCount={post.object.replyCount}
layout='feed'
object={post.object}
type={post.type}
onCommentClick={() => {}}
/>
{index < posts.length - 1 && (
<Separator />
)}
</div>
))}
</div>
<PostsTab handle={profile.handle} />
)
},
{
@ -236,6 +293,7 @@ const ViewProfileModal: React.FC<ViewProfileModalProps> = ({
<Modal
align='right'
animate={true}
backDrop={false}
footer={<></>}
height={'full'}
padding={false}
@ -245,7 +303,7 @@ const ViewProfileModal: React.FC<ViewProfileModalProps> = ({
<div className='sticky top-0 z-50 border-grey-200 bg-white py-3'>
<div className='grid h-8 grid-cols-3'>
<div className='col-[3/4] flex items-center justify-end space-x-6 px-8'>
<Button icon='close' size='sm' unstyled onClick={() => modal.remove()}/>
<Button className='transition-color flex h-10 w-10 items-center justify-center rounded-full bg-white hover:bg-grey-100' icon='close' size='sm' unstyled onClick={() => modal.remove()}/>
</div>
</div>
</div>

View file

@ -1,7 +1,6 @@
import MainHeader from './MainHeader';
import React from 'react';
import {Button} from '@tryghost/admin-x-design-system';
import {useQueryClient} from '@tanstack/react-query';
import {useRouting} from '@tryghost/admin-x-framework/routing';
interface MainNavigationProps {
@ -10,15 +9,6 @@ interface MainNavigationProps {
const MainNavigation: React.FC<MainNavigationProps> = ({page}) => {
const {updateRoute} = useRouting();
const queryClient = useQueryClient();
const handleRouteChange = (newRoute: string) => {
queryClient.removeQueries({
queryKey: ['activities:index']
});
updateRoute(newRoute);
};
return (
<MainHeader>
@ -27,13 +17,13 @@ const MainNavigation: React.FC<MainNavigationProps> = ({page}) => {
className={`${page === 'inbox' ? 'font-bold text-grey-975' : 'text-grey-700 hover:text-grey-800'}`}
label='Inbox'
unstyled
onClick={() => handleRouteChange('inbox')}
onClick={() => updateRoute('inbox')}
/>
<Button
className={`${page === 'feed' ? 'font-bold text-grey-975' : 'text-grey-700 hover:text-grey-800'}`}
label='Feed'
unstyled
onClick={() => handleRouteChange('feed')}
onClick={() => updateRoute('feed')}
/>
<Button className={`${page === 'activities' ? 'font-bold text-grey-975' : 'text-grey-700 hover:text-grey-800'}`} label='Notifications' unstyled onClick={() => updateRoute('activity')} />
<Button className={`${page === 'search' ? 'font-bold text-grey-975' : 'text-grey-700 hover:text-grey-800'}`} label='Search' unstyled onClick={() => updateRoute('search')} />

View file

@ -1,9 +1,11 @@
import {Activity} from '../components/activities/ActivityItem';
import {ActivityPubAPI, ActivityThread, type Profile, type SearchResults} from '../api/activitypub';
import {useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {ActivityPubAPI, ActivityPubCollectionResponse, ActivityThread, type Profile, type SearchResults} from '../api/activitypub';
import {type UseInfiniteQueryResult, useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
let SITE_URL: string;
export type ActivityPubCollectionQueryResult<TData> = UseInfiniteQueryResult<ActivityPubCollectionResponse<TData>>;
async function getSiteUrl() {
if (!SITE_URL) {
const response = await fetch('/ghost/api/admin/site');
@ -22,23 +24,41 @@ function createActivityPubAPI(handle: string, siteUrl: string) {
);
}
export function useLikedForUser(handle: string) {
return useQuery({
queryKey: [`liked:${handle}`],
async queryFn() {
export function useOutboxForUser(handle: string) {
return useInfiniteQuery({
queryKey: [`outbox:${handle}`],
async queryFn({pageParam}: {pageParam?: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getLiked();
return api.getOutbox(pageParam);
},
getNextPageParam(prevPage) {
return prevPage.next;
}
});
}
export function useReplyMutationForUser(handle: string) {
return useMutation({
async mutationFn({id, content}: {id: string, content: string}) {
export function useLikedForUser(handle: string) {
return useInfiniteQuery({
queryKey: [`liked:${handle}`],
async queryFn({pageParam}: {pageParam?: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return await api.reply(id, content) as Activity;
return api.getLiked(pageParam);
},
getNextPageParam(prevPage) {
return prevPage.next;
}
});
}
export function useLikedCountForUser(handle: string) {
return useQuery({
queryKey: [`likedCount:${handle}`],
async queryFn() {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getLikedCount();
}
});
}
@ -149,6 +169,20 @@ export function useUserDataForUser(handle: string) {
});
}
export function useFollowersForUser(handle: string) {
return useInfiniteQuery({
queryKey: [`followers:${handle}`],
async queryFn({pageParam}: {pageParam?: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getFollowers(pageParam);
},
getNextPageParam(prevPage) {
return prevPage.next;
}
});
}
export function useFollowersCountForUser(handle: string) {
return useQuery({
queryKey: [`followersCount:${handle}`],
@ -160,6 +194,20 @@ export function useFollowersCountForUser(handle: string) {
});
}
export function useFollowingForUser(handle: string) {
return useInfiniteQuery({
queryKey: [`following:${handle}`],
async queryFn({pageParam}: {pageParam?: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getFollowing(pageParam);
},
getNextPageParam(prevPage) {
return prevPage.next;
}
});
}
export function useFollowingCountForUser(handle: string) {
return useQuery({
queryKey: [`followingCount:${handle}`],
@ -171,42 +219,63 @@ export function useFollowingCountForUser(handle: string) {
});
}
export function useFollowingForUser(handle: string) {
return useQuery({
queryKey: [`following:${handle}`],
async queryFn() {
export function useFollow(handle: string, onSuccess: () => void, onError: () => void) {
const queryClient = useQueryClient();
return useMutation({
async mutationFn(username: string) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getFollowing();
}
return api.follow(username);
},
onSuccess(followedActor, fullHandle) {
queryClient.setQueryData([`profile:${fullHandle}`], (currentProfile: unknown) => {
if (!currentProfile) {
return currentProfile;
}
return {
...currentProfile,
isFollowing: true
};
});
queryClient.setQueryData(['following:index'], (currentFollowing?: unknown[]) => {
if (!currentFollowing) {
return currentFollowing;
}
return [followedActor].concat(currentFollowing);
});
queryClient.setQueryData(['followingCount:index'], (currentFollowingCount?: number) => {
if (!currentFollowingCount) {
return 1;
}
return currentFollowingCount + 1;
});
onSuccess();
},
onError
});
}
export function useFollowersForUser(handle: string) {
return useQuery({
queryKey: [`followers:${handle}`],
async queryFn() {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getFollowers();
}
});
}
export const GET_ACTIVITIES_QUERY_KEY_INBOX = 'inbox';
export const GET_ACTIVITIES_QUERY_KEY_FEED = 'feed';
export const GET_ACTIVITIES_QUERY_KEY_NOTIFICATIONS = 'notifications';
export function useActivitiesForUser({
handle,
includeOwn = false,
includeReplies = false,
excludeNonFollowers = false,
filter = null
filter = null,
key = null
}: {
handle: string;
includeOwn?: boolean;
includeReplies?: boolean;
excludeNonFollowers?: boolean;
filter?: {type?: string[]} | null;
key?: string | null;
}) {
const queryKey = [`activities:${handle}`, {includeOwn, includeReplies, filterTypes: filter?.type}];
const queryKey = [`activities:${handle}`, key, {includeOwn, includeReplies, filter}];
const queryClient = useQueryClient();
const getActivitiesQuery = useInfiniteQuery({
@ -214,7 +283,7 @@ export function useActivitiesForUser({
async queryFn({pageParam}: {pageParam?: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getActivities(includeOwn, includeReplies, excludeNonFollowers, filter, pageParam);
return api.getActivities(includeOwn, includeReplies, filter, pageParam);
},
getNextPageParam(prevPage) {
return prevPage.next;
@ -281,76 +350,19 @@ export function useSearchForUser(handle: string, query: string) {
return {searchQuery, updateProfileSearchResult};
}
export function useFollow(handle: string, onSuccess: () => void, onError: () => void) {
export function useSuggestedProfiles(handle: string, limit = 3) {
const queryClient = useQueryClient();
return useMutation({
async mutationFn(username: string) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.follow(username);
},
onSuccess(followedActor, fullHandle) {
queryClient.setQueryData([`profile:${fullHandle}`], (currentProfile: unknown) => {
if (!currentProfile) {
return currentProfile;
}
return {
...currentProfile,
isFollowing: true
};
});
const queryKey = ['profiles', limit];
queryClient.setQueryData(['following:index'], (currentFollowing?: unknown[]) => {
if (!currentFollowing) {
return currentFollowing;
}
return [followedActor].concat(currentFollowing);
});
queryClient.setQueryData(['followingCount:index'], (currentFollowingCount?: number) => {
if (!currentFollowingCount) {
return 1;
}
return currentFollowingCount + 1;
});
onSuccess();
},
onError
});
}
export function useFollowersForProfile(handle: string) {
return useInfiniteQuery({
queryKey: [`followers:${handle}`],
async queryFn({pageParam}: {pageParam?: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getFollowersForProfile(handle, pageParam);
},
getNextPageParam(prevPage) {
return prevPage.next;
}
});
}
export function useFollowingForProfile(handle: string) {
return useInfiniteQuery({
queryKey: [`following:${handle}`],
async queryFn({pageParam}: {pageParam?: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getFollowingForProfile(handle, pageParam);
},
getNextPageParam(prevPage) {
return prevPage.next;
}
});
}
export function useSuggestedProfiles(handle: string, handles: string[]) {
const queryClient = useQueryClient();
const queryKey = ['profiles', {handles}];
const suggestedHandles = [
'@index@activitypub.ghost.org',
'@index@john.onolan.org',
'@index@www.coffeeandcomplexity.com',
'@index@ghost.codenamejimmy.com',
'@index@www.syphoncontinuity.com',
'@index@www.cosmico.org',
'@index@silverhuang.com'
];
const suggestedProfilesQuery = useQuery({
queryKey,
@ -359,7 +371,10 @@ export function useSuggestedProfiles(handle: string, handles: string[]) {
const api = createActivityPubAPI(handle, siteUrl);
return Promise.allSettled(
handles.map(h => api.getProfile(h))
suggestedHandles
.sort(() => Math.random() - 0.5)
.slice(0, limit)
.map(suggestedHandle => api.getProfile(suggestedHandle))
).then((results) => {
return results
.filter((result): result is PromiseFulfilledResult<Profile> => result.status === 'fulfilled')
@ -398,13 +413,44 @@ export function useProfileForUser(handle: string, fullHandle: string, enabled: b
});
}
export function useOutboxForUser(handle: string) {
return useQuery({
queryKey: [`outbox:${handle}`],
async queryFn() {
export function usePostsForProfile(handle: string) {
return useInfiniteQuery({
queryKey: [`posts:${handle}`],
async queryFn({pageParam}: {pageParam?: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getOutbox();
return api.getPostsForProfile(handle, pageParam);
},
getNextPageParam(prevPage) {
return prevPage.next;
}
});
}
export function useFollowersForProfile(handle: string) {
return useInfiniteQuery({
queryKey: [`followers:${handle}`],
async queryFn({pageParam}: {pageParam?: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getFollowersForProfile(handle, pageParam);
},
getNextPageParam(prevPage) {
return prevPage.next;
}
});
}
export function useFollowingForProfile(handle: string) {
return useInfiniteQuery({
queryKey: [`following:${handle}`],
async queryFn({pageParam}: {pageParam?: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getFollowingForProfile(handle, pageParam);
},
getNextPageParam(prevPage) {
return prevPage.next;
}
});
}
@ -437,6 +483,16 @@ export function useThreadForUser(handle: string, id: string) {
return {threadQuery, addToThread};
}
export function useReplyMutationForUser(handle: string) {
return useMutation({
async mutationFn({id, content}: {id: string, content: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return await api.reply(id, content) as Activity;
}
});
}
export function useNoteMutationForUser(handle: string) {
const queryClient = useQueryClient();
@ -455,7 +511,7 @@ export function useNoteMutationForUser(handle: string) {
return [activity, ...current];
});
queryClient.setQueriesData([`activities:${handle}`], (current?: {pages: {data: Activity[]}[]}) => {
queryClient.setQueriesData([`activities:${handle}`, GET_ACTIVITIES_QUERY_KEY_FEED], (current?: {pages: {data: Activity[]}[]}) => {
if (current === undefined) {
return current;
}

View file

@ -1,27 +0,0 @@
import {useMemo} from 'react';
import {useSuggestedProfiles as useSuggestedProfilesQuery} from '../hooks/useActivityPubQueries';
export const SUGGESTED_HANDLES = [
'@index@activitypub.ghost.org',
'@index@john.onolan.org',
'@index@www.coffeeandcomplexity.com',
'@index@ghost.codenamejimmy.com',
'@index@www.syphoncontinuity.com',
'@index@www.cosmico.org',
'@index@silverhuang.com'
];
const useSuggestedProfiles = (limit = 3) => {
const handles = useMemo(() => {
return SUGGESTED_HANDLES
.sort(() => Math.random() - 0.5)
.slice(0, limit);
}, [limit]);
const {suggestedProfilesQuery, updateSuggestedProfile} = useSuggestedProfilesQuery('index', handles);
const {data: suggested = [], isLoading: isLoadingSuggested} = suggestedProfilesQuery;
return {suggested, isLoadingSuggested, updateSuggestedProfile};
};
export default useSuggestedProfiles;

View file

@ -1,67 +1,160 @@
@import '@tryghost/admin-x-design-system/styles.css';
.admin-x-base.admin-x-activitypub {
animation-name: none;
animation-name: none;
}
@keyframes bump {
0% {
transform: scale(1);
}
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.bump {
animation: bump 0.3s ease-in-out;
button.ap-like-button:active svg {
animation: bump 0.3s ease-in-out;
}
.ap-red-heart path {
fill: #F50B23;
fill: #F50B23;
}
/* Note and profile content */
.ap-note-content a,
.ap-profile-content a {
color: #2563eb !important;
word-break: break-all;
.ap-profile-content a,
.ap-note-content-large a {
color: #2563eb !important;
word-break: break-all;
}
.ap-note-content a:hover,
.ap-profile-content a:hover {
color: #1d4ed8 !important;
text-decoration: underline !important;
.ap-profile-content a:hover,
.ap-note-content-large a:hover {
color: #1d4ed8 !important;
text-decoration: underline !important;
}
.ap-note-content span.invisible,
.ap-profile-content span.invisible {
display: none;
.ap-profile-content span.invisible,
.ap-note-content-large span.invisible {
display: none;
}
.ap-note-content a:not(.hashtag) span.ellipsis:after,
.ap-profile-content a:not(.hashtag) span.ellipsis:after,
.ap-note-content-large a:not(.hashtag) span.ellipsis:after,
.ap-likes .ellipsis::after {
content: "…";
content: "…";
}
.ap-note-content p+p {
margin-top: 1.5rem !important;
.ap-note-content > * + * {
margin-top: 1.2rem !important;
}
.ap-note-content-large > * + * {
margin-top: 1.6rem !important;
}
.ap-note-content > h1 + *,
.ap-note-content > h2 + *,
.ap-note-content > h3 + * {
margin-top: 0.6rem !important;
}
.ap-note-content-large > h1 + *,
.ap-note-content-large > h2 + *,
.ap-note-content-large > h3 + * {
margin-top: 0.8rem !important;
}
.ap-note-content figure,
.ap-note-content-large figure {
display: none;
}
.ap-note-content ul,
.ap-note-content-large ul {
list-style-type: '-' !important;
padding-left: 1rem !important;
}
.ap-note-content ol,
.ap-note-content-large ol {
list-style: auto !important;
padding-left: 1.7rem !important;
}
.ap-note-content li {
font-size: 1.4rem !important;
padding-left: 0.4rem !important;
}
.ap-note-content-large li {
font-size: 1.6rem !important;
padding-left: 0.4rem !important;
}
.ap-note-content h2, .ap-note-content h3, .ap-note-content h4, .ap-note-content h5 {
font-size: 1.4rem !important;
font-weight: 600 !important;
line-height: 1.375 !important;
}
.ap-note-content-large h2, .ap-note-content-large h3, .ap-note-content-large h4, .ap-note-content-large h5 {
font-size: 1.6rem !important;
}
.ap-note-content blockquote,
.ap-note-content-large blockquote {
border-left: 2px solid #E5E9ED;
padding-left: 0.8rem;
margin: 0.6rem 0 !important;
}
.ap-note-content blockquote p {
font-weight: 400;
font-size: 1.4rem;
}
.ap-note-content-large blockquote p {
font-weight: 400;
font-size: 1.6rem;
}
.ap-note-content code,
.ap-note-content-large code {
background-color: #F9FAFB !important;
font-size: 1.3rem !important;
color: #15171a;
}
.ap-note-content-large code {
font-size: 1.5rem !important;
}
.ap-note-content mark,
.ap-note-content-large mark {
background-color: transparent !important;
color: #15171a;
}
.ap-likes .invisible {
display: inline-block;
font-size: 0;
height: 0;
line-height: 0;
position: absolute;
width: 0;
display: inline-block;
font-size: 0;
height: 0;
line-height: 0;
position: absolute;
width: 0;
}
.ap-textarea {
field-sizing: content;
}
field-sizing: content;
}

View file

@ -1,6 +1,6 @@
export default function getReadingTime(content: string): string {
// Average reading speed (words per minute)
const wordsPerMinute = 238;
const wordsPerMinute = 275;
const wordCount = content.replace(/<[^>]*>/g, '')
.split(/\s+/)

View file

@ -0,0 +1,11 @@
import NiceModal from '@ebay/nice-modal-react';
import ViewProfileModal from '../components/modals/ViewProfileModal';
import getUsername from './get-username';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
export const handleProfileClick = (actor: ActorProperties, e?: React.MouseEvent) => {
e?.stopPropagation();
NiceModal.show(ViewProfileModal, {
profile: getUsername(actor)
});
};

View file

@ -0,0 +1,5 @@
const truncate = (text: string, maxLength: number = 30): string => {
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text;
};
export default truncate;

View file

@ -1,10 +0,0 @@
import {expect, test} from '@playwright/test';
// import {mockApi, responseFixtures} from '@tryghost/admin-x-framework/test/acceptance';
test.describe('Demo', async () => {
test('Renders the list page', async ({page}) => {
await page.goto('/');
await expect(page.locator('body')).toContainText('ActivityPub Inbox');
});
});

View file

@ -0,0 +1,9 @@
import {expect, test} from '@playwright/test';
test.describe('Inbox', async () => {
test('Renders the inbox page', async ({page}) => {
await page.goto('/');
await expect(page.locator('body')).toContainText('This is your inbox');
});
});

View file

@ -1,52 +0,0 @@
import {expect, test} from '@playwright/test';
import {mockApi, responseFixtures} from '@tryghost/admin-x-framework/test/acceptance';
test.describe('ListIndex', async () => {
test('Renders the list page', async ({page}) => {
const userId = 'index';
await mockApi({
page,
requests: {
useBrowseInboxForUser: {method: 'GET', path: `/inbox/${userId}`, response: responseFixtures.activitypubInbox},
useBrowseFollowingForUser: {method: 'GET', path: `/following/${userId}`, response: responseFixtures.activitypubFollowing}
},
options: {useActivityPub: true}
});
// Printing browser consol logs
page.on('console', (msg) => {
console.log(`Browser console log: ${msg.type()}: ${msg.text()}`); /* eslint-disable-line no-console */
});
await page.goto('/');
await expect(page.locator('body')).toContainText('ActivityPub Inbox');
// following list
const followingUser = await page.locator('[data-test-following] > li').textContent();
await expect(followingUser).toEqual('@index@main.ghost.org');
const followingCount = await page.locator('[data-test-following-count]').textContent();
await expect(followingCount).toEqual('1');
// following button
const followingList = await page.locator('[data-test-following-modal]');
await expect(followingList).toBeVisible();
// activities
const activity = await page.locator('[data-test-activity-heading]').textContent();
await expect(activity).toEqual('Testing ActivityPub');
// click on article
const articleBtn = await page.locator('[data-test-view-article]');
await articleBtn.click();
// article is expanded
const frameLocator = page.frameLocator('#gh-ap-article-iframe');
const textElement = await frameLocator.locator('[data-test-article-heading]').innerText();
expect(textElement).toContain('Testing ActivityPub');
// go back to list
const backBtn = await page.locator('[data-test-back-button]');
await backBtn.click();
});
});

View file

@ -1,71 +0,0 @@
import {Mock, vi} from 'vitest';
import {renderHook} from '@testing-library/react';
import type {UseQueryResult} from '@tanstack/react-query';
import * as useActivityPubQueries from '../../../src/hooks/useActivityPubQueries';
import useSuggestedProfiles, {SUGGESTED_HANDLES} from '../../../src/hooks/useSuggestedProfiles';
import type{Profile} from '../../../src/api/activitypub';
vi.mock('../../../src/hooks/useActivityPubQueries');
describe('useSuggestedProfiles', function () {
let mockUpdateSuggestedProfile: Mock;
beforeEach(function () {
mockUpdateSuggestedProfile = vi.fn();
vi.mocked(useActivityPubQueries.useSuggestedProfiles).mockImplementation((handle, handles) => {
// We expect the handle to be 'index', throw if anything else
if (handle !== 'index') {
throw new Error(`Expected handle to be: [index], got: [${handle}]`);
}
// Return the mocked query result making sure that the data is the
// same as the handles passed in. For the purposes of this test,
// we don't need to test the internals of useSuggestedProfilesQuery
return {
suggestedProfilesQuery: {
data: handles,
isLoading: false
} as unknown as UseQueryResult<Profile[], unknown>,
updateSuggestedProfile: mockUpdateSuggestedProfile
};
});
});
it('should return the default number of suggested profiles', function () {
const {result} = renderHook(() => useSuggestedProfiles());
// Check that the correct number of suggested profiles are returned
expect(result.current.suggested.length).toEqual(3);
// Check that the suggested profiles are in the SUGGESTED_HANDLES array
result.current.suggested.forEach((suggested) => {
expect(SUGGESTED_HANDLES).toContain(suggested);
});
});
it('should return the specified number of suggested profiles', function () {
const {result} = renderHook(() => useSuggestedProfiles(5));
// Assert that the correct number of suggested profiles are returned
expect(result.current.suggested.length).toEqual(5);
// Assert that the suggested profiles are in the SUGGESTED_HANDLES array
result.current.suggested.forEach((suggested) => {
expect(SUGGESTED_HANDLES).toContain(suggested);
});
});
it('should return a loading state', function () {
const {result} = renderHook(() => useSuggestedProfiles());
expect(result.current.isLoadingSuggested).toEqual(false);
});
it('should return a function to update a suggested profile', function () {
const {result} = renderHook(() => useSuggestedProfiles());
expect(result.current.updateSuggestedProfile).toEqual(mockUpdateSuggestedProfile);
});
});

View file

@ -76,7 +76,7 @@
"@sentry/react": "7.119.2",
"@tailwindcss/forms": "0.5.9",
"@tailwindcss/line-clamp": "0.4.4",
"@uiw/react-codemirror": "4.23.6",
"@uiw/react-codemirror": "4.23.7",
"autoprefixer": "10.4.19",
"clsx": "2.1.1",
"postcss": "8.4.39",

View file

@ -0,0 +1 @@
<svg viewBox="-0.75 -0.75 20 20" xmlns="http://www.w3.org/2000/svg" height="24" width="24"><desc></desc><path d="m0.578125 9.279291666666667 17.34375 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>

After

Width:  |  Height:  |  Size: 267 B

View file

@ -0,0 +1 @@
<svg viewBox="-0.75 -0.75 20 20" xmlns="http://www.w3.org/2000/svg" height="24" width="24"><desc>Typography</desc><path d="m0.578125 9.827354166666668 8.09375 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="m12.140625 13.296104166666666 5.78125 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M12.140625 16.764854166666666V10.40625a2.890625 2.890625 0 0 1 5.78125 0v6.359375" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M0.578125 16.764854166666666V5.78125a4.046875 4.046875 0 0 1 8.09375 0v10.984375" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>

After

Width:  |  Height:  |  Size: 830 B

View file

@ -16,7 +16,7 @@ export interface ModalProps {
* Possible values are: `sm`, `md`, `lg`, `xl, `full`, `bleed`. Yu can also use any number to set an arbitrary width.
*/
size?: ModalSize;
width?: 'full' | number;
width?: 'full' | 'toSidebar' | number;
height?: 'full' | number;
align?: 'center' | 'left' | 'right';
@ -47,6 +47,7 @@ export interface ModalProps {
animate?: boolean;
formSheet?: boolean;
enableCMDS?: boolean;
allowBackgroundInteraction?: boolean;
}
export const topLevelBackdropClasses = 'bg-[rgba(98,109,121,0.2)] backdrop-blur-[3px]';
@ -82,7 +83,8 @@ const Modal: React.FC<ModalProps> = ({
dirty = false,
animate = true,
formSheet = false,
enableCMDS = true
enableCMDS = true,
allowBackgroundInteraction = false
}) => {
const modal = useModal();
const {setGlobalDirtyState} = useGlobalDirtyState();
@ -203,7 +205,8 @@ const Modal: React.FC<ModalProps> = ({
);
let backdropClasses = clsx(
'fixed inset-0 z-[1000] h-[100vh] w-[100vw]'
'fixed inset-0 z-[1000] h-[100vh] w-[100vw]',
allowBackgroundInteraction && 'pointer-events-none'
);
let paddingClasses = '';
@ -375,6 +378,11 @@ const Modal: React.FC<ModalProps> = ({
modalClasses,
'w-full'
);
} else if (width === 'toSidebar') {
modalClasses = clsx(
modalClasses,
'w-full max-w-[calc(100dvw_-_280px)] lg:max-w-full min-[1280px]:max-w-[calc(100dvw_-_320px)]'
);
}
if (typeof height === 'number') {
@ -422,7 +430,10 @@ const Modal: React.FC<ModalProps> = ({
(backDrop && !formSheet) && topLevelBackdropClasses,
formSheet && 'bg-[rgba(98,109,121,0.08)]'
)}></div>
<section className={modalClasses} data-testid={testId} style={modalStyles}>
<section className={clsx(
modalClasses,
allowBackgroundInteraction && 'pointer-events-auto'
)} data-testid={testId} style={modalStyles}>
{header === false ? '' : (!topRightContent || topRightContent === 'close' ?
(<header className={headerClasses}>
{title && <Heading level={3}>{title}</Heading>}

View file

@ -85,6 +85,7 @@
"@sentry/react": "7.119.2",
"@tanstack/react-query": "4.36.1",
"@tryghost/admin-x-design-system": "0.0.0",
"@tryghost/shade": "0.0.0",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"@vitejs/plugin-react": "4.2.1",
@ -115,4 +116,4 @@
}
}
}
}
}

View file

@ -14,7 +14,11 @@ export type ObjectProperties = {
content: string;
url?: string | undefined;
attributedTo?: object | string | object[] | undefined;
image?: string;
image?: string | {
url: string;
mediaType?: string;
type?: string;
};
published?: string;
preview?: {type: string, content: string};
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View file

@ -52,6 +52,11 @@ export const useBrowseThemes = createQuery<ThemesResponseType>({
path: '/themes/'
});
export const useActiveTheme = createQuery<ThemesInstallResponseType>({
dataType,
path: '/themes/active/'
});
export const useActivateTheme = createMutation<ThemesResponseType, string>({
method: 'PUT',
path: name => `/themes/${name}/activate/`,

View file

@ -1,4 +0,0 @@
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<title>amp</title>
<path d="m171.887 116.28-53.696 89.36h-9.728l9.617-58.227-30.2.047a4.854 4.854 0 0 1-4.855-4.855c0-1.152 1.07-3.102 1.07-3.102l53.52-89.254 9.9.043-9.86 58.317 30.413-.043a4.854 4.854 0 0 1 4.855 4.855c0 1.088-.427 2.044-1.033 2.854l.004.004-.007.001zM128 0C57.306 0 0 57.3 0 128s57.306 128 128 128 128-57.306 128-128S198.7 0 128 0z" fill="#0379C4" fill-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 492 B

View file

@ -21,7 +21,6 @@ export const modalPaths: {[key: string]: ModalName} = {
'history/view/:user': 'HistoryModal',
'integrations/zapier': 'ZapierModal',
'integrations/slack': 'SlackModal',
'integrations/amp': 'AmpModal',
'integrations/unsplash': 'UnsplashModal',
'integrations/firstpromoter': 'FirstpromoterModal',
'integrations/pintura': 'PinturaModal',

View file

@ -6,7 +6,6 @@ import AddIntegrationModal from '../../settings/advanced/integrations/AddIntegra
import AddNewsletterModal from '../../settings/email/newsletters/AddNewsletterModal';
// import AddOfferModal from '../../settings/growth/offers/AddOfferModal';
import AddRecommendationModal from '../../settings/growth/recommendations/AddRecommendationModal';
import AmpModal from '../../settings/advanced/integrations/AmpModal';
import AnnouncementBarModal from '../../settings/site/AnnouncementBarModal';
import CustomIntegrationModal from '../../settings/advanced/integrations/CustomIntegrationModal';
import DesignAndThemeModal from '../../settings/site/DesignAndThemeModal';
@ -34,7 +33,6 @@ const modals = {
AddIntegrationModal,
AddNewsletterModal,
AddRecommendationModal,
AmpModal,
CustomIntegrationModal,
DesignAndThemeModal,
EditRecommendationModal,

View file

@ -8,7 +8,7 @@ import React from 'react';
import SearchableSection from '../../SearchableSection';
export const searchKeywords = {
integrations: ['advanced', 'integrations', 'zapier', 'slack', 'amp', 'unsplash', 'first promoter', 'firstpromoter', 'pintura', 'disqus', 'analytics', 'ulysses', 'typeform', 'buffer', 'plausible', 'github'],
integrations: ['advanced', 'integrations', 'zapier', 'slack', 'unsplash', 'first promoter', 'firstpromoter', 'pintura', 'disqus', 'analytics', 'ulysses', 'typeform', 'buffer', 'plausible', 'github'],
migrationtools: ['import', 'export', 'migrate', 'substack', 'substack', 'migration', 'medium'],
codeInjection: ['advanced', 'code injection', 'head', 'footer'],
labs: ['advanced', 'labs', 'alpha', 'beta', 'flag', 'routes', 'redirect', 'translation', 'editor', 'portal'],

View file

@ -2,7 +2,6 @@ import NiceModal from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import TopLevelGroup from '../../TopLevelGroup';
import usePinturaEditor from '../../../hooks/usePinturaEditor';
import {ReactComponent as AmpIcon} from '../../../assets/icons/amp.svg';
import {Button, ConfirmationModal, Icon, List, ListItem, NoValueLabel, TabView, showToast, withErrorBoundary} from '@tryghost/admin-x-design-system';
import {ReactComponent as FirstPromoterIcon} from '../../../assets/icons/firstpromoter.svg';
import {Integration, useBrowseIntegrations, useDeleteIntegration} from '@tryghost/admin-x-framework/api/integrations';
@ -43,7 +42,7 @@ const IntegrationItem: React.FC<IntegrationItemProps> = ({
const handleClick = (e?: React.MouseEvent<HTMLElement>) => {
// Prevent the click event from bubbling up when clicking the delete button
e?.stopPropagation();
if (disabled) {
updateRoute({route: 'pro', isExternal: true});
} else {
@ -52,7 +51,7 @@ const IntegrationItem: React.FC<IntegrationItemProps> = ({
};
const handleDelete = (e?: React.MouseEvent<HTMLElement>) => {
e?.stopPropagation();
e?.stopPropagation();
onDelete?.();
};
@ -89,8 +88,7 @@ const BuiltInIntegrations: React.FC = () => {
const pinturaEditor = usePinturaEditor();
const {settings} = useGlobalData();
const [ampEnabled, unsplashEnabled, firstPromoterEnabled, slackUrl, slackUsername] = getSettingValues<boolean>(settings, [
'amp',
const [unsplashEnabled, firstPromoterEnabled, slackUrl, slackUsername] = getSettingValues<boolean>(settings, [
'unsplash',
'firstpromoter',
'slack_url',
@ -119,16 +117,6 @@ const BuiltInIntegrations: React.FC = () => {
testId='slack-integration'
title='Slack' />
<IntegrationItem
action={() => {
openModal('integrations/amp');
}}
active={ampEnabled}
detail='Google AMP will be removed in Ghost 6.0'
icon={<AmpIcon className='h-8 w-8' />}
testId='amp-integration'
title='AMP' />
<IntegrationItem
action={() => {
openModal('integrations/unsplash');

View file

@ -1,98 +0,0 @@
import IntegrationHeader from './IntegrationHeader';
import NiceModal from '@ebay/nice-modal-react';
import {Form, Modal, TextField, Toggle} from '@tryghost/admin-x-design-system';
import {ReactComponent as Icon} from '../../../../assets/icons/amp.svg';
import {Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings';
import {useEffect, useState} from 'react';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
import {useRouting} from '@tryghost/admin-x-framework/routing';
const AmpModal = NiceModal.create(() => {
const {updateRoute} = useRouting();
const {settings} = useGlobalData();
const [ampEnabled] = getSettingValues<boolean>(settings, ['amp']);
const [ampId] = getSettingValues<string>(settings, ['amp_gtag_id']);
const [trackingId, setTrackingId] = useState<string | null>('');
const {mutateAsync: editSettings} = useEditSettings();
const handleError = useHandleError();
const [okLabel, setOkLabel] = useState('Save');
const [enabled, setEnabled] = useState<boolean>(!!ampEnabled);
useEffect(() => {
setEnabled(ampEnabled || false);
setTrackingId(ampId || null);
}, [ampEnabled, ampId]);
const handleSave = async () => {
const updates: Setting[] = [
{key: 'amp', value: enabled},
{key: 'amp_gtag_id', value: trackingId}
];
try {
setOkLabel('Saving...');
await Promise.all([
editSettings(updates),
new Promise((resolve) => {
setTimeout(resolve, 1000);
})
]);
setOkLabel('Saved');
} catch (e) {
handleError(e);
} finally {
setTimeout(() => setOkLabel('Save'), 1000);
}
};
const isDirty = !(enabled === ampEnabled) || !(trackingId === ampId);
return (
<Modal
afterClose={() => {
updateRoute('integrations');
}}
cancelLabel='Close'
dirty={isDirty}
okColor={okLabel === 'Saved' ? 'green' : 'black'}
okLabel={okLabel}
testId='amp-modal'
title=''
onOk={async () => {
await handleSave();
}}
>
<IntegrationHeader
detail='Accelerated Mobile Pages'
icon={<Icon className='h-14 w-14' />}
title='AMP'
/>
<div className='mt-7'>
<Form marginBottom={false} title='AMP configuration' grouped>
<Toggle
checked={enabled}
direction='rtl'
hint={<>Google AMP is <a className='text-green' href="https://en.m.wikipedia.org/wiki/Accelerated_Mobile_Pages" rel="noopener noreferrer" target='_blank'>being retired</a> this feature will be removed in Ghost 6.0</>}
label='Enable AMP'
onChange={(e) => {
setEnabled(e.target.checked);
}}
/>
{enabled && (
<TextField
hint='Tracks AMP traffic in Google Analytics'
placeholder='UA-XXXXXXX-X'
title='Google Analytics Tracking ID'
value={trackingId || ''}
onChange={(e) => {
setTrackingId(e.target.value);
}}
/>
)}
</Form>
</div>
</Modal>
);
});
export default AmpModal;

View file

@ -7,10 +7,6 @@ const features = [{
title: 'URL cache',
description: 'Enable URL Caching',
flag: 'urlCache'
},{
title: 'Lexical multiplayer',
description: 'Enables multiplayer editing in the lexical editor.',
flag: 'lexicalMultiplayer'
},{
title: 'Webmentions',
description: 'Allows viewing received mentions on the dashboard.',
@ -23,10 +19,6 @@ const features = [{
title: 'Email customization',
description: 'Adding more control over the newsletter template',
flag: 'emailCustomization'
},{
title: 'Collections',
description: 'Enables Collections 2.0',
flag: 'collections'
},{
title: 'Collections Card',
description: 'Enables the Collections Card for pages - requires Collections and the beta Editor to be enabled',
@ -59,10 +51,6 @@ const features = [{
title: 'Comment Improvements',
description: 'Enables new comment features',
flag: 'commentImprovements'
}, {
title: 'Staff 2FA',
description: 'Enables email verification for staff logins',
flag: 'staff2fa'
}];
const AlphaFeatures: React.FC = () => {

View file

@ -27,10 +27,6 @@ const BetaFeatures: React.FC = () => {
action={<FeatureToggle flag='i18n' />}
detail={<>Translate your membership flows into your publication language (<a className='text-green' href="https://github.com/TryGhost/Ghost/tree/main/ghost/i18n/locales" rel="noopener noreferrer" target="_blank">supported languages</a>). Dont see yours? <a className='text-green' href="https://forum.ghost.org/t/help-translate-ghost-beta/37461" rel="noopener noreferrer" target="_blank">Get involved</a></>}
title='Portal translation' />
<LabItem
action={<FeatureToggle flag='customFonts' />}
detail={<>Enable new custom font settings. <a className='text-green' href="https://ghost.org/docs/themes/custom-settings/#setting-up-support-for-custom-fonts" rel="noopener noreferrer" target="_blank">Learn more &rarr;</a></>}
title='Custom fonts' />
<LabItem
action={<div className='flex flex-col items-end gap-1'>
<FileUpload

View file

@ -9,7 +9,7 @@ import {useGlobalData} from '../../providers/GlobalDataProvider';
export const searchKeywords = {
enableNewsletters: ['emails', 'newsletters', 'newsletter sending', 'enable', 'disable', 'turn on', 'turn off'],
newsletters: ['newsletters', 'emails'],
newsletters: ['newsletters', 'emails', 'design', 'customization'],
defaultRecipients: ['newsletters', 'default recipients', 'emails'],
mailgun: ['mailgun', 'emails', 'newsletters']
};

View file

@ -150,6 +150,7 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
return (
<TopLevelGroup
customButtons={buttons}
description="Edit details and customize your design"
keywords={keywords}
navid='newsletters'
testId='newsletters'

View file

@ -1,5 +1,6 @@
import React from 'react';
import ThemeSetting from './ThemeSetting';
import useCustomFonts from '../../../../hooks/useCustomFonts';
import useFeatureFlag from '../../../../hooks/useFeatureFlag';
import {CustomThemeSetting} from '@tryghost/admin-x-framework/api/customThemeSettings';
import {Form} from '@tryghost/admin-x-design-system';
@ -46,6 +47,7 @@ const ThemeSettings: React.FC<ThemeSettingsProps> = ({sections, updateSetting})
const activeThemeName = activeTheme?.package.name?.toLowerCase() || '';
const activeThemeAuthor = activeTheme?.package.author?.name || '';
const hasCustomFonts = useFeatureFlag('customFonts');
const {supportsCustomFonts} = useCustomFonts();
return (
<>
@ -70,7 +72,7 @@ const ThemeSettings: React.FC<ThemeSettingsProps> = ({sections, updateSetting})
// should be removed once we remove the settings from the themes in 6.0
if (hasCustomFonts) {
const hidingSettings = themeSettingsMap[activeThemeName];
if (hidingSettings && hidingSettings.includes(setting.key) && activeThemeAuthor === 'Ghost Foundation') {
if (hidingSettings && hidingSettings.includes(setting.key) && activeThemeAuthor === 'Ghost Foundation' && supportsCustomFonts) {
spaceClass += ' hidden';
}
}

View file

@ -1,6 +1,7 @@
import InvalidThemeModal, {FatalErrors} from './InvalidThemeModal';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import useCustomFonts from '../../../../hooks/useCustomFonts';
import {Button, ButtonProps, ConfirmationModal, List, ListItem, Menu, ModalPage, showToast} from '@tryghost/admin-x-design-system';
import {JSONError} from '@tryghost/admin-x-framework/errors';
import {Theme, isActiveTheme, isDefaultTheme, isDeletableTheme, isLegacyTheme, useActivateTheme, useDeleteTheme} from '@tryghost/admin-x-framework/api/themes';
@ -48,11 +49,13 @@ const ThemeActions: React.FC<ThemeActionProps> = ({
}) => {
const {mutateAsync: activateTheme} = useActivateTheme();
const {mutateAsync: deleteTheme} = useDeleteTheme();
const {refreshActiveThemeData} = useCustomFonts();
const handleError = useHandleError();
const handleActivate = async () => {
try {
await activateTheme(theme.name);
refreshActiveThemeData();
showToast({
title: 'Theme activated',
type: 'success',

View file

@ -1,5 +1,6 @@
import NiceModal from '@ebay/nice-modal-react';
import React, {ReactNode, useState} from 'react';
import useCustomFonts from '../../../../hooks/useCustomFonts';
import {Button, ConfirmationModalContent, Heading, List, ListItem, showToast} from '@tryghost/admin-x-design-system';
import {InstalledTheme, ThemeProblem, useActivateTheme} from '@tryghost/admin-x-framework/api/themes';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
@ -42,6 +43,7 @@ const ThemeInstalledModal: React.FC<{
onActivate?: () => void;
}> = ({title, prompt, installedTheme, onActivate}) => {
const {mutateAsync: activateTheme} = useActivateTheme();
const {refreshActiveThemeData} = useCustomFonts();
const handleError = useHandleError();
let errorPrompt = null;
@ -85,6 +87,7 @@ const ThemeInstalledModal: React.FC<{
try {
const resData = await activateTheme(installedTheme.name);
const updatedTheme = resData.themes[0];
refreshActiveThemeData();
showToast({
title: 'Theme activated',

View file

@ -0,0 +1,15 @@
import {useActiveTheme} from '@tryghost/admin-x-framework/api/themes';
import {useCallback} from 'react';
const useCustomFonts = () => {
const activeThemes = useActiveTheme();
const activeTheme = activeThemes.data?.themes[0];
const supportsCustomFonts = !activeTheme?.warnings?.some(warning => warning.code === 'GS051-CUSTOM-FONTS');
const refreshActiveThemeData = useCallback(() => {
activeThemes.refetch();
}, [activeThemes]);
return {supportsCustomFonts, refreshActiveThemeData};
};
export default useCustomFonts;

View file

@ -1,63 +0,0 @@
import {expect, test} from '@playwright/test';
import {globalDataRequests} from '../../../utils/acceptance';
import {mockApi, responseFixtures, updatedSettingsResponse} from '@tryghost/admin-x-framework/test/acceptance';
test.describe('AMP integration', async () => {
test('Supports toggling and filling in AMP integration', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([
{key: 'amp', value: true},
{key: 'amp_gtag_id', value: 'UA-1234567-1'}
])}
}});
await page.goto('/');
const section = page.getByTestId('integrations');
const ampElement = section.getByText('AMP').last();
await ampElement.hover();
await page.getByRole('button', {name: 'Configure'}).click();
const ampModal = page.getByTestId('amp-modal');
const ampToggle = ampModal.getByRole('switch');
await ampToggle.click();
const input = ampModal.getByRole('textbox');
await input.fill('UA-1234567-1');
await ampModal.getByRole('button', {name: 'Save'}).click();
expect(lastApiRequests.editSettings?.body).toEqual({
settings: [
{key: 'amp', value: true},
{key: 'amp_gtag_id', value: 'UA-1234567-1'}
]
});
});
test('Warns when leaving without saving', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
editSettings: {method: 'PUT', path: '/settings/', response: responseFixtures.settings}
}});
await page.goto('/');
const section = page.getByTestId('integrations');
const ampElement = section.getByText('AMP').last();
await ampElement.hover();
await page.getByRole('button', {name: 'Configure'}).click();
const ampModal = page.getByTestId('amp-modal');
const ampToggle = ampModal.getByRole('switch');
await ampToggle.click();
await ampModal.getByRole('button', {name: 'Close'}).click();
await expect(page.getByTestId('confirmation-modal')).toHaveText(/leave/i);
await page.getByTestId('confirmation-modal').getByRole('button', {name: 'Leave'}).click();
await expect(ampModal).toBeHidden();
expect(lastApiRequests.editSettings).toBeUndefined();
});
});

View file

@ -4,7 +4,7 @@ import {mockApi, responseFixtures} from '@tryghost/admin-x-framework/test/accept
test.describe('Integrations List', async () => {
// This is a test for the integrations list, which is a list of integrations that can be toggled on and off
// To ensure the app logic shows the correct initial state, all integrations are disabled by default, except for Unsplash
// To ensure the app logic shows the correct initial state, all integrations are disabled by default, except for Unsplash
test('Only Unsplash Shows Active on initial new setup', async ({page}) => {
await mockApi({page, requests: {
...globalDataRequests,
@ -15,21 +15,18 @@ test.describe('Integrations List', async () => {
// const zapierElement = await section.getByText('Zapier').last();
const zapierElement = section.getByTestId('zapier-integration');
const slackElement = section.getByTestId('slack-integration');
const ampElement = section.getByTestId('amp-integration');
const unsplashElement = section.getByTestId('unsplash-integration');
const firstPromoterElement = section.getByTestId('firstpromoter-integration');
const pinturaElement = section.getByTestId('pintura-integration');
const zapierStatus = await zapierElement.getByText('Active');
const slackStatus = await slackElement.getByText('Active');
const ampStatus = await ampElement.getByText('Active');
const unsplashStatus = await unsplashElement.getByText('Active');
const firstPromoterStatus = await firstPromoterElement.getByText('Active');
const pinturaStatus = await pinturaElement.getByText('Active');
expect(await zapierStatus.isVisible()).toBe(false);
expect(await slackStatus.isVisible()).toBe(false);
expect(await ampStatus.isVisible()).toBe(false);
expect(await unsplashStatus.isVisible()).toBe(true); // Unsplash is the only active integration
expect(await firstPromoterStatus.isVisible()).toBe(false);
expect(await pinturaStatus.isVisible()).toBe(false);

View file

@ -399,4 +399,204 @@ test.describe('Design settings', async () => {
expect(matchingHeader).toBeDefined();
// expect(lastRequest.previewHeader).toMatch(new RegExp(`&${expectedEncoded.replace(/\+/g, '\\+')}`));
});
test('Old font settings are hidden with custom fonts support', async ({page}) => {
toggleLabsFlag('customFonts', true);
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
installTheme: {method: 'POST', path: /^\/themes\/install\/\?/, response: {
themes: [{
name: 'headline',
package: {},
active: false,
templates: []
}]
}},
activateTheme: {method: 'PUT', path: '/themes/headline/activate/', response: {
themes: [{
name: 'headline',
package: {
name: 'headline',
author: {
name: 'Ghost Foundation'
}
},
active: true,
templates: []
}]
}},
browseCustomThemeSettings: {method: 'GET', path: '/custom_theme_settings/', response: {
custom_theme_settings: [
{
type: 'select',
options: [
'Modern sans-serif',
'Elegant serif'
],
default: 'Modern sans-serif',
value: 'Modern sans-serif',
key: 'title_font'
},
{
type: 'select',
options: [
'Modern sans-serif',
'Elegant serif'
],
default: 'Elegant serif',
value: 'Elegant serif',
key: 'body_font'
}
]
}},
activeTheme: {
method: 'GET',
path: '/themes/active/',
response: {
themes: [{
name: 'casper',
package: {},
active: true,
templates: []
}]
}
}
}});
await page.goto('/');
const themeSection = page.getByTestId('theme');
await themeSection.getByRole('button', {name: 'Change theme'}).click();
const modal = page.getByTestId('theme-modal');
await modal.getByRole('button', {name: /Headline/}).click();
await modal.getByRole('button', {name: 'Install Headline'}).click();
await expect(page.getByTestId('confirmation-modal')).toHaveText(/installed/);
await page.getByRole('button', {name: 'Activate'}).click();
await expect(page.getByTestId('toast-success')).toHaveText(/headline is now your active theme/);
expect(lastApiRequests.installTheme?.url).toMatch(/\?source=github&ref=TryGhost%2FHeadline/);
await modal.getByRole('button', {name: 'Change theme'}).click();
await modal.getByRole('button', {name: 'Close'}).click();
const designSection = page.getByTestId('design');
await designSection.getByRole('button', {name: 'Customize'}).click();
const designModal = page.getByTestId('design-modal');
await designModal.getByRole('tab', {name: 'Theme'}).click();
const titleFontCustomThemeSetting = designModal.getByLabel('Title font');
await expect(titleFontCustomThemeSetting).not.toBeVisible();
const bodyFontCustomThemeSetting = designModal.getByLabel('Body font');
await expect(bodyFontCustomThemeSetting).not.toBeVisible();
});
test('Old font settings are visible with no custom fonts support', async ({page}) => {
toggleLabsFlag('customFonts', true);
await mockApi({page, requests: {
...globalDataRequests,
browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
activateTheme: {method: 'PUT', path: '/themes/casper/activate/', response: {
themes: [{
name: 'casper',
package: {},
active: true,
templates: []
}]
}},
browseCustomThemeSettings: {method: 'GET', path: '/custom_theme_settings/', response: {
custom_theme_settings: [
{
type: 'select',
options: [
'Modern sans-serif',
'Elegant serif'
],
default: 'Modern sans-serif',
value: 'Modern sans-serif',
key: 'title_font'
},
{
type: 'select',
options: [
'Modern sans-serif',
'Elegant serif'
],
default: 'Elegant serif',
value: 'Elegant serif',
key: 'body_font'
}
]
}},
activeTheme: {
method: 'GET',
path: '/themes/active/',
response: {
themes: [{
name: 'casper',
package: {},
active: true,
templates: [],
warnings: [{
fatal: false,
level: 'warning',
rule: 'Missing support for custom fonts',
details: 'CSS variables for Ghost font settings are not present: <code>--gh-font-heading</code>, <code>--gh-font-body</code>',
regex: {},
failures: [
{
ref: 'styles'
}
],
code: 'GS051-CUSTOM-FONTS'
}]
}]
}
}
}});
await page.goto('/');
const themeSection = page.getByTestId('theme');
await themeSection.getByRole('button', {name: 'Change theme'}).click();
const modal = page.getByTestId('theme-modal');
await modal.getByRole('button', {name: /Casper/}).click();
await expect(modal.getByRole('button', {name: 'Activate Casper'})).toBeVisible();
await expect(page.locator('iframe[title="Theme preview"]')).toHaveAttribute('src', 'https://demo.ghost.io/');
await modal.getByRole('button', {name: 'Change theme'}).click();
await modal.getByRole('button', {name: 'Close'}).click();
const designSection = page.getByTestId('design');
await designSection.getByRole('button', {name: 'Customize'}).click();
const designModal = page.getByTestId('design-modal');
await designModal.getByRole('tab', {name: 'Theme'}).click();
const titleFontCustomThemeSetting = designModal.getByLabel('Title font');
await expect(titleFontCustomThemeSetting).toBeVisible();
const bodyFontCustomThemeSetting = designModal.getByLabel('Body font');
await expect(bodyFontCustomThemeSetting).toBeVisible();
});
});

View file

@ -22,7 +22,19 @@ test.describe('Theme settings', async () => {
active: true,
templates: []
}]
}}
}},
activeTheme: {
method: 'GET',
path: '/themes/active/',
response: {
themes: [{
name: 'casper',
package: {},
active: true,
templates: []
}]
}
}
}});
await page.goto('/');

View file

@ -1,6 +1,6 @@
{
"name": "@tryghost/comments-ui",
"version": "0.23.1",
"version": "1.0.5",
"license": "MIT",
"repository": "git@github.com:TryGhost/comments-ui.git",
"author": "Ghost Foundation",

View file

@ -7,7 +7,7 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react';
import i18nLib from '@tryghost/i18n';
import setupGhostApi from './utils/api';
import {ActionHandler, SyncActionHandler, isSyncAction} from './actions';
import {AppContext, DispatchActionType, EditableAppContext, LabsContextType} from './AppContext';
import {AppContext, DispatchActionType, EditableAppContext} from './AppContext';
import {CommentsFrame} from './components/Frame';
import {setupAdminAPI} from './utils/adminApi';
import {useOptions} from './utils/options';
@ -29,7 +29,9 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
popup: null,
labs: {},
order: 'count__likes desc, created_at desc',
adminApi: null
adminApi: null,
commentsIsLoading: false,
commentIdToHighlight: null
});
const iframeRef = React.createRef<HTMLIFrameElement>();
@ -42,8 +44,6 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
});
}, [options]);
// const [adminApi, setAdminApi] = useState<AdminApi|null>(null);
const setState = useCallback((newState: Partial<EditableAppContext> | ((state: EditableAppContext) => Partial<EditableAppContext>)) => {
setFullState((state) => {
if (typeof newState === 'function') {
@ -75,7 +75,7 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
// allow for async actions within it's updater function so this is the best option.
return new Promise((resolve) => {
setState((state) => {
ActionHandler({action, data, state, api, adminApi: state.adminApi!, options}).then((updatedState) => {
ActionHandler({action, data, state, api, adminApi: state.adminApi!, options, dispatchAction: dispatchAction as DispatchActionType}).then((updatedState) => {
const newState = {...updatedState};
resolve(newState);
setState(newState);
@ -112,15 +112,14 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
let admin = null;
try {
admin = await adminApi.getUser();
if (admin && state.labs.commentImprovements) {
if (admin) {
// this is a bit of a hack, but we need to fetch the comments fully populated if the user is an admin
const adminComments = await adminApi.browse({page: 1, postId: options.postId, order: state.order});
const adminComments = await adminApi.browse({page: 1, postId: options.postId, order: state.order, memberUuid: state.member?.uuid});
setState({
...state,
adminApi: adminApi,
comments: adminComments.comments,
pagination: adminComments.meta.pagination,
commentCount: adminComments.meta.pagination.total
pagination: adminComments.meta.pagination
});
}
} catch (e) {
@ -140,14 +139,8 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
};
/** Fetch first few comments */
const fetchComments = async (labs: LabsContextType) => {
let dataPromise;
if (labs?.commentImprovements) {
dataPromise = api.comments.browse({page: 1, postId: options.postId, order: state.order});
} else {
dataPromise = api.comments.browse({page: 1, postId: options.postId});
}
const fetchComments = async () => {
const dataPromise = api.comments.browse({page: 1, postId: options.postId, order: state.order});
const countPromise = api.comments.count({postId: options.postId});
const [data, count] = await Promise.all([dataPromise, countPromise]);
@ -164,16 +157,17 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
try {
// Fetch data from API, links, preview, dev sources
const {member, labs} = await api.init();
const {comments, pagination, count} = await fetchComments(labs);
const order = labs.commentImprovements ? 'count__likes desc, created_at desc' : 'created_at desc';
const {comments, pagination, count} = await fetchComments();
const state = {
member,
initStatus: 'success',
comments,
pagination,
commentCount: count,
order,
labs: labs
order: 'count__likes desc, created_at desc',
labs: labs,
commentsIsLoading: false,
commentIdToHighlight: null
};
setState(state);

View file

@ -81,7 +81,9 @@ export type EditableAppContext = {
popup: Page | null,
labs: LabsContextType,
order: string,
adminApi: AdminApi | null
adminApi: AdminApi | null,
commentsIsLoading?: boolean
commentIdToHighlight: string | null
}
export type TranslationFunction = (key: string, replacements?: Record<string, string | number>) => string;
@ -118,3 +120,4 @@ export const useLabs = () => {
return {};
}
};

View file

@ -0,0 +1,36 @@
import {Actions} from './actions';
describe('Actions', function () {
describe('loadMoreComments', function () {
it('deduplicates comments', async function () {
const state = {
comments: [
{id: '1'},
{id: '2'},
{id: '3'}
]
};
const api = {
comments: {
browse: () => Promise.resolve({
comments: [
{id: '2'},
{id: '3'},
{id: '4'}
],
meta: {
pagination: {}
}
})
}
};
const newState = await Actions.loadMoreComments({state, api, options: {postId: '1'}, order: 'desc'});
expect(newState.comments).toEqual([
{id: '1'},
{id: '2'},
{id: '3'},
{id: '4'}
]);
});
});
});

View file

@ -1,4 +1,4 @@
import {AddComment, Comment, CommentsOptions, EditableAppContext, OpenCommentForm} from './AppContext';
import {AddComment, Comment, CommentsOptions, DispatchActionType, EditableAppContext, OpenCommentForm} from './AppContext';
import {AdminApi} from './utils/adminApi';
import {GhostApi} from './utils/api';
import {Page} from './pages';
@ -9,38 +9,56 @@ async function loadMoreComments({state, api, options, order}: {state: EditableAp
page = state.pagination.page + 1;
}
let data;
if (state.admin && state.adminApi && state.labs.commentImprovements) {
data = await state.adminApi.browse({page, postId: options.postId, order: order || state.order});
if (state.admin && state.adminApi) {
data = await state.adminApi.browse({page, postId: options.postId, order: order || state.order, memberUuid: state.member?.uuid});
} else {
data = await api.comments.browse({page, postId: options.postId, order: order || state.order});
}
const updatedComments = [...state.comments, ...data.comments];
const dedupedComments = updatedComments.filter((comment, index, self) => self.findIndex(c => c.id === comment.id) === index);
// Note: we store the comments from new to old, and show them in reverse order
return {
comments: [...state.comments, ...data.comments],
comments: dedupedComments,
pagination: data.meta.pagination
};
}
async function setOrder({state, data: {order}, options, api}: {state: EditableAppContext, data: {order: string}, options: CommentsOptions, api: GhostApi}) {
let data;
if (state.admin && state.adminApi && state.labs.commentImprovements) {
data = await state.adminApi.browse({page: 1, postId: options.postId, order});
}
data = await api.comments.browse({page: 1, postId: options.postId, order: order});
function setCommentsIsLoading({data: isLoading}: {data: boolean | null}) {
return {
comments: [...data.comments],
pagination: data.meta.pagination,
order
commentsIsLoading: isLoading
};
}
async function setOrder({state, data: {order}, options, api, dispatchAction}: {state: EditableAppContext, data: {order: string}, options: CommentsOptions, api: GhostApi, dispatchAction: DispatchActionType}) {
dispatchAction('setCommentsIsLoading', true);
try {
let data;
if (state.admin && state.adminApi) {
data = await state.adminApi.browse({page: 1, postId: options.postId, order, memberUuid: state.member?.uuid});
} else {
data = await api.comments.browse({page: 1, postId: options.postId, order});
}
return {
comments: [...data.comments],
pagination: data.meta.pagination,
order,
commentsIsLoading: false
};
} catch (error) {
console.error('Failed to set order:', error); // eslint-disable-line no-console
state.commentsIsLoading = false;
throw error; // Rethrow the error to allow upstream handling
}
}
async function loadMoreReplies({state, api, data: {comment, limit}, isReply}: {state: EditableAppContext, api: GhostApi, data: {comment: any, limit?: number | 'all'}, isReply: boolean}): Promise<Partial<EditableAppContext>> {
let data;
if (state.admin && state.adminApi && state.labs.commentImprovements && !isReply) { // we don't want the admin api to load reply data for replying to a reply, so we pass isReply: true
data = await state.adminApi.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit});
if (state.admin && state.adminApi && !isReply) { // we don't want the admin api to load reply data for replying to a reply, so we pass isReply: true
data = await state.adminApi.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit, memberUuid: state.member?.uuid});
} else {
data = await api.comments.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit});
}
@ -135,13 +153,13 @@ async function hideComment({state, data: comment}: {state: EditableAppContext, a
async function showComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, adminApi: any, data: {id: string}}) {
if (state.adminApi) {
await state.adminApi.showComment(comment.id);
await state.adminApi.showComment({id: comment.id});
}
// We need to refetch the comment, to make sure we have an up to date HTML content
// + all relations are loaded as the current member (not the admin)
let data;
if (state.admin && state.adminApi && state.labs.commentImprovements) {
data = await state.adminApi.read({commentId: comment.id});
if (state.admin && state.adminApi) {
data = await state.adminApi.read({commentId: comment.id, memberUuid: state.member?.uuid});
} else {
data = await api.comments.read(comment.id);
}
@ -256,7 +274,7 @@ async function unlikeComment({state, api, data: comment}: {state: EditableAppCon
};
}
async function deleteComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, data: {id: string}}) {
async function deleteComment({state, api, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
await api.comments.edit({
comment: {
id: comment.id,
@ -264,32 +282,46 @@ async function deleteComment({state, api, data: comment}: {state: EditableAppCon
}
});
// If we're deleting a top-level comment with no replies we refresh the
// whole comments section to maintain correct pagination
const commentToDelete = state.comments.find(c => c.id === comment.id);
if (commentToDelete && (!commentToDelete.replies || commentToDelete.replies.length === 0)) {
dispatchAction('setOrder', {order: state.order});
return null;
}
return {
comments: state.comments.map((c) => {
const replies = c.replies.map((r) => {
if (r.id === comment.id) {
comments: state.comments.map((topLevelComment) => {
// If the comment has replies we want to keep it so the replies are
// still visible, but mark the comment as deleted. Otherwise remove it.
if (topLevelComment.id === comment.id) {
if (topLevelComment.replies.length > 0) {
return {
...r,
...topLevelComment,
status: 'deleted'
};
} else {
return null; // Will be filtered out later
}
return r;
});
if (c.id === comment.id) {
return {
...c,
status: 'deleted',
replies
};
}
return {
...c,
replies
const originalLength = topLevelComment.replies.length;
const updatedReplies = topLevelComment.replies.filter(reply => reply.id !== comment.id);
const hasDeletedReply = originalLength !== updatedReplies.length;
const updatedTopLevelComment = {
...topLevelComment,
replies: updatedReplies
};
}),
// When a reply is deleted we need to update the parent's count so
// pagination displays the correct number of replies still to load
if (hasDeletedReply && topLevelComment.count?.replies) {
topLevelComment.count.replies = topLevelComment.count.replies - 1;
}
return updatedTopLevelComment;
}).filter(Boolean),
commentCount: state.commentCount - 1
};
}
@ -399,6 +431,29 @@ async function openCommentForm({data: newForm, api, state}: {data: OpenCommentFo
};
}
function setHighlightComment({data: commentId}: {data: string | null}) {
return {
commentIdToHighlight: commentId
};
}
function highlightComment({
data: {commentId},
dispatchAction
}: {
data: { commentId: string | null };
state: EditableAppContext;
dispatchAction: DispatchActionType;
}) {
setTimeout(() => {
dispatchAction('setHighlightComment', null);
}, 3000);
return {
commentIdToHighlight: commentId
};
}
function setCommentFormHasUnsavedChanges({data: {id, hasUnsavedChanges}, state}: {data: {id: string, hasUnsavedChanges: boolean}, state: EditableAppContext}) {
const updatedForms = state.openCommentForms.map((f) => {
if (f.id === id) {
@ -440,7 +495,10 @@ export const Actions = {
loadMoreReplies,
updateMember,
setOrder,
openCommentForm
openCommentForm,
highlightComment,
setHighlightComment,
setCommentsIsLoading
};
export type ActionType = keyof typeof Actions;
@ -450,10 +508,10 @@ export function isSyncAction(action: string): action is SyncActionType {
}
/** Handle actions in the App, returns updated state */
export async function ActionHandler({action, data, state, api, adminApi, options}: {action: ActionType, data: any, state: EditableAppContext, options: CommentsOptions, api: GhostApi, adminApi: AdminApi}): Promise<Partial<EditableAppContext>> {
export async function ActionHandler({action, data, state, api, adminApi, options, dispatchAction}: {action: ActionType, data: any, state: EditableAppContext, options: CommentsOptions, api: GhostApi, adminApi: AdminApi, dispatchAction: DispatchActionType}): Promise<Partial<EditableAppContext>> {
const handler = Actions[action];
if (handler) {
return await handler({data, state, api, adminApi, options} as any) || {};
return await handler({data, state, api, adminApi, options, dispatchAction} as any) || {};
}
return {};
}

View file

@ -1,5 +1,5 @@
import {ReactComponent as AvatarIcon} from '../../images/icons/avatar.svg';
import {Comment, useAppContext} from '../../AppContext';
import {Comment, Member, useAppContext} from '../../AppContext';
import {getInitials, getMemberInitialsFromComment} from '../../utils/helpers';
function getDimensionClasses() {
@ -10,8 +10,8 @@ export const BlankAvatar = () => {
const dimensionClasses = getDimensionClasses();
return (
<figure className={`relative ${dimensionClasses}`}>
<div className={`flex items-center justify-center rounded-full bg-black/10 dark:bg-white/15 ${dimensionClasses}`}>
<AvatarIcon className="stroke-white opacity-80" />
<div className={`flex items-center justify-center rounded-full bg-black/5 text-neutral-900/25 dark:bg-white/15 dark:text-white/30 ${dimensionClasses}`}>
<AvatarIcon className="h-7 w-7 opacity-80" />
</div>
</figure>
);
@ -19,13 +19,15 @@ export const BlankAvatar = () => {
type AvatarProps = {
comment?: Comment;
member?: Member;
};
export const Avatar: React.FC<AvatarProps> = ({comment}) => {
// #TODO greyscale the avatar image when it's hidden
const {member, avatarSaturation, t} = useAppContext();
export const Avatar: React.FC<AvatarProps> = ({comment, member: propMember}) => {
const {member: contextMember, avatarSaturation, t} = useAppContext();
const dimensionClasses = getDimensionClasses();
const memberName = member?.name ?? comment?.member?.name;
const activeMember = propMember || comment?.member || contextMember;
const memberName = activeMember?.name;
const getHashOfString = (str: string) => {
let hash = 0;
@ -41,9 +43,7 @@ export const Avatar: React.FC<AvatarProps> = ({comment}) => {
};
const generateHSL = (): [number, number, number] => {
const commentMember = (comment ? comment.member : member);
if (!commentMember || !commentMember.name) {
if (!activeMember || !activeMember.name) {
return [0,0,10];
}
@ -54,7 +54,7 @@ export const Avatar: React.FC<AvatarProps> = ({comment}) => {
const lRangeBottom = lRangeTop - 20;
const lRange = [lRangeBottom, lRangeTop];
const hash = getHashOfString(commentMember.name);
const hash = getHashOfString(activeMember.name);
const h = normalizeHash(hash, hRange[0], hRange[1]);
const l = normalizeHash(hash, lRange[0], lRange[1]);
@ -66,8 +66,7 @@ export const Avatar: React.FC<AvatarProps> = ({comment}) => {
};
const memberInitials = (comment && getMemberInitialsFromComment(comment, t)) ||
(member && getInitials(member.name || '')) || '';
const commentMember = (comment ? comment.member : member);
(activeMember && getInitials(activeMember.name || '')) || '';
const bgColor = HSLtoString(generateHSL());
const avatarStyle = {
@ -83,7 +82,7 @@ export const Avatar: React.FC<AvatarProps> = ({comment}) => {
(<div className={`flex items-center justify-center rounded-full bg-neutral-900 dark:bg-white/70 ${dimensionClasses}`} data-testid="avatar-background">
<AvatarIcon className="stroke-white dark:stroke-black/60" />
</div>)}
{commentMember && <img alt="Avatar" className={`absolute left-0 top-0 rounded-full ${dimensionClasses}`} data-testid="avatar-image" src={commentMember.avatar_image} />}
{activeMember?.avatar_image && <img alt="Avatar" className={`absolute left-0 top-0 rounded-full ${dimensionClasses}`} data-testid="avatar-image" src={activeMember.avatar_image} />}
</>
);

View file

@ -1,5 +1,6 @@
import {AppContext} from '../../AppContext';
import {CommentComponent} from './Comment';
import {CommentComponent, RepliedToSnippet} from './Comment';
import {buildComment} from '../../../test/utils/fixtures';
import {render, screen} from '@testing-library/react';
const contextualRender = (ui, {appContext, ...renderOptions}) => {
@ -20,23 +21,103 @@ const contextualRender = (ui, {appContext, ...renderOptions}) => {
describe('<CommentComponent>', function () {
it('renders reply-to-reply content', function () {
const appContext = {labs: {commentImprovements: true}};
const parent = {
id: '1',
status: 'published',
count: {likes: 0}
};
const comment = {
id: '3',
status: 'published',
in_reply_to_id: '2',
const reply1 = buildComment({
html: '<p>First reply</p>'
});
const reply2 = buildComment({
in_reply_to_id: reply1.id,
in_reply_to_snippet: 'First reply',
html: '<p>Second reply</p>',
count: {likes: 0}
};
html: '<p>Second reply</p>'
});
const parent = buildComment({
replies: [reply1, reply2]
});
const appContext = {comments: [parent]};
contextualRender(<CommentComponent comment={comment} parent={parent} />, {appContext});
contextualRender(<CommentComponent comment={reply2} parent={parent} />, {appContext});
expect(screen.queryByText('First reply')).toBeInTheDocument();
expect(screen.getByText('First reply')).toBeInTheDocument();
});
it('outputs member uuid data attribute for published comments', function () {
const comment = buildComment({
status: 'published',
member: {uuid: '123'}
});
const appContext = {comments: [comment]};
const {container} = contextualRender(<CommentComponent comment={comment} />, {appContext});
expect(container.querySelector('[data-member-uuid="123"]')).toBeInTheDocument();
});
it('does not output member uuid data attribute for unpublished comments', function () {
const comment = buildComment({
status: 'hidden',
member: {uuid: '123'}
});
const appContext = {comments: [comment]};
const {container} = contextualRender(<CommentComponent comment={comment} />, {appContext});
expect(container.querySelector('[data-member-uuid="123"]')).not.toBeInTheDocument();
});
});
describe('<RepliedToSnippet>', function () {
it('renders a link when replied-to comment is published', function () {
const reply1 = buildComment({
html: '<p>First reply</p>'
});
const reply2 = buildComment({
in_reply_to_id: reply1.id,
in_reply_to_snippet: 'First reply',
html: '<p>Second reply</p>'
});
const parent = buildComment({
replies: [reply1, reply2]
});
const appContext = {comments: [parent]};
contextualRender(<RepliedToSnippet comment={reply2} />, {appContext});
const element = screen.getByTestId('comment-in-reply-to');
expect(element).toBeInstanceOf(HTMLAnchorElement);
});
it('does not render a link when replied-to comment is deleted', function () {
const reply1 = buildComment({
html: '<p>First reply</p>',
status: 'deleted'
});
const reply2 = buildComment({
in_reply_to_id: reply1.id,
in_reply_to_snippet: 'First reply',
html: '<p>Second reply</p>'
});
const parent = buildComment({
replies: [reply1, reply2]
});
const appContext = {comments: [parent]};
contextualRender(<RepliedToSnippet comment={reply2} />, {appContext});
const element = screen.getByTestId('comment-in-reply-to');
expect(element).toBeInstanceOf(HTMLSpanElement);
});
it('does not render a link when replied-to comment is missing (i.e. removed)', function () {
const reply2 = buildComment({
in_reply_to_id: 'missing',
in_reply_to_snippet: 'First reply',
html: '<p>Second reply</p>'
});
const parent = buildComment({
replies: [reply2]
});
const appContext = {comments: [parent]};
contextualRender(<RepliedToSnippet comment={reply2} />, {appContext});
const element = screen.getByTestId('comment-in-reply-to');
expect(element).toBeInstanceOf(HTMLSpanElement);
});
});

View file

@ -5,9 +5,9 @@ import Replies, {RepliesProps} from './Replies';
import ReplyButton from './buttons/ReplyButton';
import ReplyForm from './forms/ReplyForm';
import {Avatar, BlankAvatar} from './Avatar';
import {Comment, OpenCommentForm, useAppContext, useLabs} from '../../AppContext';
import {Comment, OpenCommentForm, useAppContext} from '../../AppContext';
import {Transition} from '@headlessui/react';
import {formatExplicitTime, getCommentInReplyToSnippet, getMemberNameFromComment} from '../../utils/helpers';
import {findCommentById, formatExplicitTime, getCommentInReplyToSnippet, getMemberNameFromComment} from '../../utils/helpers';
import {useCallback} from 'react';
import {useRelativeTime} from '../../utils/hooks';
@ -17,8 +17,10 @@ type AnimatedCommentProps = {
};
const AnimatedComment: React.FC<AnimatedCommentProps> = ({comment, parent}) => {
const {commentsIsLoading} = useAppContext();
return (
<Transition
className={`${commentsIsLoading ? 'animate-pulse' : ''}`}
data-testid="animated-comment"
enter="transition-opacity duration-300 ease-out"
enterFrom="opacity-0"
@ -30,54 +32,14 @@ const AnimatedComment: React.FC<AnimatedCommentProps> = ({comment, parent}) => {
show={true}
appear
>
<EditableComment comment={comment} parent={parent} />
<CommentComponent comment={comment} parent={parent} />
</Transition>
);
};
type EditableCommentProps = AnimatedCommentProps;
const EditableComment: React.FC<EditableCommentProps> = ({comment, parent}) => {
const {openCommentForms} = useAppContext();
const form = openCommentForms.find(openForm => openForm.id === comment.id && openForm.type === 'edit');
const isInEditMode = !!form;
if (isInEditMode) {
return (<EditForm comment={comment} openForm={form} parent={parent} />);
} else {
return (<CommentComponent comment={comment} parent={parent} />);
}
};
type CommentProps = AnimatedCommentProps;
const useCommentVisibility = (comment: Comment, admin: boolean, labs: {commentImprovements?: boolean}) => {
const hasReplies = comment.replies && comment.replies.length > 0;
const isDeleted = comment.status === 'deleted';
const isHidden = comment.status === 'hidden';
if (labs?.commentImprovements) {
return {
// Show deleted message only when comment has replies (regardless of admin status)
showDeletedMessage: isDeleted && hasReplies,
// Show hidden message for non-admins when comment has replies
showHiddenMessage: hasReplies && isHidden && !admin,
// Show comment content if not deleted AND (is published OR admin viewing hidden)
showCommentContent: !isDeleted && (admin || comment.status === 'published')
};
}
// Original behavior when labs is false
return {
showDeletedMessage: false,
showHiddenMessage: false,
showCommentContent: comment.status === 'published'
};
};
export const CommentComponent: React.FC<CommentProps> = ({comment, parent}) => {
const {dispatchAction, admin} = useAppContext();
const labs = useLabs();
const {showDeletedMessage, showHiddenMessage, showCommentContent} = useCommentVisibility(comment, admin, labs);
const {showDeletedMessage, showHiddenMessage, showCommentContent} = useCommentVisibility(comment, admin);
const openEditMode = useCallback(() => {
const newForm: OpenCommentForm = {
@ -90,28 +52,45 @@ export const CommentComponent: React.FC<CommentProps> = ({comment, parent}) => {
dispatchAction('openCommentForm', newForm);
}, [comment.id, dispatchAction]);
if (showDeletedMessage) {
if (showDeletedMessage || showHiddenMessage) {
return <UnpublishedComment comment={comment} openEditMode={openEditMode} />;
} else if (showCommentContent && !showHiddenMessage) {
return <PublishedComment comment={comment} openEditMode={openEditMode} parent={parent} />;
} else if (!labs.commentImprovements && comment.status !== 'published' || showHiddenMessage) {
return <UnpublishedComment comment={comment} openEditMode={openEditMode} />;
}
return null;
};
type CommentProps = AnimatedCommentProps;
const useCommentVisibility = (comment: Comment, admin: boolean) => {
const hasReplies = comment.replies && comment.replies.length > 0;
const isDeleted = comment.status === 'deleted';
const isHidden = comment.status === 'hidden';
return {
// Show deleted message only when comment has replies (regardless of admin status)
showDeletedMessage: isDeleted && hasReplies,
// Show hidden message for non-admins when comment has replies
showHiddenMessage: hasReplies && isHidden && !admin,
// Show comment content if not deleted AND (is published OR admin viewing hidden)
showCommentContent: !isDeleted && (admin || comment.status === 'published')
};
};
type PublishedCommentProps = CommentProps & {
openEditMode: () => void;
}
const PublishedComment: React.FC<PublishedCommentProps> = ({comment, parent, openEditMode}) => {
const {dispatchAction, openCommentForms, admin} = useAppContext();
const labs = useLabs();
const {dispatchAction, openCommentForms, admin, commentIdToHighlight} = useAppContext();
// Determine if the comment should be displayed with reduced opacity
const isHidden = labs.commentImprovements && admin && comment.status === 'hidden';
const isHidden = admin && comment.status === 'hidden';
const hiddenClass = isHidden ? 'opacity-30' : '';
// Check if this comment is being edited
const editForm = openCommentForms.find(openForm => openForm.id === comment.id && openForm.type === 'edit');
const isInEditMode = !!editForm;
// currently a reply-to-reply form is displayed inside the top-level PublishedComment component
// so we need to check for a match of either the comment id or the parent id
const openForm = openCommentForms.find(f => (f.id === comment.id || f.parent_id === comment.id) && f.type === 'reply');
@ -147,16 +126,27 @@ const PublishedComment: React.FC<PublishedCommentProps> = ({comment, parent, ope
const avatar = (<Avatar comment={comment} />);
return (
<CommentLayout avatar={avatar} className={hiddenClass} hasReplies={hasReplies}>
<CommentHeader className={hiddenClass} comment={comment} />
<CommentBody className={hiddenClass} html={comment.html} />
<CommentMenu
comment={comment}
highlightReplyButton={highlightReplyButton}
openEditMode={openEditMode}
openReplyForm={openReplyForm}
parent={parent}
/>
<CommentLayout avatar={avatar} className={hiddenClass} hasReplies={hasReplies} memberUuid={comment.member?.uuid}>
<div>
{isInEditMode ? (
<>
<CommentHeader className={hiddenClass} comment={comment} />
<EditForm comment={comment} openForm={editForm} parent={parent} />
</>
) : (
<>
<CommentHeader className={hiddenClass} comment={comment} />
<CommentBody className={hiddenClass} html={comment.html} isHighlighted={comment.id === commentIdToHighlight} />
<CommentMenu
comment={comment}
highlightReplyButton={highlightReplyButton}
openEditMode={openEditMode}
openReplyForm={openReplyForm}
parent={parent}
/>
</>
)}
</div>
<RepliesContainer comment={comment} />
{displayReplyForm && <ReplyFormBox comment={comment} openForm={openForm} />}
</CommentLayout>
@ -168,11 +158,11 @@ type UnpublishedCommentProps = {
openEditMode: () => void;
}
const UnpublishedComment: React.FC<UnpublishedCommentProps> = ({comment, openEditMode}) => {
const {openCommentForms, t, labs, admin} = useAppContext();
const {admin, openCommentForms, t} = useAppContext();
const avatar = (labs.commentImprovements && admin && comment.status !== 'deleted') ?
<Avatar comment={comment} /> :
<BlankAvatar />;
const avatar = (admin && comment.status !== 'deleted')
? <Avatar comment={comment} />
: <BlankAvatar />;
const hasReplies = comment.replies && comment.replies.length > 0;
const notPublishedMessage = comment.status === 'hidden' ?
@ -276,18 +266,9 @@ const AuthorName: React.FC<{comment: Comment}> = ({comment}) => {
);
};
type CommentHeaderProps = {
comment: Comment;
className?: string;
}
const CommentHeader: React.FC<CommentHeaderProps> = ({comment, className = ''}) => {
const {t} = useAppContext();
const labs = useLabs();
const createdAtRelative = useRelativeTime(comment.created_at);
const {member} = useAppContext();
const memberExpertise = member && comment.member && comment.member.uuid === member.uuid ? member.expertise : comment?.member?.expertise;
const isReplyToReply = labs.commentImprovements && comment.in_reply_to_id && comment.in_reply_to_snippet;
export const RepliedToSnippet: React.FC<{comment: Comment}> = ({comment}) => {
const {comments, dispatchAction, t} = useAppContext();
const inReplyToComment = findCommentById(comments, comment.in_reply_to_id);
const scrollRepliedToCommentIntoView = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
@ -298,10 +279,40 @@ const CommentHeader: React.FC<CommentHeaderProps> = ({comment, className = ''})
const element = (e.target as HTMLElement).ownerDocument.getElementById(comment.in_reply_to_id);
if (element) {
dispatchAction('highlightComment', {commentId: comment.in_reply_to_id});
element.scrollIntoView({behavior: 'smooth', block: 'center'});
}
};
let inReplyToSnippet = comment.in_reply_to_snippet;
// For public API requests hidden/deleted comments won't exist in the comments array
// unless it was only just deleted in which case it will exist but have a 'deleted' status
if (!inReplyToComment || inReplyToComment.status !== 'published') {
inReplyToSnippet = `[${t('removed')}]`;
}
const linkToReply = inReplyToComment && inReplyToComment.status === 'published';
const className = 'font-medium text-neutral-900/60 transition-colors dark:text-white/70';
return (
linkToReply
? <a className={`${className} hover:text-neutral-900/75 dark:hover:text-white/85`} data-testid="comment-in-reply-to" href={`#${comment.in_reply_to_id}`} onClick={scrollRepliedToCommentIntoView}>{inReplyToSnippet}</a>
: <span className={className} data-testid="comment-in-reply-to">{inReplyToSnippet}</span>
);
};
type CommentHeaderProps = {
comment: Comment;
className?: string;
}
const CommentHeader: React.FC<CommentHeaderProps> = ({comment, className = ''}) => {
const {member, t} = useAppContext();
const createdAtRelative = useRelativeTime(comment.created_at);
const memberExpertise = member && comment.member && comment.member.uuid === member.uuid ? member.expertise : comment?.member?.expertise;
const isReplyToReply = comment.in_reply_to_id && comment.in_reply_to_snippet;
return (
<>
<div className={`mt-0.5 flex flex-wrap items-start sm:flex-row ${memberExpertise ? 'flex-col' : 'flex-row'} ${isReplyToReply ? 'mb-0.5' : 'mb-2'} ${className}`}>
@ -316,7 +327,7 @@ const CommentHeader: React.FC<CommentHeaderProps> = ({comment, className = ''})
</div>
{(isReplyToReply &&
<div className="mb-2 line-clamp-1 font-sans text-base leading-snug text-neutral-900/50 sm:text-sm dark:text-white/60">
<span>{t('Replied to')}</span>:&nbsp;<a className="font-semibold text-neutral-900/60 transition-colors hover:text-neutral-900/70 dark:text-white/70 dark:hover:text-white/80" data-testid="comment-in-reply-to" href={`#${comment.in_reply_to_id}`} onClick={scrollRepliedToCommentIntoView}>{comment.in_reply_to_snippet}</a>
<span>{t('Replied to')}</span>:&nbsp;<RepliedToSnippet comment={comment} />
</div>
)}
</>
@ -326,13 +337,38 @@ const CommentHeader: React.FC<CommentHeaderProps> = ({comment, className = ''})
type CommentBodyProps = {
html: string;
className?: string;
isHighlighted?: boolean;
}
const CommentBody: React.FC<CommentBodyProps> = ({html, className = ''}) => {
const dangerouslySetInnerHTML = {__html: html};
const CommentBody: React.FC<CommentBodyProps> = ({html, className = '', isHighlighted}) => {
let commentHtml = html;
if (isHighlighted) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const paragraphs = doc.querySelectorAll('p');
paragraphs.forEach((p) => {
const mark = doc.createElement('mark');
mark.className =
'animate-[highlight_2.5s_ease-out] [animation-delay:1s] bg-yellow-300/40 -my-0.5 py-0.5 dark:text-white/85 dark:bg-yellow-500/40';
while (p.firstChild) {
mark.appendChild(p.firstChild);
}
p.appendChild(mark);
});
// Serialize the modified html back to a string
commentHtml = doc.body.innerHTML;
}
const dangerouslySetInnerHTML = {__html: commentHtml};
return (
<div className={`mt mb-2 flex flex-row items-center gap-4 pr-4 ${className}`}>
<p dangerouslySetInnerHTML={dangerouslySetInnerHTML} className="gh-comment-content text-md text-pretty font-sans leading-normal text-neutral-900 [overflow-wrap:anywhere] sm:text-lg dark:text-white/85" data-testid="comment-content"/>
<p dangerouslySetInnerHTML={dangerouslySetInnerHTML} className="gh-comment-content text-md -mx-1 text-pretty rounded-md px-1 font-sans leading-normal text-neutral-900 [overflow-wrap:anywhere] sm:text-lg dark:text-white/85" data-testid="comment-content"/>
</div>
);
};
@ -345,39 +381,25 @@ type CommentMenuProps = {
parent?: Comment;
className?: string;
};
const CommentMenu: React.FC<CommentMenuProps> = ({comment, openReplyForm, highlightReplyButton, openEditMode, parent, className = ''}) => {
const {member, commentsEnabled, t, admin} = useAppContext();
const labs = useLabs();
const CommentMenu: React.FC<CommentMenuProps> = ({comment, openReplyForm, highlightReplyButton, openEditMode, className = ''}) => {
const {admin, t} = useAppContext();
const paidOnly = commentsEnabled === 'paid';
const isPaidMember = member && !!member.paid;
const canReply = member && (isPaidMember || !paidOnly) && (labs.commentImprovements ? true : !parent);
const isHiddenForAdmin = labs.commentImprovements && admin && comment.status === 'hidden';
if (isHiddenForAdmin) {
if (admin && comment.status === 'hidden') {
return (
<div className={`flex items-center gap-4 ${className}`}>
<span className="font-sans text-base leading-snug text-red-600 sm:text-sm">{t('Hidden for members')}</span>
{<MoreButton comment={comment} toggleEdit={openEditMode} />}
</div>
);
}
return (
labs.commentImprovements ? (
} else {
return (
<div className={`flex items-center gap-4 ${className}`}>
{<LikeButton comment={comment} />}
{<ReplyButton isReplying={highlightReplyButton} openReplyForm={openReplyForm} />}
{<MoreButton comment={comment} toggleEdit={openEditMode} />}
</div>
) : (
<div className={`flex items-center gap-4 ${className}`}>
{<LikeButton comment={comment} />}
{(canReply && <ReplyButton isReplying={highlightReplyButton} openReplyForm={openReplyForm} />)}
{<MoreButton comment={comment} toggleEdit={openEditMode} />}
</div>
)
);
);
}
};
//
@ -389,7 +411,7 @@ const RepliesLine: React.FC<{hasReplies: boolean}> = ({hasReplies}) => {
return null;
}
return (<div className="mb-2 h-full w-px grow rounded bg-gradient-to-b from-neutral-900/10 via-neutral-900/10 to-transparent dark:from-white/10 dark:via-white/10" />);
return (<div className="mb-2 h-full w-px grow rounded bg-gradient-to-b from-neutral-900/15 from-70% to-transparent dark:from-white/20 dark:from-70%" data-testid="replies-line" />);
};
type CommentLayoutProps = {
@ -397,10 +419,11 @@ type CommentLayoutProps = {
avatar: React.ReactNode;
hasReplies: boolean;
className?: string;
memberUuid?: string;
}
const CommentLayout: React.FC<CommentLayoutProps> = ({children, avatar, hasReplies, className = ''}) => {
const CommentLayout: React.FC<CommentLayoutProps> = ({children, avatar, hasReplies, className = '', memberUuid = ''}) => {
return (
<div className={`flex w-full flex-row ${hasReplies === true ? 'mb-0' : 'mb-7'}`} data-testid="comment-component">
<div className={`flex w-full flex-row ${hasReplies === true ? 'mb-0' : 'mb-7'}`} data-member-uuid={memberUuid} data-testid="comment-component">
<div className="mr-2 flex flex-col items-center justify-start sm:mr-3">
<div className={`flex-0 mb-3 sm:mb-4 ${className}`}>
{avatar}

View file

@ -44,10 +44,10 @@ describe('<Content>', function () {
expect(screen.queryByTestId('main-form')).toBeInTheDocument();
});
it('renders no CTA or form when a reply form is open', function () {
it('renders main form when a reply form is open', function () {
contextualRender(<Content />, {appContext: {member: {}, openFormCount: 1}});
expect(screen.queryByTestId('cta-box')).not.toBeInTheDocument();
expect(screen.queryByTestId('main-form')).not.toBeInTheDocument();
expect(screen.queryByTestId('main-form')).toBeInTheDocument();
});
});
});

View file

@ -10,16 +10,7 @@ import {useEffect} from 'react';
const Content = () => {
const labs = useLabs();
const {pagination, member, comments, commentCount, commentsEnabled, title, showCount, openFormCount, t} = useAppContext();
let commentsElements;
const commentsDataset = comments;
if (labs && labs.commentImprovements) {
commentsElements = commentsDataset.slice().map(comment => <Comment key={comment.id} comment={comment} />);
} else {
commentsElements = commentsDataset.slice().reverse().map(comment => <Comment key={comment.id} comment={comment} />);
}
const {pagination, member, comments, commentCount, commentsEnabled, title, showCount, commentsIsLoading, t} = useAppContext();
useEffect(() => {
const elem = document.getElementById(ROOT_DIV_ID);
@ -42,60 +33,36 @@ const Content = () => {
const isPaidOnly = commentsEnabled === 'paid';
const isPaidMember = member && !!member.paid;
const isFirst = pagination?.total === 0;
const hasOpenReplyForms = openFormCount > 0;
const commentsComponents = comments.slice().map(comment => <Comment key={comment.id} comment={comment} />);
return (
labs.commentImprovements ? (
<>
<ContentTitle count={commentCount} showCount={showCount} title={title}/>
<div>
{(member && (isPaidMember || !isPaidOnly)) ? (
<MainForm commentsCount={comments.length} />
) : (
<section className="flex flex-col items-center py-6 sm:px-8 sm:py-10" data-testid="cta-box">
<CTABox isFirst={isFirst} isPaid={isPaidOnly} />
</section>
)}
</div>
{commentCount > 1 && (
<div className="z-20 mb-7 mt-3">
<span className="flex items-center gap-1.5 text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('Sort by')}: <SortingForm/>
</span>
</div>
<>
<ContentTitle count={commentCount} showCount={showCount} title={title}/>
<div>
{(member && (isPaidMember || !isPaidOnly)) ? (
<MainForm commentsCount={comments.length} />
) : (
<section className="flex flex-col items-center py-6 sm:px-8 sm:py-10" data-testid="cta-box">
<CTABox isFirst={isFirst} isPaid={isPaidOnly} />
</section>
)}
<div className="z-10" data-test="comment-elements">
{commentsElements}
</div>
{commentCount > 1 && (
<div className="z-20 mb-7 mt-3">
<span className="flex items-center gap-1.5 text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('Sort by')}: <SortingForm/>
</span>
</div>
<Pagination />
{
labs?.testFlag ? <div data-testid="this-comes-from-a-flag" style={{display: 'none'}}></div> : null
}
</>
) : (
<>
<ContentTitle count={commentCount} showCount={showCount} title={title}/>
<Pagination />
<div className={!pagination ? 'mt-4' : ''} data-test="comment-elements">
{commentsElements}
</div>
<div>
{!hasOpenReplyForms
? (member ? (isPaidMember || !isPaidOnly ? <MainForm commentsCount={commentCount} /> :
<section className={`flex flex-col items-center pt-[40px] ${member ? 'pb-[32px]' : 'pb-[48px]'} ${!isFirst && 'mt-4'} ${(!member || (member && isPaidOnly)) && commentCount ? 'border-t' : 'border-none'} border-[rgba(0,0,0,0.075)] sm:px-8 dark:border-[rgba(255,255,255,0.1)]`} data-testid="cta-box">
<CTABox isFirst={isFirst} isPaid={isPaidOnly} />
</section>) :
<section className={`flex flex-col items-center pt-[40px] ${member ? 'pb-[32px]' : 'pb-[48px]'} ${!isFirst && 'mt-4'} ${(!member || (member && isPaidOnly)) && commentCount ? 'border-t' : 'border-none'} border-[rgba(0,0,0,0.075)] sm:px-8 dark:border-[rgba(255,255,255,0.1)]`} data-testid="cta-box">
<CTABox isFirst={isFirst} isPaid={isPaidOnly} />
</section>)
: null
}
</div>
{
labs?.testFlag ? <div data-testid="this-comes-from-a-flag" style={{display: 'none'}}></div> : null
}
</>
)
)}
<div className={`z-10 transition-opacity duration-100 ${commentsIsLoading ? 'opacity-50' : ''}`} data-testid="comment-elements">
{commentsComponents}
</div>
<Pagination />
{
labs?.testFlag ? <div data-testid="this-comes-from-a-flag" style={{display: 'none'}}></div> : null
}
</>
);
};

View file

@ -0,0 +1,32 @@
import Pagination from './Pagination';
import {AppContext} from '../../AppContext';
import {render, screen} from '@testing-library/react';
const contextualRender = (ui, {appContext, ...renderOptions}) => {
const contextWithDefaults = {
t: (str, replacements) => {
if (replacements) {
return str.replace(/{{([^{}]*)}}/g, (_, key) => replacements[key]);
}
return str;
},
...appContext
};
return render(
<AppContext.Provider value={contextWithDefaults}>{ui}</AppContext.Provider>,
renderOptions
);
};
describe('<Pagination>', function () {
it('has correct text for 1 more', function () {
contextualRender(<Pagination />, {appContext: {pagination: {total: 4, page: 1, limit: 3}}});
expect(screen.getByText('Load more (1)')).toBeInTheDocument();
});
it('has correct text for x more', function () {
contextualRender(<Pagination />, {appContext: {pagination: {total: 6, page: 1, limit: 3}}});
expect(screen.getByText('Load more (3)')).toBeInTheDocument();
});
});

View file

@ -1,9 +1,8 @@
import {formatNumber} from '../../utils/helpers';
import {useAppContext, useLabs} from '../../AppContext';
import {useAppContext} from '../../AppContext';
const Pagination = () => {
const {pagination, dispatchAction, t} = useAppContext();
const labs = useLabs();
const loadMore = () => {
dispatchAction('loadMoreComments', {});
@ -13,27 +12,18 @@ const Pagination = () => {
return null;
}
const left = pagination.total - pagination.page * pagination.limit;
const commentsLeft = pagination.total - pagination.page * pagination.limit;
if (left <= 0) {
if (commentsLeft <= 0) {
return null;
}
// TODO: add i18n support for these strings when removing labs flag
const text = labs.commentImprovements
? (left === 1 ? 'Load more (1)' : `Load more (${formatNumber(left)})`)
: (left === 1 ? t('Show 1 previous comment') : t('Show {{amount}} previous comments', {amount: formatNumber(left)}));
const text = t(`Load more ({{amount}})`, {amount: formatNumber(commentsLeft)});
return (
labs.commentImprovements ? (
<button className="text-md group mb-10 flex items-center px-0 pb-2 pt-0 text-left font-sans font-semibold text-neutral-700 dark:text-white" data-testid="pagination-component" type="button" onClick={loadMore}>
<span className="flex h-[40px] items-center justify-center whitespace-nowrap rounded-[6px] bg-black/5 px-4 py-2 text-center font-sans text-sm font-semibold text-neutral-700 outline-0 transition-all duration-150 hover:bg-black/10 dark:bg-white/15 dark:text-neutral-300 dark:hover:bg-white/20 dark:hover:text-neutral-100">{text}</span>
</button>
) : (
<button className="text-md group mb-10 flex w-full items-center px-0 pb-2 pt-0 text-left font-sans font-semibold text-neutral-700 dark:text-white" data-testid="pagination-component" type="button" onClick={loadMore}>
<span className="flex h-[40px] w-full items-center justify-center whitespace-nowrap rounded-[6px] bg-black/5 px-3 py-2 text-center font-sans text-sm font-semibold text-neutral-700 outline-0 transition-all duration-150 hover:bg-black/10 dark:bg-white/15 dark:text-neutral-300 dark:hover:bg-white/20 dark:hover:text-neutral-100"> {text}</span>
</button>
)
<button className="text-md group mb-10 flex items-center px-0 pb-2 pt-0 text-left font-sans font-semibold text-neutral-700 dark:text-white" data-testid="pagination-component" type="button" onClick={loadMore}>
<span className="flex h-[40px] items-center justify-center whitespace-nowrap rounded-[6px] bg-black/5 px-4 py-2 text-center font-sans text-sm font-semibold text-neutral-700 outline-0 transition-all duration-150 hover:bg-black/10 dark:bg-white/15 dark:text-neutral-300 dark:hover:bg-white/20 dark:hover:text-neutral-100">{text}</span>
</button>
);
};

View file

@ -12,7 +12,7 @@ const RepliesPagination: React.FC<Props> = ({loadMore, count}) => {
const shortText = t('{{amount}} more', {amount: formatNumber(count)});
return (
<div className="flex w-full items-center justify-start">
<div className="flex w-full items-center justify-start" data-testid="replies-pagination">
<button className="text-md group mb-10 ml-[48px] flex w-auto items-center px-0 pb-2 pt-0 text-left font-sans font-semibold text-neutral-700 sm:mb-12 dark:text-white " data-testid="reply-pagination-button" type="button" onClick={loadMore}>
<span className="flex h-[40px] w-auto items-center justify-center whitespace-nowrap rounded-[6px] bg-black/5 px-4 py-2 text-center font-sans text-sm font-semibold text-neutral-700 outline-0 transition-all duration-150 hover:bg-black/10 dark:bg-white/15 dark:text-neutral-300 dark:hover:bg-white/20 dark:hover:text-neutral-100"> <span className="ml-1 hidden sm:inline">{longText}</span><span className="ml-1 inline sm:hidden">{shortText}</span> </span>
</button>

View file

@ -1,4 +1,4 @@
import {Comment, useAppContext, useLabs} from '../../../AppContext';
import {Comment, useAppContext} from '../../../AppContext';
import {ReactComponent as LikeIcon} from '../../../images/icons/like.svg';
import {useState} from 'react';
@ -7,7 +7,6 @@ type Props = {
};
const LikeButton: React.FC<Props> = ({comment}) => {
const {dispatchAction, member, commentsEnabled} = useAppContext();
const labs = useLabs();
const [animationClass, setAnimation] = useState('');
const paidOnly = commentsEnabled === 'paid';
@ -15,13 +14,11 @@ const LikeButton: React.FC<Props> = ({comment}) => {
const canLike = member && (isPaidMember || !paidOnly);
const toggleLike = () => {
if (!canLike && labs && labs.commentImprovements) {
if (!canLike) {
dispatchAction('openPopup', {
type: 'ctaPopup'
});
return;
} else if (!canLike) {
return;
}
if (!comment.liked) {
@ -35,39 +32,22 @@ const LikeButton: React.FC<Props> = ({comment}) => {
}
};
// If can like: use <button> element, otherwise use a <span>
const CustomTag = canLike ? `button` : `span`;
let likeCursor = 'cursor-pointer';
if (!canLike) {
likeCursor = 'cursor-text';
}
if (labs && labs.commentImprovements) {
return (
<button
className={`duration-50 group flex cursor-pointer items-center font-sans text-base outline-0 transition-all ease-linear sm:text-sm ${
comment.liked ? 'text-black/90 dark:text-white/90' : 'text-black/50 hover:text-black/75 dark:text-white/60 dark:hover:text-white/75'
}`}
data-testid="like-button"
type="button"
onClick={toggleLike}
>
<LikeIcon
className={animationClass + ` mr-[6px] ${
comment.liked ? 'fill-black dark:fill-white stroke-black dark:stroke-white' : 'stroke-black/50 group-hover:stroke-black/75 dark:stroke-white/60 dark:group-hover:stroke-white/75'
} ${!comment.liked && canLike && 'group-hover:stroke-black/75 dark:group-hover:stroke-white/75'} transition duration-50 ease-linear`}
/>
{comment.count.likes}
</button>
);
}
return (
<CustomTag className={`duration-50 group flex items-center font-sans text-base outline-0 transition-all ease-linear sm:text-sm ${comment.liked ? 'text-black/90 dark:text-white/90' : 'text-black/50 hover:text-black/75 dark:text-white/60 dark:hover:text-white/75'} ${likeCursor}`} data-testid="like-button" type="button" onClick={toggleLike}>
<LikeIcon className={animationClass + ` mr-[6px] ${comment.liked ? 'fill-black dark:fill-white stroke-black dark:stroke-white' : 'stroke-black/50 group-hover:stroke-black/75 dark:stroke-white/60 dark:group-hover:stroke-white/75'} ${!comment.liked && canLike && 'group-hover:stroke-black/75 dark:group-hover:stroke-white/75'} transition duration-50 ease-linear`} />
<button
className={`duration-50 group flex cursor-pointer items-center font-sans text-base outline-0 transition-all ease-linear sm:text-sm ${
comment.liked ? 'text-black/90 dark:text-white/90' : 'text-black/50 hover:text-black/75 dark:text-white/60 dark:hover:text-white/75'
}`}
data-testid="like-button"
type="button"
onClick={toggleLike}
>
<LikeIcon
className={animationClass + ` mr-[6px] ${
comment.liked ? 'fill-black dark:fill-white stroke-black dark:stroke-white' : 'stroke-black/50 group-hover:stroke-black/75 dark:stroke-white/60 dark:group-hover:stroke-white/75'
} ${!comment.liked && canLike && 'group-hover:stroke-black/75 dark:group-hover:stroke-white/75'} transition duration-50 ease-linear`}
/>
{comment.count.likes}
</CustomTag>
</button>
);
};

View file

@ -10,7 +10,7 @@ type Props = {
const MoreButton: React.FC<Props> = ({comment, toggleEdit}) => {
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const {member, admin, pagination, comments} = useAppContext();
const {member, admin} = useAppContext();
const isAdmin = !!admin;
const toggleContextMenu = () => {
@ -21,10 +21,6 @@ const MoreButton: React.FC<Props> = ({comment, toggleEdit}) => {
setIsContextMenuOpen(false);
};
// Check if this is the last comment and there's no more pagination
const isLastComment = (!pagination || pagination.total <= pagination.page * pagination.limit) &&
comments[comments.length - 1]?.id === comment.id;
const show = (!!member && comment.status === 'published') || isAdmin;
if (!show) {
@ -36,7 +32,7 @@ const MoreButton: React.FC<Props> = ({comment, toggleEdit}) => {
<button className="outline-0" type="button" onClick={toggleContextMenu}>
<MoreIcon className={`duration-50 gh-comments-icon gh-comments-icon-more outline-0 transition ease-linear hover:fill-black/75 dark:hover:fill-white/75 ${isContextMenuOpen ? 'fill-black/75 dark:fill-white/75' : 'fill-black/50 dark:fill-white/60'}`} />
</button>
{isContextMenuOpen ? <CommentContextMenu close={closeContextMenu} comment={comment} isLastComment={isLastComment} toggleEdit={toggleEdit} /> : null}
{isContextMenuOpen ? <CommentContextMenu close={closeContextMenu} comment={comment} toggleEdit={toggleEdit} /> : null}
</div>
);
};

View file

@ -1,5 +1,5 @@
import {ReactComponent as ReplyIcon} from '../../../images/icons/reply.svg';
import {useAppContext, useLabs} from '../../../AppContext';
import {useAppContext} from '../../../AppContext';
type Props = {
disabled?: boolean;
@ -9,14 +9,13 @@ type Props = {
const ReplyButton: React.FC<Props> = ({disabled, isReplying, openReplyForm}) => {
const {member, t, dispatchAction, commentsEnabled} = useAppContext();
const labs = useLabs();
const paidOnly = commentsEnabled === 'paid';
const isPaidMember = member && !!member.paid;
const canReply = member && (isPaidMember || !paidOnly);
const handleClick = () => {
if (!canReply && labs && labs.commentImprovements) {
if (!canReply) {
dispatchAction('openPopup', {
type: 'ctaPopup'
});
@ -25,10 +24,6 @@ const ReplyButton: React.FC<Props> = ({disabled, isReplying, openReplyForm}) =>
openReplyForm();
};
if (!member && !labs?.commentImprovements) {
return null;
}
return (
<button
className={`duration-50 group flex items-center font-sans text-base outline-0 transition-all ease-linear sm:text-sm ${isReplying ? 'text-black/90 dark:text-white/90' : 'text-black/50 hover:text-black/75 dark:text-white/60 dark:hover:text-white/75'}`}

View file

@ -1,5 +1,5 @@
import React from 'react';
import {Comment, useAppContext, useLabs} from '../../../AppContext';
import {Comment, useAppContext} from '../../../AppContext';
type Props = {
comment: Comment;
@ -8,17 +8,12 @@ type Props = {
};
const AuthorContextMenu: React.FC<Props> = ({comment, close, toggleEdit}) => {
const {dispatchAction, t} = useAppContext();
const labs = useLabs();
const deleteComment = () => {
if (labs.commentImprovements) {
dispatchAction('openPopup', {
type: 'deletePopup',
comment
});
} else {
dispatchAction('deleteComment', comment);
}
dispatchAction('openPopup', {
type: 'deletePopup',
comment
});
close();
};
@ -27,7 +22,7 @@ const AuthorContextMenu: React.FC<Props> = ({comment, close, toggleEdit}) => {
<button className="w-full rounded px-2.5 py-1.5 text-left text-[14px] transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700" data-testid="edit" type="button" onClick={toggleEdit}>
{t('Edit')}
</button>
<button className="w-full rounded px-2.5 py-1.5 text-left text-[14px] text-red-600 transition-colors hover:bg-neutral-100 dark:text-red-500 dark:hover:bg-neutral-700" type="button" onClick={deleteComment}>
<button className="w-full rounded px-2.5 py-1.5 text-left text-[14px] text-red-600 transition-colors hover:bg-neutral-100 dark:text-red-500 dark:hover:bg-neutral-700" data-testid="delete" type="button" onClick={deleteComment}>
{t('Delete')}
</button>
</div>

View file

@ -0,0 +1,40 @@
import CommentContextMenu from './CommentContextMenu';
import React from 'react';
import sinon from 'sinon';
import {AppContext} from '../../../AppContext';
import {buildComment} from '../../../../test/utils/fixtures';
import {render, screen} from '@testing-library/react';
const contextualRender = (ui, {appContext, ...renderOptions}) => {
const contextWithDefaults = {
member: null,
dispatchAction: () => {},
t: str => str,
...appContext
};
return render(
<AppContext.Provider value={contextWithDefaults}>{ui}</AppContext.Provider>,
renderOptions
);
};
describe('<CommentContextMenu>', () => {
afterEach(() => {
sinon.restore();
});
it('has display-below classes when in viewport', () => {
const comment = buildComment();
contextualRender(<CommentContextMenu comment={comment} />, {appContext: {admin: true}});
expect(screen.getByTestId('comment-context-menu-inner')).toHaveClass('top-0');
});
it('has display-above classes when bottom is out of viewport', () => {
sinon.stub(HTMLElement.prototype, 'getBoundingClientRect').returns({bottom: 2000});
const comment = buildComment();
contextualRender(<CommentContextMenu comment={comment} />, {appContext: {admin: true}});
expect(screen.getByTestId('comment-context-menu-inner')).toHaveClass('bottom-full', 'mb-6');
});
});

View file

@ -1,22 +1,30 @@
import AdminContextMenu from './AdminContextMenu';
import AuthorContextMenu from './AuthorContextMenu';
import NotAuthorContextMenu from './NotAuthorContextMenu';
import {Comment, useAppContext, useLabs} from '../../../AppContext';
import {Comment, useAppContext} from '../../../AppContext';
import {useEffect, useRef} from 'react';
import {useOutOfViewportClasses} from '../../../utils/hooks';
type Props = {
comment: Comment;
close: () => void;
toggleEdit: () => void;
isLastComment?: boolean;
};
const CommentContextMenu: React.FC<Props> = ({comment, close, toggleEdit, isLastComment}) => {
const CommentContextMenu: React.FC<Props> = ({comment, close, toggleEdit}) => {
const {member, admin} = useAppContext();
const isAuthor = member && comment.member?.uuid === member?.uuid;
const isAdmin = !!admin;
const element = useRef<HTMLDivElement>(null);
const labs = useLabs();
const innerElement = useRef<HTMLDivElement>(null);
// By default display dropdown below but move above if that renders off-screen
useOutOfViewportClasses(innerElement, {
bottom: {
default: 'top-0',
outOfViewport: 'bottom-full mb-6'
}
});
useEffect(() => {
const listener = () => {
close();
@ -78,19 +86,11 @@ const CommentContextMenu: React.FC<Props> = ({comment, close, toggleEdit, isLast
}
return (
labs.commentImprovements ? (
<div ref={element} className="relative" onClick={stopPropagation}>
<div className={`absolute z-10 min-w-min whitespace-nowrap rounded bg-white p-1 font-sans text-sm shadow-lg outline-0 sm:min-w-[80px] dark:bg-neutral-800 dark:text-white ${isLastComment ? 'bottom-full mb-6' : 'top-0'}`}>
{contextMenu}
</div>
<div ref={element} className="relative" data-testid="comment-context-menu" onClick={stopPropagation}>
<div ref={innerElement} className={`absolute z-10 min-w-min whitespace-nowrap rounded bg-white p-1 font-sans text-sm shadow-lg outline-0 sm:min-w-[80px] dark:bg-neutral-800 dark:text-white`} data-testid="comment-context-menu-inner">
{contextMenu}
</div>
) : (
<div ref={element} onClick={stopPropagation}>
<div className="absolute z-10 min-w-min whitespace-nowrap rounded bg-white p-1 font-sans text-sm shadow-lg outline-0 sm:min-w-[80px] dark:bg-neutral-800 dark:text-white">
{contextMenu}
</div>
</div>
)
</div>
);
};

View file

@ -1,5 +1,5 @@
import React from 'react';
import {useAppContext} from '../../../AppContext';
import {Comment, useAppContext} from '../../../AppContext';
type Props = {
comment: Comment;

View file

@ -1,9 +1,8 @@
import Form from './Form';
import {Comment, OpenCommentForm, useAppContext} from '../../../AppContext';
import {getEditorConfig} from '../../../utils/editor';
import {Form} from './Form';
import {isMobile} from '../../../utils/helpers';
import {useCallback, useEffect} from 'react';
import {useEditor} from '@tiptap/react';
import {useCallback, useEffect, useMemo} from 'react';
import {useEditor} from '../../../utils/hooks';
type Props = {
openForm: OpenCommentForm;
@ -14,17 +13,15 @@ type Props = {
const EditForm: React.FC<Props> = ({comment, openForm, parent}) => {
const {dispatchAction, t} = useAppContext();
const config = {
const editorConfig = useMemo(() => ({
placeholder: t('Edit this comment'),
// warning: we cannot use autofocus on the edit field, because that sets
// the cursor position at the beginning of the text field instead of the end
autofocus: false,
content: comment.html
};
}), [comment]);
const editor = useEditor({
...getEditorConfig(config)
});
const {editor} = useEditor(editorConfig);
// Instead of autofocusing, we focus and jump to end manually
useEffect(() => {
@ -60,20 +57,18 @@ const EditForm: React.FC<Props> = ({comment, openForm, parent}) => {
}, [dispatchAction, openForm]);
return (
<div className='px-2 pb-2 pt-3'>
<div className='mt-[-16px] pr-3'>
<Form
close={close}
comment={comment}
editor={editor}
isOpen={true}
openForm={openForm}
reduced={isMobile()}
submit={submit}
submitSize={'small'}
submitText={t('Save')}
/>
</div>
<div className="relative w-full">
<Form
close={close}
comment={comment}
editor={editor}
isOpen={true}
openForm={openForm}
reduced={isMobile()}
submit={submit}
submitSize={'small'}
submitText={t('Save')}
/>
</div>
);
};

View file

@ -1,6 +1,6 @@
import React from 'react';
import {Avatar} from '../Avatar';
import {Comment, OpenCommentForm, useAppContext, useLabs} from '../../../AppContext';
import {Comment, OpenCommentForm, useAppContext} from '../../../AppContext';
import {ReactComponent as EditIcon} from '../../../images/icons/edit.svg';
import {Editor, EditorContent} from '@tiptap/react';
import {ReactComponent as SpinnerIcon} from '../../../images/icons/spinner.svg';
@ -8,9 +8,9 @@ import {Transition} from '@headlessui/react';
import {useCallback, useEffect, useRef, useState} from 'react';
import {usePopupOpen} from '../../../utils/hooks';
type Progress = 'default' | 'sending' | 'sent' | 'error';
export type Progress = 'default' | 'sending' | 'sent' | 'error';
export type SubmitSize = 'small' | 'medium' | 'large';
type FormEditorProps = {
export type FormEditorProps = {
comment?: Comment;
submit: (data: {html: string}) => Promise<void>;
progress: Progress;
@ -23,27 +23,24 @@ type FormEditorProps = {
submitSize: SubmitSize;
openForm?: OpenCommentForm;
};
const FormEditor: React.FC<FormEditorProps> = ({comment, submit, progress, setProgress, close, reduced, isOpen, editor, submitText, submitSize, openForm}) => {
const labs = useLabs();
export const FormEditor: React.FC<FormEditorProps> = ({comment, submit, progress, setProgress, close, isOpen, editor, submitText, submitSize, openForm}) => {
const {dispatchAction, t} = useAppContext();
let buttonIcon = null;
const [hasContent, setHasContent] = useState(false);
useEffect(() => {
if (editor) {
if (editor && openForm) {
const checkContent = () => {
const editorHasContent = !editor.isEmpty;
setHasContent(editorHasContent);
const hasUnsavedChanges = comment && openForm.type === 'edit' ?
editor.getHTML() !== comment.html :
!editor.isEmpty;
if (openForm) {
const hasUnsavedChanges = comment && openForm.type === 'edit' ? editor.getHTML() !== comment.html : editorHasContent;
// avoid unnecessary state updates to prevent infinite loops
if (openForm.hasUnsavedChanges !== hasUnsavedChanges) {
dispatchAction('setCommentFormHasUnsavedChanges', {id: openForm.id, hasUnsavedChanges});
}
// avoid unnecessary state updates to prevent infinite loops
if (openForm.hasUnsavedChanges !== hasUnsavedChanges) {
dispatchAction('setCommentFormHasUnsavedChanges', {id: openForm.id, hasUnsavedChanges});
}
};
editor.on('update', checkContent);
editor.on('transaction', checkContent);
@ -57,7 +54,7 @@ const FormEditor: React.FC<FormEditorProps> = ({comment, submit, progress, setPr
}, [editor, comment, openForm, dispatchAction]);
if (progress === 'sending') {
buttonIcon = <SpinnerIcon className={`h-[24px] w-[24px] fill-white ${labs.commentImprovements ? '' : 'dark:fill-black'}`} data-testid="button-spinner" />;
buttonIcon = <SpinnerIcon className={`h-[24px] w-[24px] fill-white`} data-testid="button-spinner" />;
}
const stopIfFocused = useCallback((event) => {
@ -132,17 +129,10 @@ const FormEditor: React.FC<FormEditorProps> = ({comment, submit, progress, setPr
};
}, [editor, close, submitForm]);
let openStyles = '';
if (isOpen) {
const isReplyToReply = labs.commentImprovements && !!openForm?.in_reply_to_snippet;
openStyles = isReplyToReply ? 'pl-[1px] pt-[68px] sm:pl-[44px] sm:pt-[56px]' : 'pl-[1px] pt-[48px] sm:pl-[44px] sm:pt-[40px]';
}
return (
<div className={`relative w-full pl-[40px] transition-[padding] delay-100 duration-150 sm:pl-[44px] ${reduced && 'pl-0'} ${openStyles}`}>
<>
<div
className={`text-md min-h-[120px] w-full rounded-lg border border-black/10 bg-white/75 p-2 pb-[68px] font-sans leading-normal transition-all delay-100 duration-150 focus:outline-0 sm:px-3 sm:text-lg dark:bg-white/10 dark:text-neutral-300 ${isOpen ? 'cursor-text' : 'cursor-pointer'}
`}
className={`text-md min-h-[120px] w-full rounded-lg border border-black/10 bg-white/75 p-2 pb-[68px] font-sans leading-normal transition-all delay-100 duration-150 focus:outline-0 sm:px-3 sm:text-lg dark:bg-white/10 dark:text-neutral-300 ${isOpen ? 'cursor-text' : 'cursor-pointer'}`}
data-testid="form-editor">
<EditorContent
editor={editor} onMouseDown={stopIfFocused}
@ -153,30 +143,18 @@ const FormEditor: React.FC<FormEditorProps> = ({comment, submit, progress, setPr
{close &&
<button className="ml-2.5 font-sans text-sm font-medium text-neutral-900/50 outline-0 transition-all hover:text-neutral-900/70 dark:text-white/60 dark:hover:text-white/75" type="button" onClick={close}>{t('Cancel')}</button>
}
{labs.commentImprovements ? (
<button
className={`flex w-auto items-center justify-center ${submitSize === 'medium' && 'sm:min-w-[100px]'} ${submitSize === 'small' && 'sm:min-w-[64px]'} h-[40px] rounded-md bg-[var(--gh-accent-color)] px-3 py-2 text-center font-sans text-base font-medium text-white outline-0 transition-colors duration-200 hover:brightness-105 disabled:bg-black/5 disabled:text-neutral-900/30 sm:text-sm dark:disabled:bg-white/15 dark:disabled:text-white/35`}
data-testid="submit-form-button"
disabled={!hasContent}
type="button"
onClick={submitForm}
>
{buttonIcon && <span className="mr-1">{buttonIcon}</span>}
{submitText && <span>{submitText}</span>}
</button>
) : (
<button
className={`flex w-auto items-center justify-center ${submitSize === 'medium' && 'sm:min-w-[100px]'} ${submitSize === 'small' && 'sm:min-w-[64px]'} h-[40px] rounded-[6px] bg-neutral-900 px-3 py-2 text-center font-sans text-base font-medium text-white/95 outline-0 transition-all duration-150 hover:bg-black hover:text-white sm:text-sm dark:bg-white/95 dark:text-neutral-800 dark:hover:bg-white dark:hover:text-neutral-900`}
data-testid="submit-form-button"
type="button"
onClick={submitForm}
>
<span>{buttonIcon}</span>
{submitText && <span>{submitText}</span>}
</button>
)}
<button
className={`flex w-auto items-center justify-center ${submitSize === 'medium' && 'sm:min-w-[100px]'} ${submitSize === 'small' && 'sm:min-w-[64px]'} h-[40px] rounded-md bg-[var(--gh-accent-color)] px-3 py-2 text-center font-sans text-base font-medium text-white outline-0 transition-colors duration-200 hover:brightness-105 disabled:bg-black/5 disabled:text-neutral-900/30 sm:text-sm dark:disabled:bg-white/15 dark:disabled:text-white/35`}
data-testid="submit-form-button"
disabled={!editor || editor.isEmpty}
type="button"
onClick={submitForm}
>
{buttonIcon && <span className="mr-1">{buttonIcon}</span>}
{submitText && <span>{submitText}</span>}
</button>
</div>
</div>
</>
);
};
@ -192,12 +170,12 @@ type FormHeaderProps = {
const FormHeader: React.FC<FormHeaderProps> = ({show, name, expertise, replyingToText, editName, editExpertise}) => {
const {t} = useAppContext();
const labs = useLabs();
const isReplyingToReply = labs.commentImprovements && replyingToText;
const isReplyingToReply = !!replyingToText;
return (
<Transition
data-testid="form-header"
enter="transition duration-500 delay-100 ease-in-out"
enterFrom="opacity-0 -translate-x-2"
enterTo="opacity-100 translate-x-0"
@ -210,7 +188,7 @@ const FormHeader: React.FC<FormHeaderProps> = ({show, name, expertise, replyingT
<div
className="w-full font-sans text-base font-bold leading-snug text-neutral-900 sm:w-auto sm:text-sm dark:text-white/85"
data-testid="member-name"
onClick={editName}
onMouseDown={editName}
>
{name ? name : 'Anonymous'}
</div>
@ -219,7 +197,7 @@ const FormHeader: React.FC<FormHeaderProps> = ({show, name, expertise, replyingT
className={`group flex items-center justify-start whitespace-nowrap text-left font-sans text-base leading-snug text-neutral-900/50 transition duration-150 hover:text-black/75 sm:text-sm dark:text-white/60 dark:hover:text-white/75 ${!expertise && 'text-black/30 hover:text-black/50 dark:text-white/30 dark:hover:text-white/50'}`}
data-testid="expertise-button"
type="button"
onClick={editExpertise}
onMouseDown={editExpertise}
>
<span><span className="mx-[0.3em] hidden sm:inline">·</span>{expertise ? expertise : 'Add your expertise'}</span>
{expertise && <EditIcon className="ml-1 h-[12px] w-[12px] translate-x-[-6px] stroke-black/50 opacity-0 transition-all duration-100 ease-out group-hover:translate-x-0 group-hover:stroke-black/75 group-hover:opacity-100 dark:stroke-white/60 dark:group-hover:stroke-white/75" />}
@ -236,7 +214,6 @@ const FormHeader: React.FC<FormHeaderProps> = ({show, name, expertise, replyingT
};
type FormProps = {
openForm: OpenCommentForm;
comment?: Comment;
editor: Editor | null;
submit: (data: {html: string}) => Promise<void>;
@ -245,16 +222,26 @@ type FormProps = {
close?: () => void;
isOpen: boolean;
reduced: boolean;
openForm?: OpenCommentForm;
};
const Form: React.FC<FormProps> = ({comment, submit, submitText, submitSize, close, editor, reduced, isOpen, openForm}) => {
const {member, dispatchAction} = useAppContext();
const Form: React.FC<FormProps> = ({
comment,
submit,
submitText,
submitSize,
close,
editor,
reduced,
isOpen,
openForm
}) => {
const {member} = useAppContext();
const isAskingDetails = usePopupOpen('addDetailsPopup');
const [progress, setProgress] = useState<Progress>('default');
const formEl = useRef(null);
const memberName = member?.name ?? comment?.member?.name;
const memberExpertise = member?.expertise ?? comment?.member?.expertise;
if (progress === 'sending' || (memberName && isAskingDetails)) {
// Force open
@ -268,6 +255,67 @@ const Form: React.FC<FormProps> = ({comment, submit, submitText, submitSize, clo
}
};
useEffect(() => {
if (!editor) {
return;
}
// Disable editing if the member doesn't have a name or when we are submitting the form
editor.setEditable(!!memberName && progress !== 'sending');
}, [editor, memberName, progress]);
return (
<form
ref={formEl}
data-testid="form"
onMouseDown={preventIfFocused}
onTouchStart={preventIfFocused}
>
<FormEditor
close={close}
comment={comment}
editor={editor}
isOpen={isOpen}
openForm={openForm}
progress={progress}
reduced={reduced}
setProgress={setProgress}
submit={submit}
submitSize={submitSize}
submitText={submitText}
/>
</form>
);
};
type FormWrapperProps = {
comment?: Comment;
editor: Editor | null;
isOpen: boolean;
reduced: boolean;
openForm?: OpenCommentForm;
children: React.ReactNode;
};
const FormWrapper: React.FC<FormWrapperProps> = ({
comment,
editor,
isOpen,
reduced,
openForm,
children
}) => {
const {member, dispatchAction} = useAppContext();
const memberName = member?.name ?? comment?.member?.name;
const memberExpertise = member?.expertise ?? comment?.member?.expertise;
let openStyles = '';
if (isOpen) {
const isReplyToReply = !!openForm?.in_reply_to_snippet;
openStyles = isReplyToReply ? 'pl-[1px] pt-[68px] sm:pl-[44px] sm:pt-[56px]' : 'pl-[1px] pt-[48px] sm:pl-[44px] sm:pt-[40px]';
}
const openEditDetails = useCallback((options) => {
editor?.commands?.blur();
@ -275,21 +323,19 @@ const Form: React.FC<FormProps> = ({comment, submit, submitText, submitSize, clo
type: 'addDetailsPopup',
expertiseAutofocus: options.expertiseAutofocus ?? false,
callback: function (succeeded: boolean) {
if (!editor || !formEl.current) {
if (!editor) {
return;
}
// Don't use focusEditor to avoid loop
if (!succeeded) {
return;
}
// useEffect is not fast enought to enable it
editor.setEditable(true);
editor.commands.focus();
}
});
}, [editor, dispatchAction, formEl]);
}, [editor, dispatchAction]);
const editName = useCallback(() => {
openEditDetails({expertiseAutofocus: false});
@ -317,43 +363,17 @@ const Form: React.FC<FormProps> = ({comment, submit, submitText, submitSize, clo
editor.commands.focus();
}, [editor, editName, memberName]);
useEffect(() => {
if (!editor) {
return;
}
// Disable editing if the member doesn't have a name or when we are submitting the form
editor.setEditable(!!memberName && progress !== 'sending');
}, [editor, memberName, progress]);
return (
<form
ref={formEl}
className={`-mx-2 mb-7 mt-[-10px] rounded-md transition duration-200 ${isOpen ? 'cursor-default' : 'cursor-pointer'}`}
data-testid="form"
onClick={focusEditor}
onMouseDown={preventIfFocused}
onTouchStart={preventIfFocused}
>
<div className="relative w-full">
<div className={`-mx-2 mt-[-10px] rounded-md transition duration-200 ${isOpen ? 'cursor-default' : 'cursor-pointer'}`}>
<div className="relative w-full" onClick={focusEditor}>
<div className="pr-[1px] font-sans leading-normal dark:text-neutral-300">
<FormEditor
close={close}
comment={comment}
editor={editor}
isOpen={isOpen}
openForm={openForm}
progress={progress}
reduced={reduced}
setProgress={setProgress}
submit={submit}
submitSize={submitSize}
submitText={submitText}
/>
<div className={`relative mb-7 w-full pl-[40px] transition-[padding] delay-100 duration-150 sm:pl-[44px] ${reduced && 'pl-0'} ${openStyles}`}>
{children}
</div>
</div>
<div className='absolute left-0 top-1 flex h-11 w-full items-start justify-start sm:h-12'>
<div className="pointer-events-none mr-2 grow-0 sm:mr-3">
<Avatar comment={comment} />
<Avatar member={member} />
</div>
<div className="grow-1 mt-0.5 w-full">
<FormHeader
@ -368,8 +388,9 @@ const Form: React.FC<FormProps> = ({comment, submit, submitText, submitSize, clo
</div>
</div>
</div>
</form>
</div>
);
};
export {Form, FormWrapper};
export default Form;

View file

@ -1,24 +1,22 @@
import Form from './Form';
import React, {useCallback, useEffect, useRef} from 'react';
import {getEditorConfig} from '../../../utils/editor';
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {Form, FormWrapper} from './Form';
import {scrollToElement} from '../../../utils/helpers';
import {useAppContext} from '../../../AppContext';
import {useEditor} from '@tiptap/react';
import {useEditor} from '../../../utils/hooks';
type Props = {
commentsCount: number
};
const MainForm: React.FC<Props> = ({commentsCount}) => {
const {postId, dispatchAction, t} = useAppContext();
const config = {
const editorConfig = useMemo(() => ({
placeholder: (commentsCount === 0 ? t('Start the conversation') : t('Join the discussion')),
autofocus: false
};
}), [commentsCount]);
const editor = useEditor({
...getEditorConfig(config)
}, [commentsCount]);
const {editor, hasContent} = useEditor(editorConfig);
const submit = useCallback(async ({html}) => {
// Send comment to server
@ -27,7 +25,7 @@ const MainForm: React.FC<Props> = ({commentsCount}) => {
status: 'published',
html
});
editor?.commands.clearContent();
}, [postId, dispatchAction, editor]);
@ -93,11 +91,18 @@ const MainForm: React.FC<Props> = ({commentsCount}) => {
submit
};
const isOpen = editor?.isFocused ?? false;
const isOpen = editor?.isFocused || hasContent;
return (
<div ref={formEl} className='px-3 pb-2 pt-3' data-testid="main-form">
<Form editor={editor} isOpen={isOpen} reduced={false} {...submitProps} />
<FormWrapper editor={editor} isOpen={isOpen} reduced={false}>
<Form
editor={editor}
isOpen={isOpen}
reduced={false}
{...submitProps}
/>
</FormWrapper>
</div>
);
};

View file

@ -1,27 +1,25 @@
import Form from './Form';
import {Comment, OpenCommentForm, useAppContext} from '../../../AppContext';
import {getEditorConfig} from '../../../utils/editor';
import {Form, FormWrapper} from './Form';
import {isMobile, scrollToElement} from '../../../utils/helpers';
import {useCallback} from 'react';
import {useEditor} from '@tiptap/react';
import {useCallback, useMemo} from 'react';
import {useEditor} from '../../../utils/hooks';
import {useRefCallback} from '../../../utils/hooks';
type Props = {
openForm: OpenCommentForm;
parent: Comment;
}
const ReplyForm: React.FC<Props> = ({openForm, parent}) => {
const {postId, dispatchAction, t} = useAppContext();
const [, setForm] = useRefCallback<HTMLDivElement>(scrollToElement);
const config = {
const config = useMemo(() => ({
placeholder: t('Reply to comment'),
autofocus: true
};
}), []);
const editor = useEditor({
...getEditorConfig(config)
});
const {editor} = useEditor(config);
const submit = useCallback(async ({html}) => {
// Send comment to server
@ -45,18 +43,20 @@ const ReplyForm: React.FC<Props> = ({openForm, parent}) => {
</>);
return (
<div ref={setForm}>
<div ref={setForm} data-testid="reply-form">
<div className='mt-[-16px] pr-2'>
<Form
close={close}
editor={editor}
isOpen={true}
openForm={openForm}
reduced={isMobile()}
submit={submit}
submitSize={'medium'}
submitText={SubmitText}
/>
<FormWrapper comment={parent} editor={editor} isOpen={true} openForm={openForm} reduced={isMobile()}>
<Form
close={close}
editor={editor}
isOpen={true}
openForm={openForm}
reduced={isMobile()}
submit={submit}
submitSize={'medium'}
submitText={SubmitText}
/>
</FormWrapper>
</div>
</div>
);

View file

@ -47,7 +47,7 @@ export const SortingForm: React.FC = () => {
};
return (
<div ref={dropdownRef} className="relative" data-testid="comments-sorting-form" onClick={stopPropagation}>
<div ref={dropdownRef} className="relative z-20" data-testid="comments-sorting-form" onClick={stopPropagation}>
<button
className="flex w-full items-center justify-between gap-2 text-sm font-medium text-neutral-900 focus-visible:outline-none dark:text-neutral-100"
type="button"

View file

@ -64,7 +64,7 @@ const DeletePopup = ({comment}: {comment: Comment}) => {
};
return (
<div className="shadow-modal relative h-screen w-screen rounded-none bg-white p-[28px] text-center sm:h-auto sm:w-[500px] sm:rounded-xl sm:p-8 sm:text-left" onMouseDown={stopPropagation}>
<div className="shadow-modal relative h-screen w-screen rounded-none bg-white p-[28px] text-center sm:h-auto sm:w-[500px] sm:rounded-xl sm:p-8 sm:text-left" data-testid="delete-popup" onMouseDown={stopPropagation}>
<div className="flex h-full flex-col justify-center pt-10 sm:justify-normal sm:pt-0">
<h1 className="mb-1.5 font-sans text-[2.2rem] font-bold tracking-tight text-black">
<span>{t('Are you sure?')}</span>
@ -73,6 +73,7 @@ const DeletePopup = ({comment}: {comment: Comment}) => {
<div className="mt-auto flex flex-col items-center justify-start gap-4 sm:mt-8 sm:flex-row">
<button
className={`text-md flex h-[44px] w-full items-center justify-center rounded-md px-4 font-sans font-medium text-white transition duration-200 ease-linear sm:w-fit ${buttonColor} opacity-100 hover:opacity-90`}
data-testid="delete-popup-confirm"
disabled={isSubmitting}
type="button"
onClick={submit}
@ -92,4 +93,4 @@ const DeletePopup = ({comment}: {comment: Comment}) => {
);
};
export default DeletePopup;
export default DeletePopup;

View file

@ -1,5 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 9.5C7 10.1566 7.12933 10.8068 7.3806 11.4134C7.63188 12.02 8.00017 12.5712 8.46447 13.0355C8.92876 13.4998 9.47995 13.8681 10.0866 14.1194C10.6932 14.3707 11.3434 14.5 12 14.5C12.6566 14.5 13.3068 14.3707 13.9134 14.1194C14.52 13.8681 15.0712 13.4998 15.5355 13.0355C15.9998 12.5712 16.3681 12.02 16.6194 11.4134C16.8707 10.8068 17 10.1566 17 9.5C17 8.84339 16.8707 8.19321 16.6194 7.58658C16.3681 6.97995 15.9998 6.42876 15.5355 5.96447C15.0712 5.50017 14.52 5.13188 13.9134 4.8806C13.3068 4.62933 12.6566 4.5 12 4.5C11.3434 4.5 10.6932 4.62933 10.0866 4.8806C9.47995 5.13188 8.92876 5.50017 8.46447 5.96447C8.00017 6.42876 7.63188 6.97995 7.3806 7.58658C7.12933 8.19321 7 8.84339 7 9.5V9.5Z" stroke="white" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="11.375" stroke="white" stroke-width="1.25"/>
<path d="M19.5 20C19.092 19.6069 18.4941 19.2498 17.7403 18.9489C16.9864 18.6481 16.0915 18.4094 15.1066 18.2466C14.1217 18.0838 13.0661 18 12 18C10.9339 18 9.87831 18.0838 8.8934 18.2466C7.90848 18.4094 7.01357 18.6481 6.25975 18.9489C5.50593 19.2498 4.90796 19.6069 4.5 20" stroke="white" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.55 4C13.483 4 15.05 5.567 15.05 7.5C15.05 9.433 13.483 11 11.55 11C9.617 11 8.05 9.433 8.05 7.5C8.05 5.567 9.617 4 11.55 4ZM11.55 20C9.044 20 6.757 19.067 5 17.538C5.997 14.888 8.551 13 11.55 13C14.549 13 17.103 14.888 18.1 17.538C16.343 19.066 14.056 20 11.55 20Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 384 B

View file

@ -36,7 +36,7 @@ body {
/* Blockquotes */
.gh-comment-content blockquote {
border-left: 3px solid rgba(13,13,13,.1);
border-left: 3px solid var(--gh-accent-color);
padding-left: 1rem;
margin: 0 0 1.2rem;
}

View file

@ -97,7 +97,7 @@ describe('setupAdminAPI', () => {
const adminUrl = 'https://example.com';
const api = setupAdminAPI({adminUrl});
const apiPromise = api.showComment('123');
const apiPromise = api.showComment({id: '123'});
const eventHandler = addEventListenerSpy.mock.calls.find(
([eventType]) => eventType === 'message'

View file

@ -59,11 +59,11 @@ export function setupAdminAPI({adminUrl}: {adminUrl: string}) {
async hideComment(id: string) {
return await callApi('hideComment', {id});
},
async showComment(id: string) {
async showComment({id} : {id: string}) {
return await callApi('showComment', {id});
},
async browse({page, postId, order}: {page: number, postId: string, order?: string}) {
async browse({page, postId, order, memberUuid}: {page: number, postId: string, order?: string, memberUuid?: string}) {
let filter = null;
if (firstCommentCreatedAt && !order) {
filter = `created_at:<=${firstCommentCreatedAt}`;
@ -80,6 +80,10 @@ export function setupAdminAPI({adminUrl}: {adminUrl: string}) {
params.set('order', order);
}
if (memberUuid) {
params.set('impersonate_member_uuid', memberUuid);
}
const response = await callApi('browseComments', {postId, params: params.toString()});
if (!firstCommentCreatedAt) {
const firstComment = response.comments[0];
@ -90,7 +94,7 @@ export function setupAdminAPI({adminUrl}: {adminUrl: string}) {
return response;
},
async replies({commentId, afterReplyId, limit}: {commentId: string; afterReplyId: string; limit?: number | 'all'}) {
async replies({commentId, afterReplyId, limit, memberUuid}: {commentId: string; afterReplyId: string; limit?: number | 'all', memberUuid?: string}) {
const filter = `id:>'${afterReplyId}'`;
const params = new URLSearchParams();
@ -103,14 +107,26 @@ export function setupAdminAPI({adminUrl}: {adminUrl: string}) {
params.set('filter', filter);
}
if (memberUuid) {
params.set('impersonate_member_uuid', memberUuid);
}
const response = await callApi('getReplies', {commentId, params: params.toString()});
return response;
},
async read({commentId}: {commentId: string}) {
const response = await callApi('readComment', {commentId});
return response;
async read({commentId, memberUuid}: {commentId: string, memberUuid?: string}) {
const params = new URLSearchParams();
if (memberUuid) {
params.set('impersonate_member_uuid', memberUuid);
}
return await callApi('readComment', {
commentId,
...(params.toString() && {params: params.toString()})
});
}
};

View file

@ -7,7 +7,12 @@ import Placeholder from '@tiptap/extension-placeholder';
import Text from '@tiptap/extension-text';
import {EditorOptions} from '@tiptap/core';
export function getEditorConfig({placeholder, autofocus = false, content = ''}: {placeholder: string; autofocus?: boolean; content?: string}): Partial<EditorOptions> {
export type CommentsEditorConfig = {
placeholder: string;
autofocus?: boolean;
content?: string;
}
export function getEditorConfig({placeholder, autofocus = false, content = ''}: CommentsEditorConfig): Partial<EditorOptions> {
return {
extensions: [
Document,

View file

@ -0,0 +1,74 @@
import React from 'react';
import sinon from 'sinon';
import {fireEvent, render, screen} from '@testing-library/react';
import {useOutOfViewportClasses} from './hooks';
describe('useOutOfViewportClasses', () => {
const classes = {
top: {default: 'default-top', outOfViewport: 'out-top'},
bottom: {default: 'default-bottom', outOfViewport: 'out-bottom'},
left: {default: 'default-left', outOfViewport: 'out-left'},
right: {default: 'default-right', outOfViewport: 'out-right'}
};
const TestComponent = () => {
const ref = React.useRef<HTMLDivElement>(null);
useOutOfViewportClasses(ref, classes);
// eslint-disable-next-line i18next/no-literal-string
return <div ref={ref} data-testid="test-element">Test element</div>;
};
afterEach(() => {
sinon.restore();
});
it('should apply default classes on mount when in viewport', () => {
render(<TestComponent />);
const element = screen.getByTestId('test-element');
expect(element).toHaveClass('default-top', 'default-bottom', 'default-left', 'default-right');
});
it('should apply outOfViewport classes on mount when out of viewport', () => {
sinon.stub(HTMLElement.prototype, 'getBoundingClientRect').returns({
top: -100, // out of viewport
bottom: 2000, // out of viewport (jest-dom default height: 768)
left: -5, // out of viewport
right: 2000, // out of viewport (jest-dom default width: 1024)
width: 100,
height: 50,
x: 0,
y: 0,
toJSON: () => ({})
});
render(<TestComponent />);
const element = screen.getByTestId('test-element');
expect(element).toHaveClass('out-top', 'out-bottom', 'out-left', 'out-right');
});
it('should apply outOfViewport classes when element moves out of viewport on resize', () => {
render(<TestComponent />);
const element = screen.getByTestId('test-element');
expect(element).toHaveClass('default-top', 'default-bottom', 'default-left', 'default-right');
sinon.stub(HTMLElement.prototype, 'getBoundingClientRect').returns({
top: -100, // out of viewport
bottom: 2000, // out of viewport (jest-dom default height: 768)
left: -5, // out of viewport
right: 2000, // out of viewport (jest-dom default width: 1024)
width: 100,
height: 50,
x: 0,
y: 0,
toJSON: () => ({})
});
fireEvent.resize(window);
expect(element).toHaveClass('out-top', 'out-bottom', 'out-left', 'out-right');
});
});

View file

@ -1,6 +1,8 @@
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {CommentsEditorConfig, getEditorConfig} from './editor';
import {Editor, useEditor as useTiptapEditor} from '@tiptap/react';
import {formatRelativeTime} from './helpers';
import {useAppContext} from '../AppContext';
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
/**
* Execute a callback when a ref is set and unset.
@ -26,23 +28,6 @@ export function useRefCallback<T>(setup: (element: T) => void, clear?: (element:
return [ref, setRef];
}
/**
* Sames as useEffect, but ignores the first mounted call and the first update (so first 2 calls ignored)
* @param {Same} fn
* @param {*} inputs
*/
export function useSecondUpdate(fn: () => void, inputs: React.DependencyList) {
const didMountRef = useRef(0);
useEffect(() => {
if (didMountRef.current >= 2) {
return fn();
}
didMountRef.current += 1;
// We shouldn't listen for fn changes, so ignore exhaustive-deps
}, inputs);
}
export function usePopupOpen(type: string) {
const {popup} = useAppContext();
return popup?.type === type;
@ -57,3 +42,115 @@ export function useRelativeTime(dateString: string) {
return formatRelativeTime(dateString, t);
}, [dateString]);
}
export function useEditor(editorConfig: CommentsEditorConfig, initialHasContent = false): {editor: Editor | null, hasContent: boolean} {
const [hasContent, setHasContent] = useState(initialHasContent);
const _editorConfig = useMemo(() => ({
...getEditorConfig(editorConfig)
}), [editorConfig]);
const editor = useTiptapEditor(_editorConfig, [_editorConfig]);
useEffect(() => {
if (editor) {
const checkContent = () => {
const editorHasContent = !editor.isEmpty;
setHasContent(editorHasContent);
};
editor.on('update', checkContent);
editor.on('transaction', checkContent);
checkContent();
return () => {
editor.off('update', checkContent);
editor.off('transaction', checkContent);
};
}
}, [editor]);
return {
editor,
hasContent
};
}
type OutOfViewport = {
top: boolean;
bottom: boolean;
left: boolean;
right: boolean;
}
type OutOfViewportClassOptions = {
default: string;
outOfViewport: string;
}
type OutOfViewportClasses = {
top?: OutOfViewportClassOptions;
bottom?: OutOfViewportClassOptions;
left?: OutOfViewportClassOptions;
right?: OutOfViewportClassOptions;
};
// TODO: This does not currently handle the case where the element is outOfViewport for both top&bottom or left&right
export function useOutOfViewportClasses(ref: React.RefObject<HTMLElement>, classes: OutOfViewportClasses) {
// Add/Remove classes directly on the element based on whether it's out of the viewport
// Modifies element classes directly in DOM so it's compatible with useLayoutEffect
const applyDefaultClasses = useCallback(() => {
if (ref.current) {
for (const value of Object.values(classes)) {
ref.current.classList.add(...value.default.split(' '));
ref.current.classList.remove(...value.outOfViewport.split(' '));
}
}
}, [ref, classes]);
const applyOutOfViewportClasses = useCallback((outOfViewport: OutOfViewport) => {
if (ref.current) {
for (const [side, sideClasses] of Object.entries(classes)) {
if (outOfViewport[side as keyof OutOfViewport]) {
ref.current.classList.add(...sideClasses.outOfViewport.split(' '));
ref.current.classList.remove(...sideClasses.default.split(' '));
} else {
ref.current.classList.add(...sideClasses.default.split(' '));
ref.current.classList.remove(...sideClasses.outOfViewport.split(' '));
}
}
}
}, [ref, classes]);
const updateOutOfViewportClasses = useCallback(() => {
if (ref.current) {
// Handle element being inside an iframe
const _document = ref.current.ownerDocument;
const _window = _document.defaultView || window;
// Reset classes so we can re-calculate without any previous re-positioning affecting the calcs
applyDefaultClasses();
const bounding = ref.current.getBoundingClientRect();
const outOfViewport = {
top: bounding.top < 0,
bottom: bounding.bottom > (_window.innerHeight || _document.documentElement.clientHeight),
left: bounding.left < 0,
right: bounding.right > (_window.innerWidth || _document.documentElement.clientWidth)
};
applyOutOfViewportClasses(outOfViewport);
}
}, [ref]);
// Layout effect needed here to avoid flicker of the default position before
// repositioning the element
useLayoutEffect(() => {
updateOutOfViewportClasses();
}, [ref]);
useEffect(() => {
window.addEventListener('resize', updateOutOfViewportClasses);
return () => {
window.removeEventListener('resize', updateOutOfViewportClasses);
};
}, []);
}

View file

@ -1,6 +1,18 @@
module.exports = {
darkMode: 'class',
theme: {
extend: {
animation: {
heartbeat: 'heartbeat 0.35s ease-in-out forwards',
pulse: 'pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite'
},
keyframes: {
heartbeat: {
'0%, 100%': {transform: 'scale(1)'},
'50%': {transform: 'scale(1.3)'}
}
}
},
screens: {
sm: '481px',
md: '768px',
@ -158,12 +170,16 @@ module.exports = {
]
},
animation: {
heartbeat: 'heartbeat 0.35s ease-in-out forwards'
heartbeat: 'heartbeat 0.35s ease-in-out forwards',
highlight: 'highlight 1s steps(1) forwards'
},
keyframes: {
heartbeat: {
'0%, 100%': {transform: 'scale(1)'},
'50%': {transform: 'scale(1.3)'}
},
highlight: {
'100%': {backgroundColor: 'transparent'}
}
}
},

View file

@ -1,5 +1,5 @@
import {MockedApi, initialize, waitEditorFocused} from '../utils/e2e';
import {buildReply} from '../utils/fixtures';
import {buildMember, buildReply} from '../utils/fixtures';
import {expect, test} from '@playwright/test';
test.describe('Actions', async () => {
@ -10,9 +10,8 @@ test.describe('Actions', async () => {
mockedApi,
page,
publication: 'Publisher Weekly',
labs: {
commentImprovements: labs
}
// always return `labs` value for any labs.x property access
labs: new Proxy({}, {get: () => labs})
});
}
@ -26,6 +25,7 @@ test.describe('Actions', async () => {
});
test('Can like and unlike a comment', async ({page}) => {
// NOTE: comments are ordered by likes
mockedApi.addComment({
html: '<p>This is comment 1</p>'
});
@ -43,7 +43,7 @@ test.describe('Actions', async () => {
const {frame} = await initializeTest(page);
// Check like button is not filled yet
const comment = frame.getByTestId('comment-component').nth(0);
const comment = frame.getByTestId('comment-component').nth(1);
const likeButton = comment.getByTestId('like-button');
await expect(likeButton).toHaveCount(1);
@ -65,7 +65,7 @@ test.describe('Actions', async () => {
await expect(likeButton).toHaveText('0');
// Check state for already liked comment
const secondComment = frame.getByTestId('comment-component').nth(1);
const secondComment = frame.getByTestId('comment-component').nth(0);
const likeButton2 = secondComment.getByTestId('like-button');
await expect(likeButton2).toHaveCount(1);
const icon2 = likeButton2.locator('svg');
@ -120,14 +120,14 @@ test.describe('Actions', async () => {
// Click button
await replyButton.click();
const editor = frame.getByTestId('form-editor');
const editor = comment.getByTestId('form-editor');
await expect(editor).toBeVisible();
// Wait for focused
await waitEditorFocused(editor);
// Ensure form data is correct
const form = frame.getByTestId('form');
await expect(form.getByTestId('avatar-image')).toHaveAttribute('src', 'https://example.com/avatar.jpg');
const replyForm = frame.getByTestId('reply-form');
await expect(replyForm.getByTestId('avatar-image')).toHaveAttribute('src', 'https://example.com/avatar.jpg');
// Should not include the replying-to-reply indicator
await expect(frame.getByTestId('replying-to')).not.toBeVisible();
@ -145,28 +145,8 @@ test.describe('Actions', async () => {
await expect(frame.getByText('This is a reply 123')).toHaveCount(1);
});
test('Reply-to-reply action not shown without labs flag', async ({
page
}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>',
replies: [
mockedApi.buildReply({
html: '<p>This is a reply to 1</p>'
})
]
});
const {frame} = await initializeTest(page);
const parentComment = frame.getByTestId('comment-component').nth(0);
const replyComment = parentComment.getByTestId('comment-component').nth(0);
expect(replyComment.getByTestId('reply-button')).not.toBeVisible();
});
async function testReplyToReply(page) {
const {frame} = await initializeTest(page, {labs: true});
const {frame} = await initializeTest(page);
const parentComment = frame.getByTestId('comment-component').nth(0);
const replyComment = parentComment.getByTestId('comment-component').nth(0);
@ -174,7 +154,7 @@ test.describe('Actions', async () => {
const replyReplyButton = replyComment.getByTestId('reply-button');
await replyReplyButton.click();
const editor = frame.getByTestId('form-editor').nth(1);
const editor = parentComment.getByTestId('form-editor');
await expect(editor).toBeVisible();
await waitEditorFocused(editor);
@ -200,10 +180,13 @@ test.describe('Actions', async () => {
// Should indicate this was a reply to a reply
await expect(frame.getByTestId('comment-in-reply-to')).toHaveText('This is a reply to 1');
return {frame};
}
test('Can reply to a reply', async ({page}) => {
mockedApi.addComment({
id: '1',
html: '<p>This is comment 1</p>',
replies: [
mockedApi.buildReply({
@ -229,6 +212,88 @@ test.describe('Actions', async () => {
await testReplyToReply(page);
});
test('Can highlight reply when clicking on reply to: snippet', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>',
replies: [
mockedApi.buildReply({
id: '2',
html: '<p>This is a reply to 1</p>'
}),
mockedApi.buildReply({
id: '3',
html: '<p>This is a reply to a reply</p>',
in_reply_to_id: '2',
in_reply_to_snippet: 'This is a reply to 1'
})
]
});
const {frame} = await initializeTest(page);
await frame.getByTestId('comment-in-reply-to').click();
// get the first reply which contains This is a reply to 1
const commentComponent = frame.getByTestId('comment-component').nth(1);
const replyComment = commentComponent.getByTestId('comment-content').nth(0);
// check that replyComment contains the text This is a reply to 1
await expect(replyComment).toHaveText('This is a reply to 1');
const markElement = await replyComment.locator('mark');
await expect(markElement).toBeVisible();
// Check that the mark element has the expected classes
await expect(markElement).toHaveClass(/animate-\[highlight_2\.5s_ease-out\]/);
await expect(markElement).toHaveClass(/\[animation-delay:1s\]/);
await expect(markElement).toHaveClass(/bg-yellow-300\/40/);
await expect(markElement).toHaveClass(/dark:bg-yellow-500\/40/);
});
test('Reply highlight disappears after a bit', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>',
replies: [
mockedApi.buildReply({
id: '2',
html: '<p>This is a reply to 1</p>'
}),
mockedApi.buildReply({
id: '3',
html: '<p>This is a reply to a reply</p>',
in_reply_to_id: '2',
in_reply_to_snippet: 'This is a reply to 1'
})
]
});
const {frame} = await initializeTest(page);
await frame.getByTestId('comment-in-reply-to').click();
// get the first reply which contains This is a reply to 1
const commentComponent = frame.getByTestId('comment-component').nth(1);
const replyComment = commentComponent.getByTestId('comment-content').nth(0);
// check that replyComment contains the text This is a reply to 1
await expect(replyComment).toHaveText('This is a reply to 1');
const markElement = await replyComment.locator('mark');
await expect(markElement).toBeVisible();
// Check that the mark element has the expected classes
await expect(markElement).toHaveClass(/animate-\[highlight_2\.5s_ease-out\]/);
await expect(markElement).toHaveClass(/\[animation-delay:1s\]/);
await expect(markElement).toHaveClass(/bg-yellow-300\/40/);
await expect(markElement).toHaveClass(/dark:bg-yellow-500\/40/);
const timeout = 3000;
await page.waitForTimeout(timeout);
await expect(markElement).not.toBeVisible();
});
test('Can add expertise', async ({page}) => {
mockedApi.setMember({name: 'John Doe', expertise: null});
@ -276,7 +341,115 @@ test.describe('Actions', async () => {
);
});
test.describe('Sorting - flag needs to be enabled', () => {
async function deleteComment(page, frame, commentComponent) {
await commentComponent.getByTestId('more-button').first().click();
await frame.getByTestId('delete').click();
const popupIframe = page.frameLocator('iframe[title="deletePopup"]');
await popupIframe.getByTestId('delete-popup-confirm').click();
}
test('Can delete a comment', async ({page}) => {
const loggedInMember = buildMember();
mockedApi.setMember(loggedInMember);
mockedApi.addComment({
html: '<p>This is comment 1</p>',
member: loggedInMember
});
const {frame} = await initializeTest(page);
const commentToDelete = frame.getByTestId('comment-component').nth(0);
await deleteComment(page, frame, commentToDelete);
await expect(frame.getByTestId('comment-component')).toHaveCount(0);
});
test('Can delete a reply', async ({page}) => {
const loggedInMember = buildMember();
mockedApi.setMember(loggedInMember);
mockedApi.addComment({
html: '<p>This is comment 1</p>',
replies: [
mockedApi.buildReply({
html: '<p>This is a reply</p>',
member: loggedInMember
})
]
});
const {frame} = await initializeTest(page);
const comment = frame.getByTestId('comment-component').nth(0);
const replyToDelete = comment.getByTestId('comment-component').nth(0);
await deleteComment(page, frame, replyToDelete);
await expect(frame.getByTestId('comment-component')).toHaveCount(1);
await expect(frame.getByTestId('replies-line')).not.toBeVisible();
});
test('Deleting a reply updates pagination', async ({page}) => {
const loggedInMember = buildMember();
mockedApi.setMember(loggedInMember);
mockedApi.addComment({
html: '<p>Parent comment</p>',
// 6 replies
replies: Array.from({length: 6}, (_, i) => buildReply({member: loggedInMember, html: `<p>Reply ${i + 1}</p>`}))
});
const {frame} = await initializeTest(page);
await expect(frame.getByTestId('replies-pagination')).toContainText('3');
const replyToDelete = frame.getByTestId('comment-component').nth(2);
await deleteComment(page, frame, replyToDelete);
// Replies count does not change - we still have 3 unloaded replies
await expect(frame.getByTestId('replies-pagination')).toContainText('3');
});
test('Can delete a comment with replies', async ({page}) => {
const loggedInMember = buildMember();
mockedApi.setMember(loggedInMember);
mockedApi.addComment({
html: '<p>This is comment 1</p>',
member: loggedInMember,
replies: [
mockedApi.buildReply({
html: '<p>This is a reply</p>'
})
]
});
const {frame} = await initializeTest(page);
const commentToDelete = frame.getByTestId('comment-component').nth(0);
await deleteComment(page, frame, commentToDelete);
await expect(frame.getByTestId('comment-component')).toHaveCount(2);
await expect(frame.getByText('This comment has been removed')).toBeVisible();
await expect(frame.getByTestId('replies-line')).toBeVisible();
});
test('Resets comments list after deleting a top-level comment', async ({page}) => {
const loggedInMember = buildMember();
mockedApi.setMember(loggedInMember);
// We have a page limit of 20, this will show the load more button
mockedApi.addComments(21, {member: loggedInMember});
const {frame} = await initializeTest(page);
await expect(frame.getByTestId('pagination-component')).toBeVisible();
const commentToDelete = frame.getByTestId('comment-component').nth(0);
await deleteComment(page, frame, commentToDelete);
// more button should have disappeared because the list was reloaded
await expect(frame.getByTestId('pagination-component')).not.toBeVisible();
});
test.describe('Sorting', () => {
test('Renders Sorting Form dropdown', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>'
@ -300,7 +473,7 @@ test.describe('Actions', async () => {
html: '<p>This is comment 6</p>'
});
const {frame} = await initializeTest(page, {labs: true});
const {frame} = await initializeTest(page);
const sortingForm = frame.getByTestId('comments-sorting-form');
@ -330,7 +503,7 @@ test.describe('Actions', async () => {
created_at: new Date('2022-02-01T00:00:00Z')
});
const {frame} = await initializeTest(page, {labs: true});
const {frame} = await initializeTest(page);
const sortingForm = frame.getByTestId('comments-sorting-form');
@ -367,7 +540,7 @@ test.describe('Actions', async () => {
html: '<p>This is comment 6</p>'
});
const {frame} = await initializeTest(page, {labs: true});
const {frame} = await initializeTest(page);
const sortingForm = frame.getByTestId('comments-sorting-form');
@ -404,7 +577,7 @@ test.describe('Actions', async () => {
created_at: new Date('2024-04-03T00:00:00Z')
});
const {frame} = await initializeTest(page, {labs: true});
const {frame} = await initializeTest(page);
const sortingForm = await frame.getByTestId('comments-sorting-form');
@ -414,15 +587,65 @@ test.describe('Actions', async () => {
'comments-sorting-form-dropdown'
);
const newestOption = await sortingDropdown.getByText('Newest');
await newestOption.click();
const optionSelect = await sortingDropdown.getByText('Newest');
mockedApi.setDelay(100);
await optionSelect.click();
const commentsElement = await frame.getByTestId('comment-elements');
const hasOpacity50 = await commentsElement.evaluate(el => el.classList.contains('opacity-50'));
expect(hasOpacity50).toBe(true);
const comments = await frame.getByTestId('comment-component');
await expect(comments.nth(0)).toContainText('This is the newest comment');
const hasNoOpacity50 = await commentsElement.evaluate(el => el.classList.contains('opacity-50'));
expect(hasNoOpacity50).toBe(false);
});
test('Sorts by oldest', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 2</p>',
created_at: new Date('2024-03-02T00:00:00Z'),
liked: true,
count: {
likes: 52
}
});
mockedApi.addComment({
html: '<p>This is the oldest</p>',
created_at: new Date('2024-02-01T00:00:00Z')
});
mockedApi.addComment({
html: '<p>This is the newest comment</p>',
created_at: new Date('2024-04-03T00:00:00Z')
});
const {frame} = await initializeTest(page);
const sortingForm = await frame.getByTestId('comments-sorting-form');
await sortingForm.click();
const sortingDropdown = await frame.getByTestId(
'comments-sorting-form-dropdown'
);
const optionSelect = await sortingDropdown.getByText('Oldest');
mockedApi.setDelay(100);
await optionSelect.click();
const commentsElement = await frame.getByTestId('comment-elements');
const hasOpacity50 = await commentsElement.evaluate(el => el.classList.contains('opacity-50'));
expect(hasOpacity50).toBe(true);
const comments = await frame.getByTestId('comment-component');
await expect(comments.nth(0)).toContainText('This is the oldest');
const hasNoOpacity50 = await commentsElement.evaluate(el => el.classList.contains('opacity-50'));
expect(hasNoOpacity50).toBe(false);
});
test('has loading state when changing sorting', async ({page}) => {
mockedApi.addComment({
html: '<p>This is the oldest</p>',
created_at: new Date('2024-02-01T00:00:00Z')
@ -436,7 +659,7 @@ test.describe('Actions', async () => {
created_at: new Date('2024-04-03T00:00:00Z')
});
const {frame} = await initializeTest(page, {labs: true});
const {frame} = await initializeTest(page);
const sortingForm = await frame.getByTestId('comments-sorting-form');
@ -446,12 +669,65 @@ test.describe('Actions', async () => {
'comments-sorting-form-dropdown'
);
const newestOption = await sortingDropdown.getByText('Oldest');
await newestOption.click();
const optionSelect = await sortingDropdown.getByText('Newest');
mockedApi.setDelay(100);
await optionSelect.click();
const commentsElement = await frame.getByTestId('comment-elements');
const hasOpacity50 = await commentsElement.evaluate(el => el.classList.contains('opacity-50'));
expect(hasOpacity50).toBe(true);
const comments = await frame.getByTestId('comment-component');
await expect(comments.nth(0)).toContainText('This is the oldest');
await expect(comments.nth(0)).toContainText('This is the newest comment');
const hasNoOpacity50 = await commentsElement.evaluate(el => el.classList.contains('opacity-50'));
expect(hasNoOpacity50).toBe(false);
});
});
test('Can edit their own comment', async ({page}) => {
const loggedInMember = buildMember();
mockedApi.setMember(loggedInMember);
// Add a comment with replies
mockedApi.addComment({
html: '<p>Parent comment</p>',
member: loggedInMember,
replies: [
mockedApi.buildReply({
html: '<p>First reply</p>'
}),
mockedApi.buildReply({
html: '<p>Second reply</p>'
})
]
});
const {frame} = await initializeTest(page);
// Get the parent comment and verify initial state
const parentComment = frame.getByTestId('comment-component').nth(0);
const replies = await parentComment.getByTestId('comment-component').all();
// Verify initial state shows parent and replies
await expect(parentComment).toContainText('Parent comment');
await expect(replies[0]).toBeVisible();
await expect(replies[0]).toContainText('First reply');
await expect(replies[1]).toBeVisible();
await expect(replies[1]).toContainText('Second reply');
// Open edit mode for parent comment
const moreButton = parentComment.getByTestId('more-button').first();
await moreButton.click();
await frame.getByTestId('edit').click();
// Verify the edit form is visible
await expect(parentComment.getByTestId('form-editor')).toBeVisible();
// Verify replies are still visible while editing
await expect(replies[0]).toBeVisible();
await expect(replies[0]).toContainText('First reply');
await expect(replies[1]).toBeVisible();
await expect(replies[1]).toContainText('Second reply');
});
});

View file

@ -18,8 +18,7 @@ test.describe('Admin moderation', async () => {
member?: any; // eslint-disable-line @typescript-eslint/no-explicit-any
};
async function initializeTest(page, options: InitializeTestOptions = {}) {
options = {isAdmin: true, labs: false, member: {id: '1'}, ...options};
options = {isAdmin: true, labs: false, member: {id: '1', uuid: '12345'}, ...options};
if (options.isAdmin) {
await mockAdminAuthFrame({page, admin});
} else {
@ -29,16 +28,18 @@ test.describe('Admin moderation', async () => {
mockedApi.setMember(options.member);
if (options.labs) {
mockedApi.setLabs({commentImprovements: true});
// enable specific labs flags here
}
return await initialize({
mockedApi,
page,
publication: 'Publisher Weekly',
title: 'Member discussion',
count: true,
admin,
labs: {
commentImprovements: options.labs
// enable specific labs flags here
}
});
}
@ -100,102 +101,216 @@ test.describe('Admin moderation', async () => {
await expect(frame.getByTestId('hide-button')).toBeVisible();
});
test('can hide and show comments', async ({page}) => {
test('member uuid are passed to admin browse api params', async ({page}) => {
mockedApi.addComment({html: '<p>This is comment 1</p>'});
mockedApi.addComment({html: '<p>This is comment 2</p>'});
const adminBrowseSpy = sinon.spy(mockedApi.adminRequestHandlers, 'browseComments');
const {frame} = await initializeTest(page);
const comments = await frame.getByTestId('comment-component');
await expect(comments).toHaveCount(1);
expect(adminBrowseSpy.called).toBe(true);
const lastCall = adminBrowseSpy.lastCall.args[0];
const url = new URL(lastCall.request().url());
expect(url.searchParams.get('impersonate_member_uuid')).toBe('12345');
});
test('member uuid gets set when loading more comments', async ({page}) => {
// create 25 comments
for (let i = 0; i < 25; i++) {
mockedApi.addComment({html: `<p>This is comment ${i}</p>`});
}
const adminBrowseSpy = sinon.spy(mockedApi.adminRequestHandlers, 'browseComments');
const {frame} = await initializeTest(page);
await frame.getByTestId('pagination-component').click();
const lastCall = adminBrowseSpy.lastCall.args[0];
const url = new URL(lastCall.request().url());
expect(url.searchParams.get('impersonate_member_uuid')).toBe('12345');
});
test('member uuid gets set when changing order', async ({page}) => {
mockedApi.addComment({
html: '<p>This is the oldest</p>',
created_at: new Date('2024-02-01T00:00:00Z')
});
mockedApi.addComment({
html: '<p>This is comment 2</p>',
created_at: new Date('2024-03-02T00:00:00Z')
});
mockedApi.addComment({
html: '<p>This is the newest comment</p>',
created_at: new Date('2024-04-03T00:00:00Z')
});
const adminBrowseSpy = sinon.spy(mockedApi.adminRequestHandlers, 'browseComments');
const {frame} = await initializeTest(page);
// Click the hide button for 2nd comment
const sortingForm = await frame.getByTestId('comments-sorting-form');
await sortingForm.click();
const sortingDropdown = await frame.getByTestId(
'comments-sorting-form-dropdown'
);
const optionSelect = await sortingDropdown.getByText('Newest');
mockedApi.setDelay(100);
await optionSelect.click();
const lastCall = adminBrowseSpy.lastCall.args[0];
const url = new URL(lastCall.request().url());
expect(url.searchParams.get('impersonate_member_uuid')).toBe('12345');
});
test('member uuid gets set when loading more replies', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>',
replies: [
buildReply({html: '<p>This is reply 1</p>'}),
buildReply({html: '<p>This is reply 2</p>'}),
buildReply({html: '<p>This is reply 3</p>'}),
buildReply({html: '<p>This is reply 4</p>'}),
buildReply({html: '<p>This is reply 5</p>'}),
buildReply({html: '<p>This is reply 6</p>'})
]
});
const adminBrowseSpy = sinon.spy(mockedApi.adminRequestHandlers, 'getReplies');
const {frame} = await initializeTest(page);
const comments = await frame.getByTestId('comment-component');
const comment = comments.nth(0);
await comment.getByTestId('reply-pagination-button').click();
const lastCall = adminBrowseSpy.lastCall.args[0];
const url = new URL(lastCall.request().url());
expect(url.searchParams.get('impersonate_member_uuid')).toBe('12345');
});
test('member uuid gets set when reading a comment (after unhiding)', async ({page}) => {
mockedApi.addComment({html: '<p>This is comment 1</p>'});
mockedApi.addComment({html: '<p>This is comment 2</p>', status: 'hidden'});
const adminReadSpy = sinon.spy(mockedApi.adminRequestHandlers, 'getOrUpdateComment');
const {frame} = await initializeTest(page);
const comments = await frame.getByTestId('comment-component');
await expect(comments).toHaveCount(2);
await expect(comments.nth(1)).toContainText('Hidden for members');
const moreButtons = comments.nth(1).getByTestId('more-button');
await moreButtons.click();
await moreButtons.getByTestId('show-button').click();
await expect(comments.nth(1)).not.toContainText('Hidden for members');
const lastCall = adminReadSpy.lastCall.args[0];
const url = new URL(lastCall.request().url());
expect(url.searchParams.get('impersonate_member_uuid')).toBe('12345');
});
test('hidden comments are not displayed for non-admins', async ({page}) => {
mockedApi.addComment({html: '<p>This is comment 1</p>'});
mockedApi.addComment({html: '<p>This is comment 2</p>', status: 'hidden'});
const adminBrowseSpy = sinon.spy(mockedApi.adminRequestHandlers, 'browseComments');
const {frame} = await initializeTest(page, {isAdmin: false});
const comments = await frame.getByTestId('comment-component');
await expect(comments).toHaveCount(1);
expect(adminBrowseSpy.called).toBe(false);
});
test('hidden comments are displayed for admins', async ({page}) => {
mockedApi.addComment({html: '<p>This is comment 1</p>'});
mockedApi.addComment({html: '<p>This is comment 2</p>', status: 'hidden'});
const adminBrowseSpy = sinon.spy(mockedApi.adminRequestHandlers, 'browseComments');
const {frame} = await initializeTest(page);
const comments = await frame.getByTestId('comment-component');
await expect(comments).toHaveCount(2);
await expect(comments.nth(1)).toContainText('Hidden for members');
expect(adminBrowseSpy.called).toBe(true);
});
test('can hide and show comments', async ({page}) => {
[1,2].forEach(i => mockedApi.addComment({html: `<p>This is comment ${i}</p>`}));
const {frame} = await initializeTest(page);
const comments = await frame.getByTestId('comment-component');
// Hide the 2nd comment
const moreButtons = frame.getByTestId('more-button');
await moreButtons.nth(1).click();
await moreButtons.nth(1).getByTestId('hide-button').click();
await moreButtons.nth(1).getByText('Hide comment').click();
// comment becomes hidden
const comments = frame.getByTestId('comment-component');
const secondComment = comments.nth(1);
await expect(secondComment).toContainText('This comment has been hidden.');
await expect(secondComment).not.toContainText('This is comment 2');
await expect(secondComment).toContainText('Hidden for members');
// can show it again
// Check can show it again
await moreButtons.nth(1).click();
await moreButtons.nth(1).getByTestId('show-button').click();
await moreButtons.nth(1).getByText('Show comment').click();
await expect(secondComment).toContainText('This is comment 2');
});
test.describe('commentImprovements', function () {
test('hidden comments are not displayed for non-admins', async ({page}) => {
mockedApi.addComment({html: '<p>This is comment 1</p>'});
mockedApi.addComment({html: '<p>This is comment 2</p>', status: 'hidden'});
const adminBrowseSpy = sinon.spy(mockedApi.adminRequestHandlers, 'browseComments');
const {frame} = await initializeTest(page, {isAdmin: false, labs: true});
const comments = await frame.getByTestId('comment-component');
await expect(comments).toHaveCount(1);
expect(adminBrowseSpy.called).toBe(false);
test('can hide and show replies', async ({page}) => {
mockedApi.addComment({
id: '1',
html: '<p>This is comment 1</p>',
replies: [
buildReply({id: '2', html: '<p>This is reply 1</p>'}),
buildReply({id: '3', html: '<p>This is reply 2</p>'})
]
});
test('hidden comments are displayed for admins', async ({page}) => {
mockedApi.addComment({html: '<p>This is comment 1</p>'});
mockedApi.addComment({html: '<p>This is comment 2</p>', status: 'hidden'});
const {frame} = await initializeTest(page);
const comments = await frame.getByTestId('comment-component');
const replyToHide = comments.nth(1);
const adminBrowseSpy = sinon.spy(mockedApi.adminRequestHandlers, 'browseComments');
// Hide the 1st reply
await replyToHide.getByTestId('more-button').click();
await replyToHide.getByTestId('hide-button').click();
const {frame} = await initializeTest(page, {labs: true});
const comments = await frame.getByTestId('comment-component');
await expect(comments).toHaveCount(2);
await expect(comments.nth(1)).toContainText('Hidden for members');
await expect(replyToHide).toContainText('Hidden for members');
expect(adminBrowseSpy.called).toBe(true);
// Show it again
await replyToHide.getByTestId('more-button').click();
await replyToHide.getByTestId('show-button').click();
await expect(replyToHide).not.toContainText('Hidden for members');
});
test('updates in-reply-to snippets when hiding', async ({page}) => {
mockedApi.addComment({
id: '1',
html: '<p>This is comment 1</p>',
replies: [
buildReply({id: '2', html: '<p>This is reply 1</p>'}),
buildReply({id: '3', html: '<p>This is reply 2</p>', in_reply_to_id: '2', in_reply_to_snippet: 'This is reply 1'}),
buildReply({id: '4', html: '<p>This is reply 3</p>'})
]
});
test('can hide and show comments', async ({page}) => {
[1,2].forEach(i => mockedApi.addComment({html: `<p>This is comment ${i}</p>`}));
const {frame} = await initializeTest(page);
const comments = await frame.getByTestId('comment-component');
const replyToHide = comments.nth(1);
const inReplyToComment = comments.nth(2);
const {frame} = await initializeTest(page, {labs: true});
const comments = await frame.getByTestId('comment-component');
// Hide the 1st reply
await replyToHide.getByTestId('more-button').click();
await replyToHide.getByTestId('hide-button').click();
// Hide the 2nd comment
const moreButtons = frame.getByTestId('more-button');
await moreButtons.nth(1).click();
await moreButtons.nth(1).getByText('Hide comment').click();
await expect(inReplyToComment).toContainText('[removed]');
await expect(inReplyToComment).not.toContainText('This is reply 1');
const secondComment = comments.nth(1);
await expect(secondComment).toContainText('Hidden for members');
// Show it again
await replyToHide.getByTestId('more-button').click();
await replyToHide.getByTestId('show-button').click();
// Check can show it again
await moreButtons.nth(1).click();
await moreButtons.nth(1).getByText('Show comment').click();
await expect(secondComment).toContainText('This is comment 2');
});
await expect(inReplyToComment).not.toContainText('[removed]');
await expect(inReplyToComment).toContainText('This is reply 1');
});
test('can hide and show replies', async ({page}) => {
mockedApi.addComment({
id: '1',
html: '<p>This is comment 1</p>',
replies: [
buildReply({id: '2', html: '<p>This is reply 1</p>'}),
buildReply({id: '3', html: '<p>This is reply 2</p>'})
]
});
test('has correct comments count', async ({page}) => {
mockedApi.addComment({html: '<p>This is comment 1</p>', replies: [buildReply()]});
mockedApi.addComment({html: '<p>This is comment 2</p>'});
const {frame} = await initializeTest(page, {labs: true});
const comments = await frame.getByTestId('comment-component');
const replyToHide = comments.nth(1);
// Hide the 1st reply
await replyToHide.getByTestId('more-button').click();
await replyToHide.getByTestId('hide-button').click();
await expect(replyToHide).toContainText('Hidden for members');
// Show it again
await replyToHide.getByTestId('more-button').click();
await replyToHide.getByTestId('show-button').click();
await expect(replyToHide).not.toContainText('Hidden for members');
});
const {frame} = await initializeTest(page);
await expect(frame.getByTestId('count')).toContainText('3 comments');
});
});

View file

@ -26,9 +26,7 @@ test.describe('Autoclose forms', async () => {
mockedApi,
page,
publication: 'Publisher weekly',
labs: {
commentImprovements: true
}
labs: {}
}));
});

View file

@ -2,7 +2,8 @@ import {MockedApi, initialize} from '../utils/e2e';
import {expect, test} from '@playwright/test';
test.describe('Deleted and Hidden Content', async () => {
// This is actually handled by the API since it shouldn not longer return hidden or deleted comments for non-admins, but we still test the behaviour here.
// This is actually handled by the API since it should no longer return hidden
// or deleted comments for non-admins, but we still test the behaviour here.
test('hides hidden and deleted comments for non admins', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.addComment({
@ -27,9 +28,7 @@ test.describe('Deleted and Hidden Content', async () => {
mockedApi,
page,
publication: 'Publisher Weekly',
labs: {
commentImprovements: true
}
labs: {}
});
const iframeElement = await page.locator('iframe[data-frame="admin-auth"]');
@ -41,7 +40,7 @@ test.describe('Deleted and Hidden Content', async () => {
await expect(comments).toHaveCount(3);
});
test('hide and deleted comment shows with hidden/deleted text when it has replies', async ({page}) => {
test('hidden and deleted comment shows with removed text when it has replies', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.addComment({
html: '<p>This is comment 1</p>'
@ -94,9 +93,7 @@ test.describe('Deleted and Hidden Content', async () => {
mockedApi,
page,
publication: 'Publisher Weekly',
labs: {
commentImprovements: true
}
labs: {}
});
await expect (frame.getByText('This is comment 2')).not.toBeVisible();
@ -106,7 +103,7 @@ test.describe('Deleted and Hidden Content', async () => {
await expect (frame.getByText('This comment has been removed')).toBeVisible();
});
test('hides replies thats hidden and deleted', async ({page}) => {
test('hides replies that are hidden or deleted', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.addComment({
html: '<p>This is comment 2</p>',
@ -130,9 +127,7 @@ test.describe('Deleted and Hidden Content', async () => {
mockedApi,
page,
publication: 'Publisher Weekly',
labs: {
commentImprovements: true
}
labs: {}
});
await expect (frame.getByText('This is reply 1')).toBeVisible();

View file

@ -19,9 +19,9 @@ test.describe('CTA', async () => {
await expect(ctaBox).toContainText('Become a member of Publisher Weekly to start commenting');
await expect(ctaBox).toContainText('Sign in');
// Does not show the reply buttons if not logged in
// Show the reply buttons if not logged in
const replyButton = frame.getByTestId('reply-button');
await expect(replyButton).toHaveCount(0);
await expect(replyButton).toHaveCount(2);
// Does not show the main form
const form = frame.getByTestId('form');
@ -66,9 +66,9 @@ test.describe('CTA', async () => {
// Don't show sign in button
await expect(ctaBox).not.toContainText('Sign in');
// No replies or comments possible
// Shows replies buttons
const replyButton = frame.getByTestId('reply-button');
await expect(replyButton).toHaveCount(0);
await expect(replyButton).toHaveCount(2);
const form = frame.getByTestId('form');
await expect(form).toHaveCount(0);

View file

@ -17,6 +17,7 @@ test.describe('Editor', async () => {
const placeholderElement = editor.locator('[data-placeholder="Start the conversation"]');
await expect(placeholderElement).toBeVisible();
});
test('Can comment on a post', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.setMember({});

View file

@ -0,0 +1,48 @@
import {MockedApi, initialize} from '../utils/e2e';
import {buildMember} from '../utils/fixtures';
import {expect, test} from '@playwright/test';
test.describe('Main form', async () => {
let mockedApi: MockedApi;
async function initializeTest(page, options = {}) {
mockedApi = new MockedApi({});
mockedApi.setMember(buildMember({}));
return await initialize({
mockedApi,
page,
publication: 'Publisher Weekly',
...options
});
}
test('hides header by default', async ({page}) => {
const {frame} = await initializeTest(page);
await expect(frame.locator('[data-testid="main-form"]')).toBeVisible();
await expect(frame.locator('[data-testid="main-form"] [data-testid="form-header"]')).not.toBeVisible();
});
test('shows header when focused', async ({page}) => {
const {frame} = await initializeTest(page);
await frame.locator('[data-testid="main-form"] [data-testid="form-editor"]').click();
await expect(frame.locator('[data-testid="main-form"] [data-testid="form-header"]')).toBeVisible();
});
test('hides header when blurred', async ({page}) => {
const {frame} = await initializeTest(page);
await frame.locator('[data-testid="main-form"] [data-testid="form-editor"]').click();
await expect(frame.locator('[data-testid="main-form"] [data-testid="form-header"]')).toBeVisible();
await page.locator('body').click();
await expect(frame.locator('[data-testid="main-form"] [data-testid="form-header"]')).not.toBeVisible();
});
test('keeps showing header when blurred with unpublished changes', async ({page}) => {
const {frame} = await initializeTest(page);
await frame.locator('[data-testid="main-form"] [data-testid="form-editor"]').click();
await expect(frame.locator('[data-testid="main-form"] [data-testid="form-header"]')).toBeVisible();
await frame.locator('[data-testid="main-form"] [data-testid="form-editor"]').pressSequentially('Some text');
await page.locator('body').click();
await expect(frame.locator('[data-testid="main-form"] [data-testid="form-header"]')).toBeVisible();
});
});

View file

@ -193,7 +193,15 @@ test.describe('Options', async () => {
test('Uses 100 avatarSaturation', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.addComment();
mockedApi.addComment({
member: {
id: 'test-id',
uuid: 'test-uuid',
name: 'Test User',
avatar: '',
expertise: ''
}
});
const {frame} = await initialize({
mockedApi,

View file

@ -16,21 +16,22 @@ test.describe('Pagination', async () => {
await expect(frame.getByTestId('pagination-component')).toBeVisible();
// Check text in pagination button
await expect(frame.getByTestId('pagination-component')).toContainText('Show 1 previous comment');
await expect(frame.getByTestId('pagination-component')).toContainText('Load more (1)');
// Test total comments with test-id comment-component is 5
await expect(frame.getByTestId('comment-component')).toHaveCount(20);
// Check only the first latest 20 comments are visible
await expect(frame.getByText('This is comment 1.')).not.toBeVisible();
await expect(frame.getByText('This is comment 1.')).toBeVisible();
await expect(frame.getByText('This is comment 2.')).toBeVisible();
await expect(frame.getByText('This is comment 3.')).toBeVisible();
await expect(frame.getByText('This is comment 4.')).toBeVisible();
await expect(frame.getByText('This is comment 5.')).toBeVisible();
await expect(frame.getByText('This is comment 6.')).toBeVisible();
await expect(frame.getByText('This is comment 20.')).toBeVisible();
await expect(frame.getByText('This is comment 21.')).not.toBeVisible();
//
//
// Click the pagination button
await frame.getByTestId('pagination-component').click();
@ -39,7 +40,7 @@ test.describe('Pagination', async () => {
await expect(frame.getByTestId('comment-component')).toHaveCount(21);
// Check comments 6 is visible
await expect(frame.getByText('This is comment 1.')).toBeVisible();
await expect(frame.getByText('This is comment 21.')).toBeVisible();
// Check the pagination button is not visible
await expect(frame.getByTestId('pagination-component')).not.toBeVisible();

Some files were not shown because too many files have changed in this diff Show more