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 }}
-
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 @@
+
+
+
+ {{ $t('Channel.Community.Viewing Posts Only Supported By Invidious') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/locales/en-US.yaml b/static/locales/en-US.yaml
index 4d8d6f80ccaaf..f4366ef53ebd0 100644
--- a/static/locales/en-US.yaml
+++ b/static/locales/en-US.yaml
@@ -52,6 +52,8 @@ Global:
Channel Count: 1 channel | {count} channels
Subscriber Count: 1 subscriber | {count} subscribers
View Count: 1 view | {count} views
+ Like Count: 1 like | {count} likes
+ Comment Count: 1 comment | {count} comments
Watching Count: 1 watching | {count} watching
Input Tags:
Length Requirement: Tag must be at least {number} characters long
@@ -758,9 +760,11 @@ Channel:
Community:
This channel currently does not have any posts: This channel currently does not have any posts
votes: '{votes} votes'
+ View Full Post: View Full Post
Reveal Answers: Reveal Answers
Hide Answers: Hide Answers
Video hidden by FreeTube: Video hidden by FreeTube
+ Viewing Posts Only Supported By Invidious: Viewing Posts is only supported by Invidious. Head to a channel's community tab to view content there without Invidious.
Video:
More Options: More Options
Mark As Watched: Mark As Watched
@@ -988,12 +992,14 @@ Comments:
And others: and others
There are no comments available for this video: There are no comments available
for this video
+ There are no comments available for this post: There are no comments available for this post
Load More Comments: Load More Comments
No more comments available: No more comments available
Pinned by: Pinned by
Member: Member
Subscribed: Subscribed
Hearted: Hearted
+
Up Next: Up Next
#Tooltips