Skip to content

Commit

Permalink
feature: add ability to copy comment link
Browse files Browse the repository at this point in the history
  • Loading branch information
indyteo committed Sep 11, 2024
1 parent 85ce362 commit 4877d1c
Show file tree
Hide file tree
Showing 5 changed files with 161 additions and 51 deletions.
4 changes: 4 additions & 0 deletions locale/en/client.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"action.change": "change",
"action.close": "Close",
"action.confirm": "Confirm",
"action.copylink": "Copy link",
"action.delete": "Delete",
"action.edit": "Edit",
"action.markallasread": "Mark All as Read",
Expand Down Expand Up @@ -127,6 +128,9 @@
"page.pendingactivation.text": "We sent you a confirmation email with a link to activate your site.",
"page.pendingactivation.text2": "Please check your inbox to activate it.",
"page.pendingactivation.title": "Your account is pending activation",
"showpost.comment.copylink.error": "Could not copy comment link, please copy page URL",
"showpost.comment.copylink.success": "Successfully copied comment link to clipboard",
"showpost.comment.unknownhighlighted": "Unknown comment ID #{id}",
"showpost.commentinput.placeholder": "Leave a comment",
"showpost.discussionpanel.emptymessage": "No one has commented yet.",
"showpost.label.author": "Posted by <0/> · <1/>",
Expand Down
41 changes: 39 additions & 2 deletions public/pages/ShowPost/ShowPost.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import "./ShowPost.page.scss"
import React from "react"

import { Comment, Post, Tag, Vote, ImageUpload, CurrentUser } from "@fider/models"
import { actions, Failure, Fider, timeAgo } from "@fider/services"
import { actions, clearUrlHash, Failure, Fider, notify, timeAgo } from "@fider/services"

import {
VoteCounter,
Expand Down Expand Up @@ -48,6 +48,7 @@ interface ShowPostPageState {
newTitle: string
attachments: ImageUpload[]
newDescription: string
highlightedComment?: number
error?: Failure
}

Expand All @@ -72,6 +73,15 @@ export default class ShowPostPage extends React.Component<ShowPostPageProps, Sho
}
}

public componentDidMount() {
this.handleHashChange()
window.addEventListener("hashchange", this.handleHashChange)
}

public componentWillUnmount() {
window.removeEventListener("hashchange", this.handleHashChange)
}

