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:
commit
b8963c9db6
566 changed files with 11847 additions and 7792 deletions
2
.github/scripts/dev.js
vendored
2
.github/scripts/dev.js
vendored
|
@ -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: {}
|
||||
|
|
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
|
@ -103,6 +103,7 @@ jobs:
|
|||
admin-x-settings:
|
||||
- *shared
|
||||
- 'apps/admin-x-settings/**'
|
||||
- 'apps/admin-x-design-system/**'
|
||||
announcement-bar:
|
||||
- *shared
|
||||
- 'apps/announcement-bar/**'
|
||||
|
|
1
.github/workflows/migration-review.yml
vendored
1
.github/workflows/migration-review.yml
vendored
|
@ -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)
|
||||
|
|
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
|
@ -21,7 +21,7 @@ jobs:
|
|||
If we’ve missed reviewing your PR & you’re 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'
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@tryghost/admin-x-activitypub",
|
||||
"version": "0.3.24",
|
||||
"version": "0.3.38",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -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
|
@ -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);
|
||||
|
|
|
@ -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'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>
|
||||
|
|
|
@ -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'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 />}
|
||||
|
|
|
@ -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'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'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'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'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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
|
@ -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
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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')} />
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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+/)
|
||||
|
|
11
apps/admin-x-activitypub/src/utils/handle-profile-click.ts
Normal file
11
apps/admin-x-activitypub/src/utils/handle-profile-click.ts
Normal 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)
|
||||
});
|
||||
};
|
5
apps/admin-x-activitypub/src/utils/truncate.ts
Normal file
5
apps/admin-x-activitypub/src/utils/truncate.ts
Normal 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;
|
|
@ -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');
|
||||
});
|
||||
});
|
9
apps/admin-x-activitypub/test/acceptance/inbox.test.ts
Normal file
9
apps/admin-x-activitypub/test/acceptance/inbox.test.ts
Normal 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');
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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",
|
||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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>}
|
||||
|
|
|
@ -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 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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/`,
|
||||
|
|
|
@ -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 |
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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;
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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>). Don’t 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 →</a></>}
|
||||
title='Custom fonts' />
|
||||
<LabItem
|
||||
action={<div className='flex flex-col items-end gap-1'>
|
||||
<FileUpload
|
||||
|
|
|
@ -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']
|
||||
};
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
15
apps/admin-x-settings/src/hooks/useCustomFonts.tsx
Normal file
15
apps/admin-x-settings/src/hooks/useCustomFonts.tsx
Normal 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;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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('/');
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {};
|
||||
}
|
||||
};
|
||||
|
||||
|
|
36
apps/comments-ui/src/actions.test.js
Normal file
36
apps/comments-ui/src/actions.test.js
Normal 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'}
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 {};
|
||||
}
|
||||
|
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>: <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>: <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}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
32
apps/comments-ui/src/components/content/Pagination.test.jsx
Normal file
32
apps/comments-ui/src/components/content/Pagination.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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'}`}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import {useAppContext} from '../../../AppContext';
|
||||
import {Comment, useAppContext} from '../../../AppContext';
|
||||
|
||||
type Props = {
|
||||
comment: Comment;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 |
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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()})
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
74
apps/comments-ui/src/utils/hooks.test.tsx
Normal file
74
apps/comments-ui/src/utils/hooks.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
|
|
@ -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'}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -26,9 +26,7 @@ test.describe('Autoclose forms', async () => {
|
|||
mockedApi,
|
||||
page,
|
||||
publication: 'Publisher weekly',
|
||||
labs: {
|
||||
commentImprovements: true
|
||||
}
|
||||
labs: {}
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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({});
|
||||
|
|
48
apps/comments-ui/test/e2e/main-form.test.ts
Normal file
48
apps/comments-ui/test/e2e/main-form.test.ts
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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
Loading…
Add table
Reference in a new issue