Wired up Admin Comment Endpoints to UI (#21661)

ref PLG-227

- Updated logic that allows Admin Users on comments to interact with
some endpoints from a specific admin-only route.
- It pulls 2 admin specific routes:
- 1. an admin specific 'browse' route that includes hidden comments that
would otherwise be hidden from regular users and members.
- 2. A specific replies route, that would also include hidden comments
- This was needed in order to get accurate pagination.

- Additionally, it wires up the routes via `message-handler` that deal
with the potential cors issues.
- Includes style updates

---------

Co-authored-by: Sanne de Vries <sannedv@protonmail.com>
Co-authored-by: Kevin Ansfield <kevin@lookingsideways.co.uk>
This commit is contained in:
Ronald Langeveld 2024-11-21 18:52:36 +08:00 committed by GitHub
parent 703ed9dfbc
commit cf6884d098
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
71 changed files with 493 additions and 74 deletions

View File

@ -7,9 +7,9 @@ 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 {AdminApi, setupAdminAPI} from './utils/adminApi';
import {AppContext, DispatchActionType, EditableAppContext, LabsContextType} from './AppContext';
import {CommentsFrame} from './components/Frame';
import {setupAdminAPI} from './utils/adminApi';
import {useOptions} from './utils/options';
type AppProps = {
@ -28,7 +28,8 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
openCommentForms: [],
popup: null,
labs: {},
order: 'count__likes desc, created_at desc'
order: 'count__likes desc, created_at desc',
adminApi: null
});
const iframeRef = React.createRef<HTMLIFrameElement>();
@ -41,7 +42,7 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
});
}, [options]);
const [adminApi, setAdminApi] = useState<AdminApi|null>(null);
// const [adminApi, setAdminApi] = useState<AdminApi|null>(null);
const setState = useCallback((newState: Partial<EditableAppContext> | ((state: EditableAppContext) => Partial<EditableAppContext>)) => {
setFullState((state) => {
@ -61,7 +62,7 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
// because updates to state may be asynchronous
// so calling dispatchAction('counterUp') multiple times, may yield unexpected results if we don't use a callback function
setState((state) => {
return SyncActionHandler({action, data, state, api, adminApi: adminApi!, options});
return SyncActionHandler({action, data, state, api, adminApi: state.adminApi!, options});
});
return;
}
@ -70,14 +71,14 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
// without creating infinite rerenders because dispatchAction needs to change on every state change
// So state shouldn't be a dependency of dispatchAction
setState((state) => {
ActionHandler({action, data, state, api, adminApi: adminApi!, options}).then((updatedState) => {
ActionHandler({action, data, state, api, adminApi: state.adminApi!, options}).then((updatedState) => {
setState({...updatedState});
}).catch(console.error); // eslint-disable-line no-console
// No immediate changes
return {};
});
}, [api, adminApi, options]); // Do not add state or context as a dependency here -> infinite render loop
}, [api, options]); // Do not add state or context as a dependency here -> infinite render loop
const i18n = useMemo(() => {
return i18nLib(options.locale, 'comments');
@ -92,7 +93,7 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
};
const initAdminAuth = async () => {
if (adminApi || !options.adminUrl) {
if (state.adminApi || !options.adminUrl) {
return;
}
@ -100,11 +101,21 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
const adminApi = setupAdminAPI({
adminUrl: options.adminUrl
});
setAdminApi(adminApi);
let admin = null;
try {
admin = await adminApi.getUser();
if (admin && state.labs.commentImprovements) {
// 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});
setState({
...state,
adminApi: adminApi,
comments: adminComments.comments,
pagination: adminComments.meta.pagination,
commentCount: adminComments.meta.pagination.total
});
}
} catch (e) {
// Loading of admin failed. Could be not signed in, or a different error (not important)
// eslint-disable-next-line no-console
@ -112,6 +123,7 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
}
setState({
adminApi: adminApi,
admin
});
} catch (e) {

View File

@ -1,6 +1,7 @@
// Ref: https://reactjs.org/docs/context.html
import React, {useContext} from 'react';
import {ActionType, Actions, SyncActionType, SyncActions} from './actions';
import {AdminApi} from './utils/adminApi';
import {Page} from './pages';
export type Member = {
@ -79,7 +80,8 @@ export type EditableAppContext = {
openCommentForms: OpenCommentForm[],
popup: Page | null,
labs: LabsContextType,
order: string
order: string,
adminApi: AdminApi | null
}
export type TranslationFunction = (key: string, replacements?: Record<string, string | number>) => string;

View File

@ -8,7 +8,12 @@ async function loadMoreComments({state, api, options, order}: {state: EditableAp
if (state.pagination && state.pagination.page) {
page = state.pagination.page + 1;
}
const data = await api.comments.browse({page, postId: options.postId, order: order || state.order});
let data;
if (state.admin && state.adminApi && state.labs.commentImprovements) {
data = await state.adminApi.browse({page, postId: options.postId, order: order || state.order});
} else {
data = await api.comments.browse({page, postId: options.postId, order: order || state.order});
}
// Note: we store the comments from new to old, and show them in reverse order
return {
@ -17,8 +22,13 @@ async function loadMoreComments({state, api, options, order}: {state: EditableAp
};
}
async function setOrder({data: {order}, options, api}: {state: EditableAppContext, data: {order: string}, options: CommentsOptions, api: GhostApi}) {
const data = await api.comments.browse({page: 1, postId: options.postId, order: order});
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});
return {
comments: [...data.comments],
@ -27,8 +37,13 @@ async function setOrder({data: {order}, options, api}: {state: EditableAppContex
};
}
async function loadMoreReplies({state, api, data: {comment, limit}}: {state: EditableAppContext, api: GhostApi, data: {comment: any, limit?: number | 'all'}}): Promise<Partial<EditableAppContext>> {
const data = await api.comments.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit});
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});
} else {
data = await api.comments.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit});
}
// Note: we store the comments from new to old, and show them in reverse order
return {
@ -84,9 +99,10 @@ async function addReply({state, api, data: {reply, parent}}: {state: EditableApp
};
}
async function hideComment({state, adminApi, data: comment}: {state: EditableAppContext, adminApi: any, data: {id: string}}) {
await adminApi.hideComment(comment.id);
async function hideComment({state, data: comment}: {state: EditableAppContext, adminApi: any, data: {id: string}}) {
if (state.adminApi) {
await state.adminApi.hideComment(comment.id);
}
return {
comments: state.comments.map((c) => {
const replies = c.replies.map((r) => {
@ -117,9 +133,10 @@ async function hideComment({state, adminApi, data: comment}: {state: EditableApp
};
}
async function showComment({state, api, adminApi, data: comment}: {state: EditableAppContext, api: GhostApi, adminApi: any, data: {id: string}}) {
await adminApi.showComment(comment.id);
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);
}
// 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)
const data = await api.comments.read(comment.id);
@ -355,7 +372,8 @@ async function openCommentForm({data: newForm, api, state}: {data: OpenCommentFo
const topLevelCommentId = newForm.parent_id || newForm.id;
if (newForm.type === 'reply' && !state.openCommentForms.some(f => f.id === topLevelCommentId || f.parent_id === topLevelCommentId)) {
const comment = state.comments.find(c => c.id === topLevelCommentId);
const newCommentsState = await loadMoreReplies({state, api, data: {comment, limit: 'all'}});
// we don't want the admin api to load reply data for replying to a reply, so we pass isReply: true
const newCommentsState = await loadMoreReplies({state, api, data: {comment, limit: 'all'}, isReply: true});
otherStateChanges = {...otherStateChanges, ...newCommentsState};
}

View File

@ -19,8 +19,10 @@ export const BlankAvatar = () => {
type AvatarProps = {
comment?: Comment;
isHidden?: boolean;
};
export const Avatar: React.FC<AvatarProps> = ({comment}) => {
// #TODO greyscale the avatar image when it's hidden
const {member, avatarSaturation, t} = useAppContext();
const dimensionClasses = getDimensionClasses();

View File

@ -7,7 +7,7 @@ import ReplyForm from './forms/ReplyForm';
import {Avatar, BlankAvatar} from './Avatar';
import {Comment, OpenCommentForm, useAppContext, useLabs} from '../../AppContext';
import {Transition} from '@headlessui/react';
import {formatExplicitTime, getCommentInReplyToSnippet, getMemberNameFromComment, isCommentPublished} from '../../utils/helpers';
import {formatExplicitTime, getCommentInReplyToSnippet, getMemberNameFromComment} from '../../utils/helpers';
import {useCallback} from 'react';
import {useRelativeTime} from '../../utils/hooks';
@ -50,10 +50,34 @@ const EditableComment: React.FC<EditableCommentProps> = ({comment, parent}) => {
};
type CommentProps = AnimatedCommentProps;
export const CommentComponent: React.FC<CommentProps> = ({comment, parent}) => {
const {dispatchAction} = useAppContext();
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';
const isPublished = isCommentPublished(comment);
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 openEditMode = useCallback(() => {
const newForm: OpenCommentForm = {
@ -66,18 +90,27 @@ export const CommentComponent: React.FC<CommentProps> = ({comment, parent}) => {
dispatchAction('openCommentForm', newForm);
}, [comment.id, dispatchAction]);
if (isPublished) {
return (<PublishedComment comment={comment} openEditMode={openEditMode} parent={parent} />);
} else {
return (<UnpublishedComment comment={comment} openEditMode={openEditMode} />);
if (showDeletedMessage) {
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 PublishedCommentProps = CommentProps & {
openEditMode: () => void;
}
const PublishedComment: React.FC<PublishedCommentProps> = ({comment, parent, openEditMode}) => {
const {dispatchAction, openCommentForms} = useAppContext();
const {dispatchAction, openCommentForms, admin} = useAppContext();
const labs = useLabs();
// Determine if the comment should be displayed with reduced opacity
const isHidden = labs.commentImprovements && admin && comment.status === 'hidden';
const hiddenClass = isHidden ? 'opacity-30' : '';
// 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
@ -114,11 +147,16 @@ const PublishedComment: React.FC<PublishedCommentProps> = ({comment, parent, ope
const avatar = (<Avatar comment={comment} />);
return (
<CommentLayout avatar={avatar} hasReplies={hasReplies}>
<CommentHeader comment={comment} />
<CommentBody html={comment.html} />
<CommentMenu comment={comment} highlightReplyButton={highlightReplyButton} openEditMode={openEditMode} openReplyForm={openReplyForm} parent={parent} />
<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}
/>
<RepliesContainer comment={comment} />
{displayReplyForm && <ReplyFormBox comment={comment} openForm={openForm} />}
</CommentLayout>
@ -130,28 +168,34 @@ type UnpublishedCommentProps = {
openEditMode: () => void;
}
const UnpublishedComment: React.FC<UnpublishedCommentProps> = ({comment, openEditMode}) => {
const {t} = useAppContext();
let notPublishedMessage:string = '';
const {t, labs, admin} = useAppContext();
const avatar = (<BlankAvatar />);
const avatar = (labs.commentImprovements && admin && comment.status !== 'deleted') ?
<Avatar comment={comment} isHidden={true} /> :
<BlankAvatar />;
const hasReplies = comment.replies && comment.replies.length > 0;
if (comment.status === 'hidden') {
notPublishedMessage = t('This comment has been hidden.');
} else if (comment.status === 'deleted') {
notPublishedMessage = t('This comment has been removed.');
}
const notPublishedMessage = comment.status === 'hidden' ?
t('This comment has been hidden.') :
comment.status === 'deleted' ?
t('This comment has been removed.') :
'';
// Only show MoreButton for hidden (not deleted) comments when admin
const showMoreButton = admin && comment.status === 'hidden';
return (
<CommentLayout avatar={avatar} hasReplies={hasReplies}>
<div className="mt-[-3px] flex items-start">
<div className="flex h-10 flex-row items-center gap-4 pb-[8px] pr-4">
<p className="text-md mt-[4px] font-sans italic leading-normal text-black/20 sm:text-lg dark:text-white/35">
<p className="text-md mt-[4px] font-sans leading-normal text-neutral-900/40 sm:text-lg dark:text-white/60">
{notPublishedMessage}
</p>
<div className="mt-[4px]">
<MoreButton comment={comment} toggleEdit={openEditMode} />
</div>
{showMoreButton && (
<div className="mt-[4px]">
<MoreButton comment={comment} toggleEdit={openEditMode} />
</div>
)}
</div>
</div>
<RepliesContainer comment={comment} />
@ -185,8 +229,7 @@ const EditedInfo: React.FC<{comment: Comment}> = ({comment}) => {
</span>
);
};
const RepliesContainer: React.FC<RepliesProps> = ({comment}) => {
const RepliesContainer: React.FC<RepliesProps & {className?: string}> = ({comment, className = ''}) => {
const hasReplies = comment.replies && comment.replies.length > 0;
if (!hasReplies) {
@ -194,7 +237,7 @@ const RepliesContainer: React.FC<RepliesProps> = ({comment}) => {
}
return (
<div className="mb-4 ml-[-1.4rem] mt-7 sm:mb-0 sm:mt-8">
<div className={`mb-4 ml-[-1.4rem] mt-7 sm:mb-0 sm:mt-8 ${className}`}>
<Replies comment={comment} />
</div>
);
@ -226,7 +269,12 @@ const AuthorName: React.FC<{comment: Comment}> = ({comment}) => {
);
};
const CommentHeader: 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);
@ -249,7 +297,7 @@ const CommentHeader: React.FC<{comment: Comment}> = ({comment}) => {
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'}`}>
<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}`}>
<AuthorName comment={comment} />
<div className="flex items-baseline pr-4 font-sans text-base leading-snug text-neutral-900/50 sm:text-sm dark:text-white/60">
<span>
@ -268,10 +316,15 @@ const CommentHeader: React.FC<{comment: Comment}> = ({comment}) => {
);
};
const CommentBody: React.FC<{html: string}> = ({html}) => {
type CommentBodyProps = {
html: string;
className?: string;
}
const CommentBody: React.FC<CommentBodyProps> = ({html, className = ''}) => {
const dangerouslySetInnerHTML = {__html: html};
return (
<div className="mt mb-2 flex flex-row items-center gap-4 pr-4">
<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"/>
</div>
);
@ -283,26 +336,35 @@ type CommentMenuProps = {
highlightReplyButton: boolean;
openEditMode: () => void;
parent?: Comment;
className?: string;
};
const CommentMenu: React.FC<CommentMenuProps> = ({comment, openReplyForm, highlightReplyButton, openEditMode, parent}) => {
// If this comment is from the current member, always override member
// with the member from the context, so we update the expertise in existing comments when we change it
const {member, commentsEnabled} = useAppContext();
const CommentMenu: React.FC<CommentMenuProps> = ({comment, openReplyForm, highlightReplyButton, openEditMode, parent, className = ''}) => {
const {member, commentsEnabled, t, admin} = useAppContext();
const labs = useLabs();
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) {
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 ? (
<div className="flex items-center gap-4">
<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">
<div className={`flex items-center gap-4 ${className}`}>
{<LikeButton comment={comment} />}
{(canReply && <ReplyButton isReplying={highlightReplyButton} openReplyForm={openReplyForm} />)}
{<MoreButton comment={comment} toggleEdit={openEditMode} />}
@ -327,12 +389,13 @@ type CommentLayoutProps = {
children: React.ReactNode;
avatar: React.ReactNode;
hasReplies: boolean;
className?: string;
}
const CommentLayout: React.FC<CommentLayoutProps> = ({children, avatar, hasReplies}) => {
const CommentLayout: React.FC<CommentLayoutProps> = ({children, avatar, hasReplies, className = ''}) => {
return (
<div className={`flex w-full flex-row ${hasReplies === true ? 'mb-0' : 'mb-7'}`} 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">
<div className={`flex-0 mb-3 sm:mb-4 ${className}`}>
{avatar}
</div>
<RepliesLine hasReplies={hasReplies} />

View File

@ -6,6 +6,7 @@ type Props = {
isReplying: boolean;
openReplyForm: () => void;
};
const ReplyButton: React.FC<Props> = ({disabled, isReplying, openReplyForm}) => {
const {member, t, dispatchAction, commentsEnabled} = useAppContext();
const labs = useLabs();
@ -29,11 +30,11 @@ const ReplyButton: React.FC<Props> = ({disabled, isReplying, openReplyForm}) =>
}
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'}`}
data-testid="reply-button"
disabled={!!disabled}
type="button"
<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'}`}
data-testid="reply-button"
disabled={!!disabled}
type="button"
onClick={handleClick}
>
<ReplyIcon className={`mr-[6px] ${isReplying ? 'fill-black dark:fill-white' : 'stroke-black/50 group-hover:stroke-black/75 dark:stroke-white/60 dark:group-hover:stroke-white/75'} duration-50 transition ease-linear`} />

View File

@ -4,6 +4,8 @@ export function setupAdminAPI({adminUrl}: {adminUrl: string}) {
const handlers: Record<string, (error: Error|undefined, result: any) => void> = {};
const adminOrigin = new URL(adminUrl).origin;
let firstCommentCreatedAt: null | string = null;
window.addEventListener('message', function (event) {
if (event.origin !== adminOrigin) {
// Other message that is not intended for us
@ -59,6 +61,51 @@ export function setupAdminAPI({adminUrl}: {adminUrl: string}) {
},
async showComment(id: string) {
return await callApi('showComment', {id});
},
async browse({page, postId, order}: {page: number, postId: string, order?: string}) {
let filter = null;
if (firstCommentCreatedAt && !order) {
filter = `created_at:<=${firstCommentCreatedAt}`;
}
const params = new URLSearchParams();
params.set('limit', '20');
if (filter) {
params.set('filter', filter);
}
params.set('page', page.toString());
if (order) {
params.set('order', order);
}
const response = await callApi('browseComments', {postId, params: params.toString()});
if (!firstCommentCreatedAt) {
const firstComment = response.comments[0];
if (firstComment) {
firstCommentCreatedAt = firstComment.created_at;
}
}
return response;
},
async replies({commentId, afterReplyId, limit}: {commentId: string; afterReplyId: string; limit?: number | 'all'}) {
const filter = `id:>'${afterReplyId}'`;
const params = new URLSearchParams();
if (limit) {
params.set('limit', limit.toString());
}
if (filter) {
params.set('filter', filter);
}
const response = await callApi('getReplies', {commentId, params: params.toString()});
return response;
}
};

View File

@ -118,7 +118,7 @@ test.describe('Auth Frame', async () => {
// Check comment2 is replaced with a hidden message
const secondComment = comments.nth(1);
await expect(secondComment).toHaveText('This comment has been hidden.');
await expect(secondComment).toContainText('This comment has been hidden.');
await expect(secondComment).not.toContainText('This is comment 2');
// Check can show it again
@ -175,7 +175,7 @@ test.describe('Auth Frame', async () => {
// Check comment2 is replaced with a hidden message
const secondComment = comments.nth(1);
await expect(secondComment).toHaveText('This comment has been hidden.');
await expect(secondComment).toContainText('This comment has been hidden.');
await expect(secondComment).not.toContainText('This is comment 2');
// Check can show it again
@ -183,5 +183,62 @@ test.describe('Auth Frame', async () => {
await moreButtons.nth(1).getByText('Show comment').click();
await expect(secondComment).toContainText('This is comment 2');
});
test('Hidden comment is displayed for admins - needs flags enabled', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.addComment({
html: '<p>This is comment 1</p>'
});
mockedApi.addComment({
html: '<p>This is comment 2</p>'
});
mockedApi.addComment({
html: '<p>This is comment 3</p>'
});
mockedApi.addComment({
html: '<p>This is comment 4</p>'
});
mockedApi.addComment({
html: '<p>This is comment 5</p>'
});
await mockAdminAuthFrame({
admin,
page
});
const {frame} = await initialize({
mockedApi,
page,
publication: 'Publisher Weekly',
admin,
labs: {
commentImprovements: true
}
});
const iframeElement = await page.locator('iframe[data-frame="admin-auth"]');
await expect(iframeElement).toHaveCount(1);
// Check if more actions button is visible on each comment
const comments = await frame.getByTestId('comment-component');
await expect(comments).toHaveCount(5);
const moreButtons = await frame.getByTestId('more-button');
await expect(moreButtons).toHaveCount(5);
// Click the 2nd button
await moreButtons.nth(1).click();
await moreButtons.nth(1).getByText('Hide comment').click();
const secondComment = comments.nth(1);
// expect "hidden for members" message
await expect(secondComment).toContainText('Hidden for members');
// 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');
});
});

View File

@ -1,5 +1,6 @@
import {FrameLocator, expect, test} from '@playwright/test';
import {MockedApi, initialize, waitEditorFocused} from '../utils/e2e';
import {buildReply} from '../utils/fixtures';
test.describe('Autoclose forms', async () => {
let mockedApi: MockedApi;
@ -15,7 +16,7 @@ test.describe('Autoclose forms', async () => {
html: '<p>Reply 1.1</p>'
}, {
html: '<p>Reply 1.2</p>'
}]
}].map(buildReply)
});
mockedApi.addComment({
html: '<p>Comment 2</p>'
@ -36,7 +37,7 @@ test.describe('Autoclose forms', async () => {
test('autocloses open reply forms when opening another', async ({}) => {
const comment1 = await frame.getByTestId('comment-component').nth(0);
await comment1.getByTestId('reply-button').click();
await comment1.getByTestId('reply-button').nth(1).click();
await expect(frame.getByTestId('form')).toHaveCount(2);
@ -48,7 +49,7 @@ test.describe('Autoclose forms', async () => {
test('does not autoclose open reply form with unsaved changes', async ({}) => {
const comment1 = await frame.getByTestId('comment-component').nth(0);
await comment1.getByTestId('reply-button').click();
await comment1.getByTestId('reply-button').nth(1).click();
await expect(frame.getByTestId('form')).toHaveCount(2);

View File

@ -0,0 +1,144 @@
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.
test('hides hidden and deleted comments for non admins', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.addComment({
html: '<p>This is comment 1</p>'
});
mockedApi.addComment({
html: '<p>This is comment 2</p>',
status: 'hidden'
});
mockedApi.addComment({
html: '<p>This is comment 3</p>'
});
mockedApi.addComment({
html: '<p>This is comment 4</p>',
status: 'deleted'
});
mockedApi.addComment({
html: '<p>This is comment 5</p>'
});
const {frame} = await initialize({
mockedApi,
page,
publication: 'Publisher Weekly',
labs: {
commentImprovements: true
}
});
const iframeElement = await page.locator('iframe[data-frame="admin-auth"]');
await expect(iframeElement).toHaveCount(1);
// Check if more actions button is visible on each comment
const comments = await frame.getByTestId('comment-component');
// 3 comments are visible
await expect(comments).toHaveCount(3);
});
test('hide and deleted comment shows with hidden/deleted text when it has replies', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.addComment({
html: '<p>This is comment 1</p>'
});
mockedApi.addComment({
html: '<p>This is comment 2</p>',
status: 'hidden',
replies: [
mockedApi.buildReply({
html: '<p>This is reply 1</p>'
}),
mockedApi.buildReply({
html: '<p>This is reply 2</p>'
}),
mockedApi.buildReply({
html: '<p>This is reply 3</p>'
}),
mockedApi.buildReply({
html: '<p>This is reply 4</p>'
})
]
});
mockedApi.addComment({
html: '<p>This is comment 3</p>'
});
mockedApi.addComment({
html: '<p>This is comment 4</p>',
status: 'deleted',
replies: [
mockedApi.buildReply({
html: '<p>This is reply 1</p>'
}),
mockedApi.buildReply({
html: '<p>This is reply 2</p>'
}),
mockedApi.buildReply({
html: '<p>This is reply 3</p>'
}),
mockedApi.buildReply({
html: '<p>This is reply 4</p>'
})
]
});
mockedApi.addComment({
html: '<p>This is comment 5</p>'
});
const {frame} = await initialize({
mockedApi,
page,
publication: 'Publisher Weekly',
labs: {
commentImprovements: true
}
});
await expect (frame.getByText('This is comment 2')).not.toBeVisible();
await expect (frame.getByText('This comment has been hidden')).toBeVisible();
await expect (frame.getByText('This is comment 4')).not.toBeVisible();
await expect (frame.getByText('This comment has been removed')).toBeVisible();
});
test('hides replies thats hidden and deleted', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.addComment({
html: '<p>This is comment 2</p>',
status: 'hidden',
replies: [
mockedApi.buildReply({
html: '<p>This is reply 1</p>'
}),
mockedApi.buildReply({
html: '<p>This is reply 2</p>',
status: 'deleted'
}),
mockedApi.buildReply({
html: '<p>This is reply 3</p>',
status: 'hidden'
})
]
});
const {frame} = await initialize({
mockedApi,
page,
publication: 'Publisher Weekly',
labs: {
commentImprovements: true
}
});
await expect (frame.getByText('This is reply 1')).toBeVisible();
await expect (frame.getByText('This is reply 2')).not.toBeVisible();
// parent comment is hidden but shows text
await expect (frame.getByText('This comment has been hidden')).toBeVisible();
});
});

View File

@ -25,8 +25,22 @@ window.addEventListener('message', async function (event) {
if (data.action === 'browseComments') {
try {
const {postId, params} = data;
const res = await fetch(
adminUrl + '/comments/?limit=50&order=created_at%20desc'
adminUrl + `/comments/post/${postId}/?${new URLSearchParams(params).toString()}`
);
const json = await res.json();
respond(null, json);
} catch (err) {
respond(err, null);
}
}
if (data.action === 'getReplies') {
try {
const {commentId, params} = data;
const res = await fetch(
adminUrl + `/comments/${commentId}/replies/?${new URLSearchParams(params).toString()}`
);
const json = await res.json();
respond(null, json);

View File

@ -289,7 +289,6 @@ const Comment = ghostBookshelf.Model.extend({
.as('count__replies');
});
}
if (options.isAdmin && labs.isSet('commentImprovements')) {
modelOrCollection.query('columns', 'comments.*', (qb) => {
qb.count('replies.id')

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Stigter @ Acme Inc",
"Full-time parent": "Voltydse ouer",
"Head of Marketing at Acme, Inc": "Hoof van Bemarking by Acme, Inc",
"Hidden for members": "",
"Hide": "Versteek",
"Hide comment": "Versteek kommentaar",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "مؤسس لدى شركة أكمي",
"Full-time parent": "أب بدوام كامل",
"Head of Marketing at Acme, Inc": "رئيس وحدة التسويق لدى شركة أكمى",
"Hidden for members": "",
"Hide": "اخفاء",
"Hide comment": "اخف التعليق",
"Jamie Larson": "فلان الفلانى",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Основател на Компания ООД",
"Full-time parent": "Родител на пълно работно време",
"Head of Marketing at Acme, Inc": "Директор маркетинг в Компания ООД",
"Hidden for members": "",
"Hide": "Скриване",
"Hide comment": "Скриване на коментара",
"Jamie Larson": "Иван Иванов",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "প্রতিষ্ঠাতা @ বাংলাদেশ ট্রেড হাব",
"Full-time parent": "পূর্ণকালীন অভিভাবক",
"Head of Marketing at Acme, Inc": "মার্কেটিং প্রধান @ বাংলাদেশ ট্রেড হাব",
"Hidden for members": "",
"Hide": "লুকান",
"Hide comment": "মন্তব্য লুকান",
"Jamie Larson": "শাহ নেওয়াজ",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Acme Inc osnivač",
"Full-time parent": "Full time roditelj",
"Head of Marketing at Acme, Inc": "Šef marketinga u kompaniji Acme d.o.o",
"Hidden for members": "",
"Hide": "Sakrij",
"Hide comment": "Sakrij komentar",
"Jamie Larson": "Vanja Larsić",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Fundador @ Acme Inc",
"Full-time parent": "Pare a temps complert",
"Head of Marketing at Acme, Inc": "Cap de màrqueting a Acme, Inc",
"Hidden for members": "",
"Hide": "Amaga",
"Hide comment": "Amaga el comentari",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Zakladatel @ Acme Inc",
"Full-time parent": "Rodič na plný úvazek",
"Head of Marketing at Acme, Inc": "Vedoucí marketingu v Acme, Inc",
"Hidden for members": "",
"Hide": "Skrýt",
"Hide comment": "Skrýt komentář",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Grundlægger @ Acme Inc",
"Full-time parent": "Forældre på fuld tid",
"Head of Marketing at Acme, Inc": "Chef for marking hos Acme, Inc",
"Hidden for members": "",
"Hide": "Skjul",
"Hide comment": "Skjul kommentar",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "",
"Full-time parent": "",
"Head of Marketing at Acme, Inc": "",
"Hidden for members": "",
"Hide": "",
"Hide comment": "",
"Jamie Larson": "",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Gründer bei Acme Inc",
"Full-time parent": "Vollzeit-Elternteil",
"Head of Marketing at Acme, Inc": "Leiter Marketing bei Acme, Inc",
"Hidden for members": "",
"Hide": "Verbergen",
"Hide comment": "Kommentar verbergen",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Ιδρυτής @ Acme Inc",
"Full-time parent": "Γονέας πλήρους απασχόλησης",
"Head of Marketing at Acme, Inc": "Επικεφαλής Μάρκετινγκ στην Acme, Inc",
"Hidden for members": "",
"Hide": "Απόκρυψη",
"Hide comment": "Απόκρυψη σχολίου",
"Jamie Larson": "Τζέιμι Λάρσον",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "",
"Full-time parent": "",
"Head of Marketing at Acme, Inc": "",
"Hidden for members": "",
"Hide": "",
"Hide comment": "",
"Jamie Larson": "",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "",
"Full-time parent": "",
"Head of Marketing at Acme, Inc": "",
"Hidden for members": "",
"Hide": "",
"Hide comment": "",
"Jamie Larson": "",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Fundador @ Acme Inc",
"Full-time parent": "Padres de tiempo completo",
"Head of Marketing at Acme, Inc": "Jefe de Marketing en Acme, Inc",
"Hidden for members": "",
"Hide": "Ocultar",
"Hide comment": "Ocultar comentario",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Asutaja @ Acme Inc",
"Full-time parent": "Täiskohaga lapsevanem",
"Head of Marketing at Acme, Inc": "Turundusjuht Acme, Inc-s",
"Hidden for members": "",
"Hide": "Peida",
"Hide comment": "Peida kommentaar",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "مدیر عامل یک شرکت خیالی",
"Full-time parent": " خانه\u200cدار تمام وقت",
"Head of Marketing at Acme, Inc": "سرپرست بخش بازاریابی یک شرکت خیالی",
"Hidden for members": "",
"Hide": "مخفی کردن",
"Hide comment": "مخفی کردن دیدگاه",
"Jamie Larson": "جیمی لارسن",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "",
"Full-time parent": "Kokoaikainen vanhempi",
"Head of Marketing at Acme, Inc": "Markkinointijohtaja",
"Hidden for members": "",
"Hide": "Piilota",
"Hide comment": "Piilota kommentti",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Fondateur @ Acme Inc",
"Full-time parent": "Parent à plein temps",
"Head of Marketing at Acme, Inc": "Responsable du marketing chez Acme, Inc",
"Hidden for members": "",
"Hide": "Masquer",
"Hide comment": "Masquer le commentaire",
"Jamie Larson": "Jean Martin",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Neach-stèidheachaidh @ Acme Inc",
"Full-time parent": "Pàrant làn-ùine",
"Head of Marketing at Acme, Inc": "Àrd-cheann Margaidheachd aig Acme, Inc",
"Hidden for members": "",
"Hide": "Cuir am falach",
"Hide comment": "Cuir am beachd am falach",
"Jamie Larson": "Seamaidh Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "מייסד @ Acme Inc",
"Full-time parent": "הורה במשרה מלאה",
"Head of Marketing at Acme, Inc": "ראש אגף שיווק ב- Acme, Inc",
"Hidden for members": "",
"Hide": "הסתר",
"Hide comment": "הסתר תגובה",
"Jamie Larson": "ג׳יימי לרסון",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "संस्थापक @ Acme Inc",
"Full-time parent": "पूर्णकालिक माता-पिता",
"Head of Marketing at Acme, Inc": "Acme, Inc में विपणन प्रमुख",
"Hidden for members": "",
"Hide": "छिपाएं",
"Hide comment": "टिप्पणी छिपाएं",
"Jamie Larson": "राहुल शर्मा",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Osnivač Acme Inc",
"Full-time parent": "Full-time Roditelj",
"Head of Marketing at Acme, Inc": "Voditelj marketinga Acme, Inc",
"Hidden for members": "",
"Hide": "Sakrij",
"Hide comment": "Sakrij komentar",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Acme Kft. alapító",
"Full-time parent": "Főállású szülő",
"Head of Marketing at Acme, Inc": "Marketing vezető —\u00a0Acme Kft.",
"Hidden for members": "",
"Hide": "Elrejtés",
"Hide comment": "Hozzászólás elrejtése",
"Jamie Larson": "Kiss Sára",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Founder @ Acme Inc",
"Full-time parent": "Orang tua penuh waktu",
"Head of Marketing at Acme, Inc": "Direktur Pemasaran di Acme, Inc",
"Hidden for members": "",
"Hide": "Sembunyikan",
"Hide comment": "Sembunyikan komentar",
"Jamie Larson": "Sutan T. Alisjahbana",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "",
"Full-time parent": "",
"Head of Marketing at Acme, Inc": "",
"Hidden for members": "",
"Hide": "",
"Hide comment": "",
"Jamie Larson": "",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Megadirettore Galattico",
"Full-time parent": "Genitore a tempo pieno",
"Head of Marketing at Acme, Inc": "Ragioniere presso Megaditta",
"Hidden for members": "",
"Hide": "Nascondi",
"Hide comment": "Nascondi commento",
"Jamie Larson": "Andrea Rossi",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "創業者 @ Acme Inc",
"Full-time parent": "専業主婦・主夫",
"Head of Marketing at Acme, Inc": "マーケティング責任者 @ Acme, Inc",
"Hidden for members": "",
"Hide": "非表示にする",
"Hide comment": "コメントを非表示にする",
"Jamie Larson": "ジェイミー・ラーソン",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Acme Inc 창업자",
"Full-time parent": "전업 부모",
"Head of Marketing at Acme, Inc": "Acme Inc 마케팅 책임자",
"Hidden for members": "",
"Hide": "숨기기",
"Hide comment": "댓글 숨기기",
"Jamie Larson": "제이미 라슨",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "@ Acme Inc құрылтайшысы",
"Full-time parent": "Толық күн жұмыс істейтін ата-ана",
"Head of Marketing at Acme, Inc": "@ Acme маркетинг жетекшісі ",
"Hidden for members": "",
"Hide": "Жасыру",
"Hide comment": "Пікірді жасыру",
"Jamie Larson": "Пәленше Түгеншиев",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Istorikas",
"Full-time parent": "Vaiką prižiūrintis tėvas",
"Head of Marketing at Acme, Inc": "Tyrinėtojas",
"Hidden for members": "",
"Hide": "Paslėpti",
"Hide comment": "Paslėpti komentarą",
"Jamie Larson": "Vardenis Pavardenis",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Основач @ Примерна Компанија",
"Full-time parent": "Родител",
"Head of Marketing at Acme, Inc": "Раководител на Маркетинг во Примерна Компанија",
"Hidden for members": "",
"Hide": "Скријте",
"Hide comment": "Скријте го коментарот",
"Jamie Larson": "Петар Петровски",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "",
"Full-time parent": "",
"Head of Marketing at Acme, Inc": "",
"Hidden for members": "",
"Hide": "",
"Hide comment": "",
"Jamie Larson": "",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "",
"Full-time parent": "",
"Head of Marketing at Acme, Inc": "",
"Hidden for members": "",
"Hide": "",
"Hide comment": "",
"Jamie Larson": "",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "",
"Full-time parent": "",
"Head of Marketing at Acme, Inc": "",
"Hidden for members": "",
"Hide": "",
"Hide comment": "",
"Jamie Larson": "",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Oprichter, Acme BV",
"Full-time parent": "Full-time ouder",
"Head of Marketing at Acme, Inc": "Acme BV, Hoofd Marketing",
"Hidden for members": "",
"Hide": "Verbergen",
"Hide comment": "Verberg reactie",
"Jamie Larson": "Jan Jansen",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "",
"Full-time parent": "",
"Head of Marketing at Acme, Inc": "",
"Hidden for members": "",
"Hide": "",
"Hide comment": "",
"Jamie Larson": "",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Grunnlegger @ Acme Inc",
"Full-time parent": "Forelder på heltid",
"Head of Marketing at Acme, Inc": "Markedsføringsleder hos Acme Inc",
"Hidden for members": "",
"Hide": "Skjul",
"Hide comment": "Skjul kommentar",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Founder @ Acme Inc",
"Full-time parent": "",
"Head of Marketing at Acme, Inc": "Head of Marketing at Acme, Inc",
"Hidden for members": "",
"Hide": "Ukryj",
"Hide comment": "Ukryj komentarz",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Fundador @ Acme Inc",
"Full-time parent": "Pai/Mãe em tempo integral",
"Head of Marketing at Acme, Inc": "Chefe de Marketing na Acme, Inc",
"Hidden for members": "",
"Hide": "Esconder",
"Hide comment": "Esconder comentário",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Fundador @ Amce Inc",
"Full-time parent": "Pai a tempo inteiro",
"Head of Marketing at Acme, Inc": "Responsável do Marketing @ Amce, Inc",
"Hidden for members": "",
"Hide": "Esconder",
"Hide comment": "Esconder comentário",
"Jamie Larson": "Jane Doe",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Fondator @ Acme Inc",
"Full-time parent": "Părinte cu normă întreagă",
"Head of Marketing at Acme, Inc": "Șef de Marketing la Acme, Inc",
"Hidden for members": "",
"Hide": "Ascunde",
"Hide comment": "Ascunde comentariul",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Основатель @ Корпорация «Акме»",
"Full-time parent": "Родитель, работающий полный рабочий день",
"Head of Marketing at Acme, Inc": "Руководитель отдела маркетинга в Корпорации «Акме»",
"Hidden for members": "",
"Hide": "Скрыть",
"Hide comment": "Скрыть комментарий",
"Jamie Larson": "Павел Бид",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Acme Inc හි නිර්මාතෘ",
"Full-time parent": "පූර්ණ කාලීන භාරකරුව\u200bන්",
"Head of Marketing at Acme, Inc": "Acme, Inc හි අලෙවි ප්\u200dරධානී",
"Hidden for members": "",
"Hide": "සඟවන්න",
"Hide comment": "අදහස සඟවන්න",
"Jamie Larson": "ජේමි ලාර්සන්",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Zakladateľ @ Acme Inc",
"Full-time parent": "Rodič na plný úväzok",
"Head of Marketing at Acme, Inc": "Vedúci marketingu v spoločnosti Acme, Inc",
"Hidden for members": "",
"Hide": "Skryť",
"Hide comment": "Skryť komentár",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "",
"Full-time parent": "",
"Head of Marketing at Acme, Inc": "",
"Hidden for members": "",
"Hide": "",
"Hide comment": "",
"Jamie Larson": "",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "",
"Full-time parent": "",
"Head of Marketing at Acme, Inc": "",
"Hidden for members": "",
"Hide": "",
"Hide comment": "",
"Jamie Larson": "",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Оснивач @ Acme Inc",
"Full-time parent": "Родитељ пуним радним временом",
"Head of Marketing at Acme, Inc": "Шеф маркетинга у Acme, Inc",
"Hidden for members": "",
"Hide": "Сакриј",
"Hide comment": "Сакриј коментар",
"Jamie Larson": "Џејми Ларсон",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "",
"Full-time parent": "",
"Head of Marketing at Acme, Inc": "",
"Hidden for members": "",
"Hide": "",
"Hide comment": "",
"Jamie Larson": "",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Grundare av Företaget AB",
"Full-time parent": "Hemmaförälder",
"Head of Marketing at Acme, Inc": "Marknadsföringschef på Företaget AB",
"Hidden for members": "",
"Hide": "Dölj",
"Hide comment": "Dölj kommentar",
"Jamie Larson": "Anders Andersson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Mwanzilishi @ Acme Inc",
"Full-time parent": "Mzazi wa muda wote",
"Head of Marketing at Acme, Inc": "Mkuu wa Masoko katika Acme, Inc",
"Hidden for members": "",
"Hide": "Ficha",
"Hide comment": "Ficha maoni",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "நிறுவனர் @ Acme Inc",
"Full-time parent": "முழுநேர பெற்றோர்",
"Head of Marketing at Acme, Inc": "Acme Inc நிறுவனத்தின் சந்தைப்படுத்தல் தலைவர்",
"Hidden for members": "",
"Hide": "மறைக்கவும்",
"Hide comment": "கருத்தை மறை",
"Jamie Larson": "ஜேமி லார்சன்",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "ผู้ก่อตั้ง @ Acme Inc",
"Full-time parent": "ผู้ปกครองเต็มเวลา",
"Head of Marketing at Acme, Inc": "หัวหน้าฝ่ายการตลาด ณ Acme, Inc",
"Hidden for members": "",
"Hide": "ซ่อน",
"Hide comment": "ซ่อนข้อความ",
"Jamie Larson": "กนกพัฒน์ สัณห์ฤทัย",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Kurucu @ Firma Ünvan",
"Full-time parent": "Tam zamanlı ebeveyn",
"Head of Marketing at Acme, Inc": "Firma, Ünvan'da Pazarlama Müdürü",
"Hidden for members": "",
"Hide": "Gizle",
"Hide comment": "Yorumu gizle",
"Jamie Larson": "Ad Soyad",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Засновник @ Acme Inc",
"Full-time parent": "Виховую дітей",
"Head of Marketing at Acme, Inc": "Голова продажів в Acme, Inc",
"Hidden for members": "",
"Hide": "Сховати",
"Hide comment": "Сховати коментар",
"Jamie Larson": "Ваше імʼя",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "ناظم @ Acme Inc",
"Full-time parent": "پورے وقت کا والد یا والدہ",
"Head of Marketing at Acme, Inc": "Acme، Inc کے مارکیٹنگ کا سربراہ",
"Hidden for members": "",
"Hide": "چھپائیں",
"Hide comment": "تبادلہ چھپائیں",
"Jamie Larson": "جیمی لارسن",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "",
"Full-time parent": "",
"Head of Marketing at Acme, Inc": "",
"Hidden for members": "",
"Hide": "",
"Hide comment": "",
"Jamie Larson": "",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Nhà sáng lập @ Acme Inc",
"Full-time parent": "Phụ huynh toàn thời gian",
"Head of Marketing at Acme, Inc": "Trưởng phòng Marketing tại Acme, Inc",
"Hidden for members": "",
"Hide": "Ẩn",
"Hide comment": "Ẩn bình luận",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Acme Inc的創辦人",
"Full-time parent": "全職父母",
"Head of Marketing at Acme, Inc": "Acme, Inc市場部負責人",
"Hidden for members": "",
"Hide": "隱藏",
"Hide comment": "隱藏留言",
"Jamie Larson": "Jamie Larson",

View File

@ -26,6 +26,7 @@
"Founder @ Acme Inc": "Acme Inc的创建者",
"Full-time parent": "全职父母",
"Head of Marketing at Acme, Inc": "Acme, Inc市场部负责人",
"Hidden for members": "",
"Hide": "隐藏",
"Hide comment": "隐藏评论",
"Jamie Larson": "Jamie Larson",