Skip to content

Commit

Permalink
Merge pull request #218 from podStation/enhancement#213__show-episode…
Browse files Browse the repository at this point in the history
…-comments

Issue #213 - UI - Initial support for ActivityPub comments
  • Loading branch information
daveajones authored Feb 11, 2023
2 parents f1cb6b5 + ae68e52 commit 1eba327
Show file tree
Hide file tree
Showing 7 changed files with 22,914 additions and 16,612 deletions.
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) {
// 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

0 comments on commit 1eba327

Please sign in to comment.