diff --git a/src/renderer/App.js b/src/renderer/App.js index e2ecc75e4140b..03e46519504db 100644 --- a/src/renderer/App.js +++ b/src/renderer/App.js @@ -453,6 +453,17 @@ export default defineComponent({ break } + case 'post': { + const { postId, query } = result + + openInternalPath({ + path: `/post/${postId}`, + query, + doCreateNewWindow + }) + break + } + case 'channel': { const { channelId, subPath, url } = result diff --git a/src/renderer/components/ft-community-post/ft-community-post.js b/src/renderer/components/ft-community-post/ft-community-post.js index 51fa39bb1afed..cbf30691f14ff 100644 --- a/src/renderer/components/ft-community-post/ft-community-post.js +++ b/src/renderer/components/ft-community-post/ft-community-post.js @@ -7,7 +7,7 @@ import autolinker from 'autolinker' import { A11y, Navigation, Pagination } from 'swiper/modules' -import { createWebURL, deepCopy, toLocalePublicationString } from '../../helpers/utils' +import { createWebURL, deepCopy, formatNumber, toLocalePublicationString } from '../../helpers/utils' import { youtubeImageUrlToInvidious } from '../../helpers/api/invidious' export default defineComponent({ @@ -29,7 +29,11 @@ export default defineComponent({ hideForbiddenTitles: { type: Boolean, default: true - } + }, + singlePost: { + type: Boolean, + default: false + }, }, data: function () { return { @@ -37,9 +41,11 @@ export default defineComponent({ postId: '', authorThumbnails: null, publishedText: '', - voteCount: '', + voteCount: 0, + formattedVoteCount: '', postContent: '', - commentCount: '', + commentCount: null, + formattedCommentCount: '', author: '', authorId: '', } @@ -56,6 +62,16 @@ export default defineComponent({ hideVideo() { return this.forbiddenTitles.some((text) => this.data.postContent.content.title?.toLowerCase().includes(text.toLowerCase())) + }, + + backendPreference: function () { + return this.$store.getters.getBackendPreference + }, + backendFallback: function () { + return this.$store.getters.getBackendFallback + }, + isInvidiousAllowed: function() { + return this.backendPreference === 'invidious' || this.backendFallback } }, created: function () { @@ -127,7 +143,9 @@ export default defineComponent({ isRSS: this.data.isRSS }) this.voteCount = this.data.voteCount + this.formattedVoteCount = formatNumber(this.voteCount) this.commentCount = this.data.commentCount + this.formattedCommentCount = formatNumber(this.commentCount) this.type = (this.data.postContent !== null && this.data.postContent !== undefined) ? this.data.postContent.type : 'text' this.author = this.data.author this.authorId = this.data.authorId diff --git a/src/renderer/components/ft-community-post/ft-community-post.scss b/src/renderer/components/ft-community-post/ft-community-post.scss index bb50bb5b4fb43..6f7b7ebce9e6a 100644 --- a/src/renderer/components/ft-community-post/ft-community-post.scss +++ b/src/renderer/components/ft-community-post/ft-community-post.scss @@ -59,6 +59,12 @@ white-space: pre-wrap; } +.commentsLink { + color: var(--primary-text-color); + text-decoration: none; + font-weight: bold; +} + .bottomSection { color: var(--tertiary-text-color); display: block; diff --git a/src/renderer/components/ft-community-post/ft-community-post.vue b/src/renderer/components/ft-community-post/ft-community-post.vue index f65e379e47824..b7f864efde2da 100644 --- a/src/renderer/components/ft-community-post/ft-community-post.vue +++ b/src/renderer/components/ft-community-post/ft-community-post.vue @@ -115,11 +115,42 @@
- {{ voteCount }} - + + + + + + + { @@ -162,8 +174,12 @@ export default defineComponent({ getCommentData: function () { this.isLoading = true - if (!process.env.SUPPORTS_LOCAL_API || this.backendPreference === 'invidious') { - this.getCommentDataInvidious() + if (!process.env.SUPPORTS_LOCAL_API || this.backendPreference === 'invidious' || this.isPostComments) { + if (!this.isPostComments) { + this.getCommentDataInvidious() + } else { + this.getPostCommentsInvidious() + } } else { this.getCommentDataLocal() } @@ -173,8 +189,12 @@ export default defineComponent({ if (this.commentData.length === 0 || this.nextPageToken === null || typeof this.nextPageToken === 'undefined') { showToast(this.$t('Comments.There are no more comments for this video')) } else { - if (!process.env.SUPPORTS_LOCAL_API || this.backendPreference === 'invidious') { - this.getCommentDataInvidious() + if (!process.env.SUPPORTS_LOCAL_API || this.backendPreference === 'invidious' || this.isPostComments) { + if (!this.isPostComments) { + this.getCommentDataInvidious() + } else { + this.getPostCommentsInvidious() + } } else { this.getCommentDataLocal(true) } @@ -190,17 +210,14 @@ export default defineComponent({ }, getCommentReplies: function (index) { - if (process.env.SUPPORTS_LOCAL_API) { - switch (this.commentData[index].dataType) { - case 'local': - this.getCommentRepliesLocal(index) - break - case 'invidious': - this.getCommentRepliesInvidious(index) - break + if (!process.env.SUPPORTS_LOCAL_API || this.commentData[index].dataType === 'invidious' || this.isPostComments) { + if (!this.isPostComments) { + this.getCommentRepliesInvidious(index) + } else { + this.getPostCommentRepliesInvidious(index) } } else { - this.getCommentRepliesInvidious(index) + this.getCommentRepliesLocal(index) } }, @@ -375,5 +392,68 @@ export default defineComponent({ this.isLoading = false }) }, + + getPostCommentsInvidious: function() { + const nextPageToken = this.nextPageToken + + const fetchComments = nextPageToken == null + ? getInvidiousCommunityPostComments({ postId: this.id, authorId: this.postAuthorId }) + : getInvidiousCommunityPostCommentReplies({ postId: this.id, replyToken: this.nextPageToken, authorId: this.postAuthorId }) + + fetchComments.then(({ response, commentData, continuation }) => { + commentData = commentData.map(({ replyToken, ...comment }) => { + if (comment.hasReplyToken) { + this.replyTokens.set(comment.id, replyToken) + } else { + this.replyTokens.delete(comment.id) + } + + return comment + }) + + this.commentData = this.commentData.concat(commentData) + this.nextPageToken = response?.continuation ?? continuation + this.isLoading = false + this.showComments = true + }).catch((err) => { + console.error(err) + const errorMessage = this.$t('Invidious API Error (Click to copy)') + showToast(`${errorMessage}: ${err}`, 10000, () => { + copyToClipboard(err) + }) + this.isLoading = false + }) + }, + + getPostCommentRepliesInvidious: function(index) { + showToast(this.$t('Comments.Getting comment replies, please wait')) + + const comment = this.commentData[index] + const replyToken = this.replyTokens.get(comment.id) + const id = this.id + + getInvidiousCommunityPostCommentReplies({ postId: id, replyToken: replyToken, authorId: this.postAuthorId }) + .then(({ commentData, continuation }) => { + comment.replies = comment.replies.concat(commentData) + comment.showReplies = true + + if (continuation) { + this.replyTokens.set(comment.id, continuation) + comment.hasReplyToken = true + } else { + this.replyTokens.delete(comment.id) + comment.hasReplyToken = false + } + + this.isLoading = false + }).catch((error) => { + console.error(error) + const errorMessage = this.$t('Invidious API Error (Click to copy)') + showToast(`${errorMessage}: ${error}`, 10000, () => { + copyToClipboard(error) + }) + this.isLoading = false + }) + } } }) diff --git a/src/renderer/components/watch-video-comments/watch-video-comments.vue b/src/renderer/components/watch-video-comments/watch-video-comments.vue index ee5798c631b73..d39df77213606 100644 --- a/src/renderer/components/watch-video-comments/watch-video-comments.vue +++ b/src/renderer/components/watch-video-comments/watch-video-comments.vue @@ -41,7 +41,7 @@ {{ $t("Comments.Click to View Comments") }} -

+

+ {{ $t("Comments.There are no comments available for this post") }} +

+

{{ $t("Comments.There are no comments available for this video") }}

diff --git a/src/renderer/helpers/api/invidious.js b/src/renderer/helpers/api/invidious.js index 17259c7f8158e..f05c4af95e920 100644 --- a/src/renderer/helpers/api/invidious.js +++ b/src/renderer/helpers/api/invidious.js @@ -60,6 +60,16 @@ export function invidiousAPICall({ resource, id = '', params = {}, doLogError = }) } +async function resolveUrl(url) { + return await invidiousAPICall({ + resource: 'resolveurl', + params: { + url + }, + doLogError: false + }) +} + /** * Gets the channel ID for a channel URL * used to get the ID for channel usernames and handles @@ -67,13 +77,7 @@ export function invidiousAPICall({ resource, id = '', params = {}, doLogError = */ export async function invidiousGetChannelId(url) { try { - const response = await invidiousAPICall({ - resource: 'resolveurl', - params: { - url - }, - doLogError: false - }) + const response = await resolveUrl(url) if (response.pageType === 'WEB_PAGE_TYPE_CHANNEL') { return response.ucid @@ -198,6 +202,60 @@ export async function invidiousGetCommunityPosts(channelId, continuation = null) return { posts: response.comments, continuation: response.continuation ?? null } } +export async function getInvidiousCommunityPost(postId, authorId = null) { + const payload = { + resource: 'post', + id: postId, + } + + if (authorId == null) { + authorId = await invidiousGetChannelId('https://www.youtube.com/post/' + postId) + } + + payload.params = { + ucid: authorId + } + + const response = await invidiousAPICall(payload) + + const post = parseInvidiousCommunityData(response.comments[0]) + post.authorId = authorId + post.commentCount = null + + return post +} + +export async function getInvidiousCommunityPostComments({ postId, authorId }) { + const payload = { + resource: 'post', + id: postId, + subResource: 'comments', + params: { + ucid: authorId + } + } + + const response = await invidiousAPICall(payload) + const commentData = parseInvidiousCommentData(response) + + return { response, commentData } +} + +export async function getInvidiousCommunityPostCommentReplies({ postId, replyToken, authorId }) { + const payload = { + resource: 'post', + id: postId, + subResource: 'comments', + params: { + ucid: authorId, + continuation: replyToken + } + } + + const response = await invidiousAPICall(payload) + return { commentData: parseInvidiousCommentData(response), continuation: response.continuation ?? null } +} + function parseInvidiousCommunityData(data) { return { // use #/ to support channel YT links. diff --git a/src/renderer/helpers/api/local.js b/src/renderer/helpers/api/local.js index ba9a260f7c3cf..83454169b75fd 100644 --- a/src/renderer/helpers/api/local.js +++ b/src/renderer/helpers/api/local.js @@ -1433,7 +1433,7 @@ function parseLocalCommunityPost(post) { postId: post.id, authorThumbnails: post.author.thumbnails, publishedText: post.published.text, - voteCount: post.vote_count, + voteCount: parseLocalSubscriberCount(post.vote_count.text), postContent: parseLocalAttachment(post.attachment), commentCount: replyCount, author: post.author.name, diff --git a/src/renderer/router/index.js b/src/renderer/router/index.js index 2185a7d4878ce..f0a0a2ec4cb60 100644 --- a/src/renderer/router/index.js +++ b/src/renderer/router/index.js @@ -14,6 +14,7 @@ import Playlist from '../views/Playlist/Playlist.vue' import Channel from '../views/Channel/Channel.vue' import Watch from '../views/Watch/Watch.vue' import Hashtag from '../views/Hashtag/Hashtag.vue' +import Post from '../views/Post/Post.vue' Vue.use(Router) @@ -133,6 +134,13 @@ const router = new Router({ title: 'Hashtag' }, component: Hashtag + }, + { + path: '/post/:id', + meta: { + title: 'Post', + }, + component: Post } ], scrollBehavior(to, from, savedPosition) { diff --git a/src/renderer/store/modules/utils.js b/src/renderer/store/modules/utils.js index ce9e710e4863d..f257c98018cc4 100644 --- a/src/renderer/store/modules/utils.js +++ b/src/renderer/store/modules/utils.js @@ -472,11 +472,13 @@ const actions = { const hashtagPattern = /^\/hashtag\/(?[^#&/?]+)$/ + const postPattern = /^\/post\/(?.+)/ const typePatterns = new Map([ ['playlist', /^(\/playlist\/?|\/embed(\/?videoseries)?)$/], ['search', /^\/results|search\/?$/], ['hashtag', hashtagPattern], - ['channel', channelPattern] + ['channel', channelPattern], + ['post', postPattern] ]) for (const [type, pattern] of typePatterns) { @@ -553,6 +555,17 @@ const actions = { hashtag } } + + case 'post': { + const match = url.pathname.match(postPattern) + const postId = match.groups.postId + const query = { authorId: url.searchParams.get('ucid') } + return { + urlType: 'post', + postId, + query + } + } /* Using RegExp named capture groups from ES2018 To avoid access to specific captured value broken @@ -610,6 +623,16 @@ const actions = { subPath = 'about' break case 'community': + if (url.searchParams.has('lb')) { + // if it has the lb search parameter then it is linking a specific community post + const postId = url.searchParams.get('lb') + const query = { authorId: channelId } + return { + urlType: 'post', + postId, + query + } + } subPath = 'community' break default: diff --git a/src/renderer/views/Post/Post.js b/src/renderer/views/Post/Post.js new file mode 100644 index 0000000000000..d13bb906bbf95 --- /dev/null +++ b/src/renderer/views/Post/Post.js @@ -0,0 +1,62 @@ +import { defineComponent } from 'vue' +import FtCard from '../../components/ft-card/ft-card.vue' +import FtCommunityPost from '../../components/ft-community-post/ft-community-post.vue' +import FtLoader from '../../components/ft-loader/ft-loader.vue' +import WatchVideoComments from '../../components/watch-video-comments/watch-video-comments.vue' + +import { getInvidiousCommunityPost } from '../../helpers/api/invidious' + +export default defineComponent({ + name: 'Post', + components: { + FtCard, + FtCommunityPost, + FtLoader, + WatchVideoComments + }, + data: function () { + return { + id: '', + authorId: '', + post: null, + comments: null, + isLoading: true, + } + }, + computed: { + backendPreference: function () { + return this.$store.getters.getBackendPreference + }, + backendFallback: function () { + return this.$store.getters.getBackendFallback + }, + isInvidiousAllowed: function() { + return this.backendPreference === 'invidious' || this.backendFallback + } + }, + watch: { + async $route() { + // react to route changes... + this.isLoading = true + if (this.isInvidiousAllowed) { + this.id = this.$route.params.id + this.authorId = this.$route.query.authorId + await this.loadDataInvidiousAsync() + } + } + }, + mounted: async function () { + if (this.isInvidiousAllowed) { + this.id = this.$route.params.id + this.authorId = this.$route.query.authorId + await this.loadDataInvidiousAsync() + } + }, + methods: { + loadDataInvidiousAsync: async function() { + this.post = await getInvidiousCommunityPost(this.id, this.authorId) + this.authorId = this.post.authorId + this.isLoading = false + } + } +}) diff --git a/src/renderer/views/Post/Post.vue b/src/renderer/views/Post/Post.vue new file mode 100644 index 0000000000000..22b316ebaeaa7 --- /dev/null +++ b/src/renderer/views/Post/Post.vue @@ -0,0 +1,31 @@ + + +