private saveChanges = async () => {
const result = await actions.updatePost(this.props.post.number, this.state.newTitle, this.state.newDescription, this.state.attachments)
if (result.ok) {
Expand Down Expand Up @@ -103,6 +113,33 @@ export default class ShowPostPage extends React.Component<ShowPostPageProps, Sho
this.setState({ editMode: true })
}

private handleHashChange = (e?: Event) => {
const hash = window.location.hash
const result = /#comment-([0-9]+)/.exec(hash)

let highlightedComment
if (result === null) {
// No match
highlightedComment = undefined
} else {
// Match, extract numeric ID
const id = parseInt(result[1])
if (this.props.comments.map((comment) => comment.id).includes(id)) {
highlightedComment = id
} else {
// Unknown comment
if (e?.cancelable) {
e.preventDefault()
} else {
clearUrlHash(true)
}
notify.error(<Trans id="showpost.comment.unknownhighlighted">Unknown comment ID #{id}</Trans>)
highlightedComment = undefined
}
}
this.setState({ highlightedComment })
}

public render() {
return (
<>
Expand Down Expand Up @@ -201,7 +238,7 @@ export default class ShowPostPage extends React.Component<ShowPostPageProps, Sho
</VStack>

<div className="p-show-post__discussion_col">
<DiscussionPanel post={this.props.post} comments={this.props.comments} />
<DiscussionPanel post={this.props.post} comments={this.props.comments} highlightedComment={this.state.highlightedComment} />
</div>
</VStack>
</div>
Expand Down
3 changes: 2 additions & 1 deletion public/pages/ShowPost/components/DiscussionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface DiscussionPanelProps {
user?: CurrentUser
post: Post
comments: Comment[]
highlightedComment?: number
}

export const DiscussionPanel = (props: DiscussionPanelProps) => {
Expand All @@ -22,7 +23,7 @@ export const DiscussionPanel = (props: DiscussionPanelProps) => {
</span>
<VStack spacing={4} className="c-comment-list">
{props.comments.map((c) => (
<ShowComment key={c.id} post={props.post} comment={c} />
<ShowComment key={c.id} post={props.post} comment={c} highlighted={props.highlightedComment === c.id} />
))}
<CommentInput post={props.post} />
</VStack>
Expand Down
135 changes: 87 additions & 48 deletions public/pages/ShowPost/components/ShowComment.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,42 @@
import React, { useState } from "react"
import React, { useEffect, useRef, useState } from "react"
import { Comment, Post, ImageUpload } from "@fider/models"
import { Avatar, UserName, Moment, Form, TextArea, Button, Markdown, Modal, ImageViewer, MultiImageUploader, Dropdown, Icon } from "@fider/components"
import { HStack } from "@fider/components/layout"
import { formatDate, Failure, actions } from "@fider/services"
import { formatDate, Failure, actions, notify, copyToClipboard, classSet, clearUrlHash } from "@fider/services"
import { useFider } from "@fider/hooks"
import IconDotsHorizontal from "@fider/assets/images/heroicons-dots-horizontal.svg"
import { Trans } from "@lingui/macro"
import { t, Trans } from "@lingui/macro"

interface ShowCommentProps {
post: Post
comment: Comment
highlighted?: boolean
}

export const ShowComment = (props: ShowCommentProps) => {
const fider = useFider()
const node = useRef<HTMLDivElement | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [newContent, setNewContent] = useState("")
const [isDeleteConfirmationModalOpen, setIsDeleteConfirmationModalOpen] = useState(false)
const [attachments, setAttachments] = useState<ImageUpload[]>([])
const [error, setError] = useState<Failure>()

const handleClick = (e: MouseEvent) => {
if (node.current == null || !node.current.contains(e.target as Node)) {
clearUrlHash()
}
}

useEffect(() => {
if (props.highlighted) {
document.addEventListener("mousedown", handleClick)
return () => {
document.removeEventListener("mousedown", handleClick)
}
}
}, [props.highlighted])

const canEditComment = (): boolean => {
if (fider.session.isAuthenticated) {
return fider.session.user.isCollaborator || props.comment.user.id === fider.session.user.id
Expand Down Expand Up @@ -56,7 +73,13 @@ export const ShowComment = (props: ShowCommentProps) => {
}

const onActionSelected = (action: string) => () => {
if (action === "edit") {
if (action === "copylink") {
window.location.hash = `#comment-${props.comment.id}`
copyToClipboard(window.location.href).then(
() => notify.success(t({ id: "showpost.comment.copylink.success", message: "Successfully copied comment link to clipboard" })),
() => notify.error(t({ id: "showpost.comment.copylink.error", message: "Could not copy comment link, please copy page URL" }))
)
} else if (action === "edit") {
setIsEditing(true)
setNewContent(props.comment.content)
clearError()
Expand Down Expand Up @@ -97,53 +120,69 @@ export const ShowComment = (props: ShowCommentProps) => {
<span data-tooltip={`This comment has been edited by ${comment.editedBy.name} on ${formatDate(fider.currentLocale, comment.editedAt)}`}>· edited</span>
)

const classList = classSet({
"flex-grow rounded-md p-2": true,
"bg-gray-50": !props.highlighted,
"bg-gray-200": props.highlighted,
})

return (
<HStack spacing={2} center={false} className="c-comment flex-items-baseline">
{modal()}
<div className="pt-4">
<Avatar user={comment.user} />
</div>
<div className="flex-grow bg-gray-50 rounded-md p-2">
<div className="mb-1">
<HStack justify="between">
<HStack>
<UserName user={comment.user} />{" "}
<div className="text-xs">
· <Moment locale={fider.currentLocale} date={comment.createdAt} /> {editedMetadata}
</div>
<div id={`comment-${comment.id}`}>
<HStack spacing={2} center={false} className="c-comment flex-items-baseline">
{modal()}
<div className="pt-4">
<Avatar user={comment.user} />
</div>
<div ref={node} className={classList}>
<div className="mb-1">
<HStack justify="between">
<HStack>
<UserName user={comment.user} />{" "}
<div className="text-xs">
· <Moment locale={fider.currentLocale} date={comment.createdAt} /> {editedMetadata}
</div>
</HStack>
{!isEditing && (
<Dropdown position="left" renderHandle={<Icon sprite={IconDotsHorizontal} width="16" height="16" />}>
<Dropdown.ListItem onClick={onActionSelected("copylink")}>
<Trans id="action.copylink">Copy link</Trans>
</Dropdown.ListItem>
{canEditComment() && (
<>
<Dropdown.Divider />
<Dropdown.ListItem onClick={onActionSelected("edit")}>
<Trans id="action.edit">Edit</Trans>
</Dropdown.ListItem>
<Dropdown.ListItem onClick={onActionSelected("delete")} className="text-red-700">
<Trans id="action.delete">Delete</Trans>
</Dropdown.ListItem>
</>
)}
</Dropdown>
)}
</HStack>
{!isEditing && canEditComment() && (
<Dropdown position="left" renderHandle={<Icon sprite={IconDotsHorizontal} width="16" height="16" />}>
<Dropdown.ListItem onClick={onActionSelected("edit")}>
<Trans id="action.edit">Edit</Trans>
</Dropdown.ListItem>
<Dropdown.ListItem onClick={onActionSelected("delete")} className="text-red-700">
<Trans id="action.delete">Delete</Trans>
</Dropdown.ListItem>
</Dropdown>
</div>
<div>
{isEditing ? (
<Form error={error}>
<TextArea field="content" minRows={1} value={newContent} placeholder={comment.content} onChange={setNewContent} />
<MultiImageUploader field="attachments" bkeys={comment.attachments} maxUploads={2} onChange={setAttachments} />
<Button size="small" onClick={saveEdit} variant="primary">
<Trans id="action.save">Save</Trans>
</Button>
<Button variant="tertiary" size="small" onClick={cancelEdit}>
<Trans id="action.cancel">Cancel</Trans>
</Button>
</Form>
) : (
<>
<Markdown text={comment.content} style="full" />
{comment.attachments && comment.attachments.map((x) => <ImageViewer key={x} bkey={x} />)}
</>
)}
</HStack>
</div>
<div>
{isEditing ? (
<Form error={error}>
<TextArea field="content" minRows={1} value={newContent} placeholder={comment.content} onChange={setNewContent} />
<MultiImageUploader field="attachments" bkeys={comment.attachments} maxUploads={2} onChange={setAttachments} />
<Button size="small" onClick={saveEdit} variant="primary">
<Trans id="action.save">Save</Trans>
</Button>
<Button variant="tertiary" size="small" onClick={cancelEdit}>
<Trans id="action.cancel">Cancel</Trans>
</Button>
</Form>
) : (
<>
<Markdown text={comment.content} style="full" />
{comment.attachments && comment.attachments.map((x) => <ImageViewer key={x} bkey={x} />)}
</>
)}
</div>
</div>
</div>
</HStack>
</HStack>
</div>
)
}
29 changes: 29 additions & 0 deletions public/services/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,32 @@ export const truncate = (input: string, maxLength: number): string => {
export type StringObject<T = any> = {
[key: string]: T
}

export const copyToClipboard = (text: string): Promise<void> => {
if (window.navigator && window.navigator.clipboard && window.navigator.clipboard.writeText) {
return window.navigator.clipboard.writeText(text)
}
return Promise.reject(new Error("Clipboard API not available"))
}

export const clearUrlHash = (replace?: boolean) => {
const oldURL = window.location.href
const newURL = window.location.pathname + window.location.search
if (replace) {
window.history.replaceState("", document.title, newURL)
} else {
window.history.pushState("", document.title, newURL)
}
// Trigger event manually
const hashChangeEvent = new HashChangeEvent("hashchange", {
oldURL,
newURL,
cancelable: true,
bubbles: true,
composed: false,
})
if (!window.dispatchEvent(hashChangeEvent)) {
// Event got cancelled
window.history.replaceState("", document.title, oldURL)
}
}

0 comments on commit 4877d1c

Please sign in to comment.