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

Issue #213 - UI - Initial support for ActivityPub comments #218

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
af11517
Issue #213 - UI - Initial support for comments
dellagustin Jan 28, 2023
3b10c58
refactoring: moved import
dellagustin Jan 28, 2023
b39da33
Issue #213: Button for hidding comments
dellagustin Jan 28, 2023
949eb31
Issue #231: open on external source
dellagustin Jan 28, 2023
bdf237e
Issue #213: Fetch comments using threadcap
dellagustin Jan 29, 2023
115a26a
Merge branch 'Podcastindex-org:master' into enhancement#213__show-epi…
dellagustin Feb 1, 2023
d0b22ad
Issue #231 - support commenters without image/icon
dellagustin Feb 2, 2023
ae59712
Merge github.com:podStation/podcastindex-web-ui into enhancement#213_…
dellagustin Feb 2, 2023
e598289
Merge branch 'enhancement#213__show-episode-comments' of github.com:p…
dellagustin Feb 2, 2023
b48dc1b
Issue #213 - New comments design
dellagustin Feb 2, 2023
608dcfb
Issue #213: resolve language tagged values on comments
dellagustin Feb 5, 2023
094393d
Issue #213 - Support for summary
dellagustin Feb 5, 2023
2f826a7
Issue #213: Add user agent
dellagustin Feb 5, 2023
22f868b
Issue #213: Solved missing key warning
dellagustin Feb 5, 2023
5282362
Issue #213: Add indicator of loading comments
dellagustin Feb 7, 2023
d33af68
Issue #213: New design for comments
dellagustin Feb 9, 2023
61ae1cc
Issue #213: Undo 2f826a759137971187fff2039be6780efde49168
dellagustin Feb 11, 2023
f2037c2
Issue #213: socialInteract from the API
dellagustin Feb 11, 2023
07207d2
Issue #213: Content sanitization
dellagustin Feb 11, 2023
ae68e52
Issue #213: yarn.lock
dellagustin Feb 11, 2023
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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"private": true,
"dependencies": {
"@hcaptcha/react-hcaptcha": "^0.3.9",
"@types/dompurify": "^2.4.0",
"@types/history": "^4.7.5",
"@types/react": "^16.9.20",
"@types/react-dom": "^16.9.5",
Expand All @@ -19,10 +20,12 @@
"canvas-confetti": "^1.5.1",
"connected-react-router": "^6.7.0",
"crypto-js": "^4.0.0",
"dompurify": "^2.4.3",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"history": "^4.10.1",
"moment": "^2.29.0",
"node-fetch": "^2.6.8",
"pluralize": "^8.0.0",
"podcast-index-api": "^1.1.9",
"query-string": "^6.13.5",
Expand All @@ -39,6 +42,7 @@
"redux-thunk": "^2.3.0",
"reduxsauce": "^1.1.2",
"shortid": "^2.2.15",
"threadcap": "^0.1.9",
"typesafe-actions": "^5.1.0",
"uuid": "^8.3.2",
"webln": "^0.3.0"
Expand Down
32 changes: 32 additions & 0 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@ const path = require('path')
const fs = require('fs');
const express = require('express')
const app = express() // create express app
const { makeThreadcap, InMemoryCache, updateThreadcap, makeRateLimitedFetcher } = require('threadcap');
const fetch = require('node-fetch');
const packageJson = require('../package.json');

// Gets the .env variables
require('dotenv').config()

const USER_AGENT = `Podcastindex.org-web/${packageJson.version}`;

// Utilizing the node repo from comster/podcast-index-api :)
// NOTE: This server will work as a reverse proxy.
const api = require('podcast-index-api')(
Expand Down Expand Up @@ -112,6 +118,32 @@ app.use('/api/add/byfeedurl', async (req, res) => {
res.send(response)
})

// ------------------------------------------------
// ------------ API to get comments for episode ---
// ------------------------------------------------
app.use('/api/comments/byepisodeid', async (req, res) => {
let episodeId = req.query.id;
const response = await api.episodesById(episodeId, false);

const socialInteract = response.episode.socialInteract && response.episode.socialInteract.filter((si) => si.protocol === 'activitypub');

if(!socialInteract && socialInteract.lenght >= 0) {
dellagustin marked this conversation as resolved.
Show resolved Hide resolved
// Bad requests sounds appropriate, as the client is only expected to call this API
// when it validated upfront that the episode has a property socialInteract with activitypub protocol
res.status(400).send('The episode does not contain a socialInteract property')
}

const userAgent = USER_AGENT;
const cache = new InMemoryCache();
const fetcher = makeRateLimitedFetcher(fetch);

const threadcap = await makeThreadcap(socialInteract[0].uri, { userAgent, cache, fetcher });

await updateThreadcap(threadcap, { updateTime: new Date().toISOString(), userAgent, cache, fetcher });

res.send(threadcap)
})

// ------------------------------------------------
// ---------- Static files for API data -----------
// ------------------------------------------------
Expand Down
218 changes: 218 additions & 0 deletions ui/src/components/Comments/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import * as React from 'react'
import DOMPurify from 'dompurify'

import './styles.scss'

interface IProps {
id: number,
}

interface IState {
showComments: boolean,
loadingComments: boolean,
comments: StateComment[]
}

interface StateComment {
url: string,
publishedAt?: Date,
summary?: string,
content?: string,
attributedTo?: Commenter,
replies?: StateComment[],
commentError?: string,
repliesError?: string
}

interface Commenter {
name: string,
iconUrl: string,
url: string,
account: string
}

interface ICommentProps {
comment: StateComment
}

class Comment extends React.PureComponent<ICommentProps> {
constructor(props) {
super(props);
}

render(): React.ReactNode {
return (
<details open>
{!this.props.comment.commentError &&
<summary>
<a className='profile' href={this.props.comment.attributedTo.url}>
<img className='profile-img' src={this.props.comment.attributedTo.iconUrl || '/images/brand-icon.svg'} />
<div className='user'>
<strong>{this.props.comment.attributedTo.name}</strong>
<span className='handle'>{this.props.comment.attributedTo.account}</span>
</div>
</a>
<span aria-hidden="true">·</span>
<a href={this.props.comment.url} className='permalink'>
<time>{this.props.comment.publishedAt.toLocaleString()}</time>
</a>
</summary>
}
{ this.props.comment.summary ?
<div className="contents">
<details className="content-warning">
<summary>Content Warning Summary</summary>
{
!this.props.comment.commentError && this.props.comment.content &&
<div dangerouslySetInnerHTML={{__html: this.props.comment.content}}/>
}
</details>
</div>
:
// content can be empty when there are attachments
!this.props.comment.commentError && this.props.comment.content &&
<div className="contents" dangerouslySetInnerHTML={{__html: this.props.comment.content}}/>
}
{this.props.comment.commentError &&
<summary>
<a className='profile' href={this.props.comment.url}>
<img className='profile-img' src='/images/brand-icon.svg' />
<strong>Error loading this comment</strong>
</a>
</summary>
}
{!this.props.comment.repliesError && this.props.comment.replies && <div>
{this.props.comment.replies.map((reply) => <Comment key={reply.url} comment={reply}/>)}
</div>}
{
this.props.comment.repliesError && <div className='contents'>Error loading replies for this comment</div>
}
</details>
)
}
}

export default class Comments extends React.PureComponent<IProps, IState> {
constructor(props) {
super(props);
this.state = {
showComments: false,
loadingComments: false,
comments: []
};
}

async onClickShowComments() {
const stateToSet: any = {
showComments: true,
loadingComments: false
};

if(!this.state.comments.length) {
this.setState({
loadingComments: true
});

const response = await fetch('/api/comments/byepisodeid?' + new URLSearchParams({id: String(this.props.id) }));

const responseBody = await response.json();

stateToSet.comments = responseBody.roots.map((root) => Comments.buildStateComment(root, responseBody));
}

this.setState(stateToSet);
}

async onClickHideComments() {
this.setState({showComments: false});
}

private static buildStateComment(commentUrl: string, commentsApiResponseBody): StateComment | null {
const node = commentsApiResponseBody.nodes[commentUrl];

if(!node) {
return null;
}

const commenter = node.comment && commentsApiResponseBody.commenters[node.comment.attributedTo];

let stateComment: StateComment = {
url: commentUrl
}

if(node.comment) {
const summary = node.comment.summary && DOMPurify.sanitize(Comments.resolveLanguageTaggedValues(node.comment.summary));
const content = node.comment.content && DOMPurify.sanitize(Comments.resolveLanguageTaggedValues(node.comment.content));

stateComment = {
...stateComment,
url: node.comment.url,
publishedAt: new Date(node.comment.published),
summary: summary,
content: content,

attributedTo: commenter && {
name: commenter.name,
iconUrl: commenter.icon?.url,
url: commenter.url,
account: commenter.fqUsername,
}
}
}
else {
console.warn('There was an error on the server fetching a comment', node.commentError);
stateComment.commentError = node.commentError;
}

if(node.replies) {
stateComment = {
...stateComment,
replies: node.replies.map((reply) => Comments.buildStateComment(reply, commentsApiResponseBody))
}
}
else {
console.warn('There was an error on the server fetching a replies to a comment', node.repliesError);
stateComment.repliesError = node.repliesError
}

return stateComment;
}

/**
* Returns a single value from a an object with multiple language tagged values
*
* Currently, it returns the value of the fist property in languageTaggedValues.
* In the future, it should return the value of the property that best matches
* the user's language (navigator.language || navigator.userLanguage), as
* reference, see https://www.rfc-editor.org/info/bcp47
*
* @example
* // value will be 'A mensagem'
* let value = resolveLanguageTaggedValues({pt-BR: 'A mensagem', en: 'The message'})
*
* @param languageTaggedValues
* @returns the value of the first property in languageTaggedValues
*/
private static resolveLanguageTaggedValues(languageTaggedValues): string | null {
if(!languageTaggedValues) {
return null;
}

for(let propertyName in languageTaggedValues) {
if(languageTaggedValues.hasOwnProperty(propertyName)) {
return languageTaggedValues[propertyName];
}
}
}

render() {
return (
<div className='comments-container'>
{!this.state.showComments && <button disabled={this.state.loadingComments} onClick={() => this.onClickShowComments()}>Show comments</button>}
{this.state.showComments && <button onClick={() => this.onClickHideComments()}>Hide comments</button>}
{this.state.loadingComments && <p>Loading comments...</p>}
{this.state.showComments && this.state.comments.map((comment) => <Comment key={comment.url} comment={comment}/>)}
</div>
)
}
}
Loading