Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: add ability to copy comment link #981

Merged
merged 1 commit into from
Sep 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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": "Failed to copy comment link, please copy page URL",
"showpost.comment.copylink.success": "Comment link copied to clipboard",
"showpost.comment.unknownhighlighted": "Invalid 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)
}
}
Loading