Skip to content

Commit

Permalink
Merge pull request #1633 from FreeFeed/checklist
Browse files Browse the repository at this point in the history
Show beautiful & interactive checkboxes at the start of text
  • Loading branch information
davidmz authored Jul 31, 2023
2 parents 3a2048c + 3ae9d69 commit 1cb26e7
Show file tree
Hide file tree
Showing 13 changed files with 376 additions and 157 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.122] - Not released
### Added
- Comments can now be used as checklists if the comment text starts with "[ ]"
or "[x]" (one can use other symbols in parentheses as well: [v], [*], [🗸], or
Russian "Ha").

If the current user matches the author of the comment, an interactive checkbox
is displayed at the beginning of the text. When the user clicks the checkbox,
the comment text changes to reflect the checkbox state.

For other users, a fixed-width text representation of the checkbox is shown:
"[ ]" or "[🗸]".
### Fixed
- Telegram previews now supports dark theme

Expand Down
8 changes: 7 additions & 1 deletion src/components/button-link.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ export function useKeyboardEvents(onClick) {
return useMemo(
() => ({
onClick: (event) => {
event.currentTarget.blur();
/**
* This line is crashes in jsdom environment with message
* [TypeError: Failed to execute 'contains' on 'Node': parameter 1 is not of type 'Node'.]
*/
if (process.env.NODE_ENV !== 'test') {
event.currentTarget.blur();
}
onClick(event);
},
onKeyDown: (event) => {
Expand Down
67 changes: 67 additions & 0 deletions src/components/initial-checkbox.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import cn from 'classnames';
import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveEditingComment } from '../redux/action-creators';
import { initialAsyncState } from '../redux/async-helpers';
import { checkMark, isChecked, setCheckState } from '../utils/initial-checkbox';
import style from './initial-checkbox.module.scss';
import { Throbber } from './throbber';
import { useComment } from './post/post-comment-ctx';

export function InitialCheckbox({ checked }) {
const comment = useComment();
const myId = useSelector((state) => state.user.id);

const isActive = myId && comment?.createdBy === myId;

return (
<>
{isActive ? (
<ActiveCheckbox comment={comment} checked={checked} />
) : (
<span className={style.textBox}>
[
<span aria-hidden={!checked} className={cn(!checked && style.hidden)}>
{checkMark}
</span>
]
</span>
)}{' '}
</>
);
}

function ActiveCheckbox({ comment, checked }) {
const updateStatus = useSelector(
(state) => state.commentEditState[comment?.id]?.saveStatus ?? initialAsyncState,
);
const dispatch = useDispatch();
const onClick = useCallback(
(e) => {
if (e.clientX !== 0) {
// True mouse click (not keyboard)
e.target.blur();
}
const newState = e.target.checked;
if (isChecked(comment.body) === newState) {
return;
}
const updatedBody = setCheckState(comment.body, newState);
dispatch(saveEditingComment(comment?.id, updatedBody));
},
[comment?.body, comment?.id, dispatch],
);

return (
<span className={style.box}>
{updateStatus.loading && <Throbber className={style.throbber} delay={0} />}
<input
type="checkbox"
readOnly
checked={checked}
className={cn(style.chk, updateStatus.loading && style.hidden)}
onClick={onClick}
/>
</span>
);
}
31 changes: 31 additions & 0 deletions src/components/initial-checkbox.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
input[type='checkbox'].chk {
margin: 0;
width: 1.1em;
height: 1.1em;
cursor: pointer;

&:focus-visible {
outline-offset: 2px;
}
}

.box {
position: relative;
margin-inline-end: 0.15em;
vertical-align: middle;
}

.throbber {
position: absolute;
top: 50%;
left: 50%;
translate: -50% -50%;
}

.hidden {
visibility: hidden;
}

.textBox {
white-space: nowrap;
}
17 changes: 13 additions & 4 deletions src/components/linkify.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import classnames from 'classnames';
import { Arrows, Link as TLink, parseText, shortCodeToService } from '../utils/parse-text';
import { highlightString } from '../utils/search-highlighter';
import { FRIENDFEED_POST } from '../utils/link-types';
import { InitialCheckbox as InitialCheckboxToken } from '../utils/initial-checkbox';
import { getMediaType } from './media-viewer';

import { Icon } from './fontawesome-icons';
import UserName from './user-name';
import ErrorBoundary from './error-boundary';
import { InitialCheckbox } from './initial-checkbox';

const MAX_URL_LENGTH = 50;
const { searchEngine } = CONFIG.search;
Expand Down Expand Up @@ -62,6 +64,7 @@ export default function Linkify({
* @returns {import('react').ReactNode[]}
*/
function processStrings(children, processor, excludeTags = [], params = {}) {
params.throughIndex = params.throughIndex ?? -1;
if (typeof children === 'string') {
return processor(children, params);
} else if (isValidElement(children) && !excludeTags.includes(children.type)) {
Expand All @@ -76,13 +79,14 @@ function processStrings(children, processor, excludeTags = [], params = {}) {
return children;
}

function parseString(text, { userHover, arrowHover, arrowClick, showMedia, attachmentsRef }) {
function parseString(text, params) {
if (text === '') {
return [];
}

return parseText(text).map((token, i) => {
const key = i;
const { userHover, arrowHover, arrowClick, showMedia, attachmentsRef } = params;
return parseText(text).map((token) => {
params.throughIndex++;
const key = params.throughIndex;

const anchorEl = anchorElWithKey(key);
const linkEl = linkElWithKey(key);
Expand Down Expand Up @@ -173,6 +177,11 @@ function parseString(text, { userHover, arrowHover, arrowClick, showMedia, attac
}
}

// Render checkbox only at first token
if (params.throughIndex === 0 && token instanceof InitialCheckboxToken) {
return <InitialCheckbox key="comment-checkbox" checked={token.checked} />;
}

return token.text;
});
}
Expand Down
2 changes: 2 additions & 0 deletions src/components/piece-of-text.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ const splitIntoSpoilerBlocks = (input) => {
spoilerText = '';
} else if (isInSpoiler) {
spoilerText += token.text;
} else if (typeof newNodes[newNodes.length - 1] === 'string') {
newNodes[newNodes.length - 1] += token.text;
} else {
newNodes.push(token.text);
}
Expand Down
22 changes: 22 additions & 0 deletions src/components/post/post-comment-ctx.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createContext, useContext } from 'react';
import { useSelector } from 'react-redux';

/**
* @type {import('react').Context<string|null>}
*/
export const commentIdContext = createContext(null);

export function useComment() {
const id = useContext(commentIdContext);
return useSelector((state) => state.comments[id] ?? null);
}

/**
* @type {import('react').Context<string|null>}
*/
export const postIdContext = createContext(null);

export function usePost() {
const id = useContext(postIdContext);
return useSelector((state) => state.posts[id] ?? null);
}
5 changes: 3 additions & 2 deletions src/components/post/post-comment-preview.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import TimeDisplay from '../time-display';
import UserName from '../user-name';

import styles from './post-comment-preview.module.scss';
import { CommentProvider } from './post-comment-provider';

export function PostCommentPreview({
postId,
Expand Down Expand Up @@ -152,7 +153,7 @@ export function PostCommentPreview({
style={style}
>
{comment ? (
<>
<CommentProvider id={comment.id}>
{comment.hideType ? (
<span className={styles['hidden-text']}>{commentBody}</span>
) : (
Expand All @@ -175,7 +176,7 @@ export function PostCommentPreview({
Go to comment
</Link>
</div>
</>
</CommentProvider>
) : getCommentStatus.error ? (
<div className={styles['error']}>Error: {getCommentStatus.errorText}</div>
) : (
Expand Down
9 changes: 9 additions & 0 deletions src/components/post/post-comment-provider.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { commentIdContext, postIdContext } from './post-comment-ctx';

export function CommentProvider({ id, children }) {
return <commentIdContext.Provider value={id}>{children}</commentIdContext.Provider>;
}

export function PostProvider({ id, children }) {
return <postIdContext.Provider value={id}>{children}</postIdContext.Provider>;
}
61 changes: 32 additions & 29 deletions src/components/post/post-comment.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { TranslatedText } from '../translated-text';
import { initialAsyncState } from '../../redux/async-helpers';
import { PostCommentMore } from './post-comment-more';
import { PostCommentPreview } from './post-comment-preview';
import { CommentProvider } from './post-comment-provider';

class PostComment extends Component {
commentContainer;
Expand Down Expand Up @@ -283,35 +284,37 @@ class PostComment extends Component {

return (
<div className="comment-body">
<Expandable
expanded={
this.props.readMoreStyle === READMORE_STYLE_COMPACT ||
this.props.isSinglePost ||
this.props.isExpanded ||
!this.props.translateStatus.initial
}
bonusInfo={commentTail}
config={commentReadmoreConfig}
>
<PieceOfText
text={this.props.body}
readMoreStyle={this.props.readMoreStyle}
highlightTerms={this.props.highlightTerms}
userHover={this.props.authorHighlightHandlers}
arrowHover={this.arrowHoverHandlers}
arrowClick={this.arrowClick}
showMedia={this.props.showMedia}
/>
<TranslatedText
type="comment"
id={this.props.id}
userHover={this.props.authorHighlightHandlers}
arrowHover={this.arrowHoverHandlers}
arrowClick={this.arrowClick}
showMedia={this.props.showMedia}
/>
{commentTail}
</Expandable>
<CommentProvider id={this.props.id}>
<Expandable
expanded={
this.props.readMoreStyle === READMORE_STYLE_COMPACT ||
this.props.isSinglePost ||
this.props.isExpanded ||
!this.props.translateStatus.initial
}
bonusInfo={commentTail}
config={commentReadmoreConfig}
>
<PieceOfText
text={this.props.body}
readMoreStyle={this.props.readMoreStyle}
highlightTerms={this.props.highlightTerms}
userHover={this.props.authorHighlightHandlers}
arrowHover={this.arrowHoverHandlers}
arrowClick={this.arrowClick}
showMedia={this.props.showMedia}
/>
<TranslatedText
type="comment"
id={this.props.id}
userHover={this.props.authorHighlightHandlers}
arrowHover={this.arrowHoverHandlers}
arrowClick={this.arrowClick}
showMedia={this.props.showMedia}
/>
{commentTail}
</Expandable>
</CommentProvider>
</div>
);
}
Expand Down
Loading

0 comments on commit 1cb26e7

Please sign in to comment.