From e2d283d6b51661b96e4316e6fe3fbc06167ae6ce Mon Sep 17 00:00:00 2001 From: Felipe Mateus Date: Wed, 12 Mar 2025 07:52:43 -0300 Subject: [PATCH 01/22] translate notifications --- resources/assets/components/Notifications.vue | 30 ++++----- .../partials/timeline/Notification.vue | 60 +++++++++-------- .../components/sections/Notifications.vue | 39 +++++------ resources/assets/js/spa.js | 64 +++++++++---------- resources/lang/en/web.php | 17 +++++ resources/lang/pt/web.php | 62 +++++++++++------- 6 files changed, 153 insertions(+), 119 deletions(-) diff --git a/resources/assets/components/Notifications.vue b/resources/assets/components/Notifications.vue index 2fb085b4e..27217b699 100644 --- a/resources/assets/components/Notifications.vue +++ b/resources/assets/components/Notifications.vue @@ -9,7 +9,7 @@
@@ -19,7 +19,7 @@

- Follow Requests + {{ $t("notifications.followRequests") }}

@@ -141,13 +141,13 @@ class="btn btn-outline-success py-1 btn-sm font-weight-bold rounded-pill mr-2 mb-1" @click.prevent="handleFollowRequest('accept', index)" > - Accept + {{ $t('notifications.accept') }} @@ -161,7 +161,7 @@
-

Filtering results may not include older notifications

+

{{ $t("notifications.filteringResults") }}

@@ -244,40 +244,40 @@ { id: 'mentions', - name: 'Mentions', - description: 'Replies to your posts and posts you were mentioned in', + name: this.$t("notifications.mentions"), + description: this.$t("notifications.mentionsDescription"), icon: 'far fa-at', types: ['comment', 'mention'] }, { id: 'likes', - name: 'Likes', - description: 'Accounts that liked your posts', + name: this.$t("notifications.likes"), + description: this.$t("notifications.likesDescription"), icon: 'far fa-heart', types: ['favourite'] }, { id: 'followers', - name: 'Followers', - description: 'Accounts that followed you', + name: this.$t("notifications.followers"), + description: this.$t("notifications.followersDescription"), icon: 'far fa-user-plus', types: ['follow'] }, { id: 'reblogs', - name: 'Reblogs', - description: 'Accounts that shared or reblogged your posts', + name: this.$t("notifications.reblogs"), + description:this.$t("notifications.reblogsDescription"), icon: 'far fa-retweet', types: ['share'] }, { id: 'direct', - name: 'DMs', - description: 'Direct messages you have with other accounts', + name: this.$t("notifications.dms"), + description: this.$t("notifications.dmsDescription"), icon: 'far fa-envelope', types: ['direct'] }, diff --git a/resources/assets/components/partials/timeline/Notification.vue b/resources/assets/components/partials/timeline/Notification.vue index 8f6a011a4..078717a6a 100644 --- a/resources/assets/components/partials/timeline/Notification.vue +++ b/resources/assets/components/partials/timeline/Notification.vue @@ -7,13 +7,13 @@

- @{{n.account.acct}} {{ $t('notifications.liked') }} post. + @{{n.account.acct}} {{ $t('notifications.liked') }} {{ $t("notifications.post")}}.

- @{{n.account.acct}} {{ $t('notifications.commented') }} post. + @{{n.account.acct}} {{ $t('notifications.commented') }} {{ $t("notifications.post")}}.

@@ -25,7 +25,7 @@

- @{{n.account.acct}} {{ $t('notifications.reacted') }} story. + @{{n.account.acct}} {{ $t('notifications.reacted') }} {{ $t('notifications.story') }}.

@@ -141,30 +141,36 @@ return text.slice(0, limit) + '...' }, - timeAgo(ts) { - let date = Date.parse(ts); - let seconds = Math.floor((new Date() - date) / 1000); - let interval = Math.floor(seconds / 31536000); - if (interval >= 1) { - return interval + "y"; - } - interval = Math.floor(seconds / 604800); - if (interval >= 1) { - return interval + "w"; - } - interval = Math.floor(seconds / 86400); - if (interval >= 1) { - return interval + "d"; - } - interval = Math.floor(seconds / 3600); - if (interval >= 1) { - return interval + "h"; - } - interval = Math.floor(seconds / 60); - if (interval >= 1) { - return interval + "m"; - } - return Math.floor(seconds) + "s"; + timeAgo(ts) { + let date = new Date(ts); + let now = new Date(); + let seconds = Math.floor((now - date) / 1000); + let interval = Math.floor(seconds / 31536000); + if (interval >= 1) { + return new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: 'auto' }).format(-interval, 'year'); + } + interval = Math.floor(seconds / 2592000); + if (interval >= 1) { + return new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: 'auto' }).format(-interval, 'month'); + } + interval = Math.floor(seconds / 604800); + if (interval >= 1) { + return new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: 'auto' }).format(-interval, 'week'); + } + interval = Math.floor(seconds / 86400); + if (interval >= 1) { + return new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: 'auto' }).format(-interval, 'day'); + } + interval = Math.floor(seconds / 3600); + if (interval >= 1) { + return new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: 'auto' }).format(-interval, 'hour'); + } + interval = Math.floor(seconds / 60); + if (interval >= 1) { + return new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: 'auto' }).format(-interval, 'minute'); + } + return new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: 'auto' }).format(-seconds, 'second'); + }, mentionUrl(status) { diff --git a/resources/assets/components/sections/Notifications.vue b/resources/assets/components/sections/Notifications.vue index 1ddf522fc..d20d472d8 100644 --- a/resources/assets/components/sections/Notifications.vue +++ b/resources/assets/components/sections/Notifications.vue @@ -3,7 +3,7 @@
- Notifications + {{ $t("notifications.title")}}
@@ -49,27 +49,28 @@ class="mr-2 rounded-circle shadow-sm" :src="n.account.avatar" width="32" + height="32" onerror="this.onerror=null;this.src='/storage/avatars/default.png';">

- Your recent post has been unlisted. + {{ $t("notifications.youRecent")}} {{ $t("notifications.post")}} {{ $t("notifications.hasUnlisted")}}.

Click here for more info. @@ -77,64 +78,64 @@

- {{truncate(n.account.username)}} updated a modlog. + {{truncate(n.account.username)}} {{ $t("notifications.updatedA")}} modlog.

- Your application to join the {{truncate(n.group.name)}} group was approved! + {{ $t("notifications.yourApplication")}} {{truncate(n.group.name)}} {{ $t("notifications.wasApproved")}}

- Your application to join {{truncate(n.group.name)}} was rejected. + {{ $t("notifications.yourApplication")}} {{truncate(n.group.name)}} {{ $t("notifications.wasRejected")}}

@@ -146,11 +147,11 @@

- We cannot display this notification at this time. + {{ $t("notifications.cannotDisplay")}}

-
{{timeAgo(n.created_at)}}
+
{{timeAgo(n.created_at)}}
diff --git a/resources/assets/js/spa.js b/resources/assets/js/spa.js index 73bd22f2e..0d74c6dc8 100644 --- a/resources/assets/js/spa.js +++ b/resources/assets/js/spa.js @@ -697,40 +697,36 @@ window.App.util = { } return new Intl.NumberFormat(locale, { notation: notation , compactDisplay: "short" }).format(count); }), - timeAgo: function(ts) { - const date = new Date(ts); - const now = new Date(); - - const seconds = Math.floor((now - date) / 1000); - - const secondsInYear = 60 * 60 * 24 * 365.25; - let interval = Math.floor(seconds / secondsInYear); - if (interval >= 1) { - return interval + "y"; - } - - interval = Math.floor(seconds / (60 * 60 * 24 * 7)); - if (interval >= 1) { - return interval + "w"; - } - - interval = Math.floor(seconds / (60 * 60 * 24)); - if (interval >= 1) { - return interval + "d"; - } - - interval = Math.floor(seconds / (60 * 60)); - if (interval >= 1) { - return interval + "h"; - } - - interval = Math.floor(seconds / 60); - if (interval >= 1) { - return interval + "m"; - } - - return Math.floor(seconds) + "s"; - }, + timeAgo: (function(ts) { + let date = new Date(ts); + let now = new Date(); + let seconds = Math.floor((now - date) / 1000); + let interval = Math.floor(seconds / 31536000); + if (interval >= 1) { + return new Intl.RelativeTimeFormat(i18n.locale, { numeric: 'auto', style: 'short' }).format(-interval, 'year'); + } + interval = Math.floor(seconds / 2592000); + if (interval >= 1) { + return new Intl.RelativeTimeFormat(i18n.locale, { numeric: 'auto', style: 'short' }).format(-interval, 'month'); + } + interval = Math.floor(seconds / 604800); + if (interval >= 1) { + return new Intl.RelativeTimeFormat(i18n.locale, { numeric: 'auto', style: 'short' }).format(-interval, 'week'); + } + interval = Math.floor(seconds / 86400); + if (interval >= 1) { + return new Intl.RelativeTimeFormat(i18n.locale, { numeric: 'auto', style: 'short' }).format(-interval, 'day'); + } + interval = Math.floor(seconds / 3600); + if (interval >= 1) { + return new Intl.RelativeTimeFormat(i18n.locale, { numeric: 'auto', style: 'short' }).format(-interval, 'hour'); + } + interval = Math.floor(seconds / 60); + if (interval >= 1) { + return new Intl.RelativeTimeFormat(i18n.locale, { numeric: 'auto', style: 'short' }).format(-interval, 'minute'); + } + return new Intl.RelativeTimeFormat(i18n.locale, { numeric: 'auto', style: 'short' }).format(-seconds, 'second'); + }), timeAhead: (function(ts, short = true) { let date = Date.parse(ts); let diff = date - Date.parse(new Date()); diff --git a/resources/lang/en/web.php b/resources/lang/en/web.php index 3acde415b..d20781257 100644 --- a/resources/lang/en/web.php +++ b/resources/lang/en/web.php @@ -104,6 +104,23 @@ return [ 'post' => 'post', 'story' => 'story', 'noneFound' => 'No notifications found', + 'youRecent' => 'You recent', + 'hasUnlisted' => 'has been unlisted', + 'cannotDisplay' => 'We cannot display this notification at this time.', + 'followRequest' => 'Follow Requests', + 'filteringResults' => 'Filtering results may not include older notifications', + 'mentions' => 'Mentions', + 'mentionsDescription' => 'Replies to your posts and posts you were mentioned in', + 'likes' => 'Likes', + 'likesDescription' => 'Accounts that liked your posts', + 'followers' => 'Followers', + 'followersDescription' => 'Accounts that followed you', + 'reblogs' => 'Reblogs', + 'reblogsDescription' => 'Accounts that shared or reblogged your posts', + 'dms' => 'DMs', + 'dmsDescription' => 'Direct messages you have with other accounts', + 'accept' => 'Accept', + 'reject' => 'Reject' ], 'post' => [ diff --git a/resources/lang/pt/web.php b/resources/lang/pt/web.php index 6b6c8aded..2051d7a23 100644 --- a/resources/lang/pt/web.php +++ b/resources/lang/pt/web.php @@ -79,30 +79,44 @@ return [ 'requests' => 'Pedidos' ], - 'notifications' => [ - 'liked' => 'gostou do seu', - 'commented' => 'comentou no seu', - 'reacted' => 'reagiu ao seu', - 'shared' => 'partilhou o teu', - 'tagged' => 'etiquetou-te numa publicação', - - 'updatedA' => 'atualizou uma', - 'sentA' => 'enviou uma', - - 'followed' => 'seguiu-te', - 'mentioned' => 'mencionou-te', - 'you' => 'tu', - - 'yourApplication' => 'O teu pedido de adesão', - 'applicationApproved' => 'foi aprovado!', - 'applicationRejected' => 'foi rejeitado. Podes voltar a candidatar-te dentro de 6 meses.', - - 'dm' => 'md', - 'groupPost' => 'publicação de grupo', - 'modlog' => 'histórico de moderação', - 'post' => 'publicação', - 'story' => 'estória', - 'noneFound' => 'Nenhuma notificação encontrada', + 'notifications' => [ + 'title' => 'Notificações', + 'liked' => 'curtiu sua', + 'commented' => 'comentou na sua', + 'reacted' => 'reagiu à sua', + 'shared' => 'compartilhou a sua', + 'tagged' => 'marcou você numa publicação', + 'updatedA' => 'atualizou', + 'sentA' => 'enviou um', + 'followed' => 'seguiu', + 'mentioned' => 'mencionou', + 'you' => 'você', + 'yourApplication' => 'A sua candidatura para se juntar', + 'applicationApproved' => 'foi aprovada!', + 'applicationRejected' => 'foi rejeitada. Você pode inscrever-se novamente em 6 meses.', + 'dm' => 'mensagem direta', + 'groupPost' => 'publicação de grupo', + 'modlog' => 'histórico de moderação', + 'post' => 'publicação', + 'story' => 'estória', + 'noneFound' => 'Nenhuma notificação encontrada', + 'youRecent' => 'Você recente', + 'hasUnlisted' => 'foi removida da lista', + 'cannotDisplay' => 'Não podemos exibir esta notificação no momento.', + 'followRequest' => 'Pedidos de Seguimento', + 'filteringResults' => 'Os resultados do filtro podem não incluir notificações mais antigas', + 'mentions' => 'Menções', + 'mentionsDescription' => 'Respostas às suas publicações e publicações em que você foi mencionado', + 'likes' => 'Curtidas', + 'likesDescription' => 'Contas que curtiram das suas publicações', + 'followers' => 'Seguidores', + 'followersDescription' => 'Contas que seguiram você', + 'reblogs' => 'Reblogs', + 'reblogsDescription' => 'Contas que compartilharam ou reblogaram suas publicações', + 'dms' => 'DMs', + 'dmsDescription' => 'Mensagens diretas que você tem com outras contas', + 'accept' => 'Aceitar', + 'reject' => 'Rejeitar' ], 'post' => [ From 30c00a864cd2e4af69b8b44e32ed3e5a29514849 Mon Sep 17 00:00:00 2001 From: Felipe Mateus Date: Sat, 15 Mar 2025 21:22:52 -0300 Subject: [PATCH 02/22] translate profile --- .../partials/profile/ProfileFeed.vue | 28 +++---- .../partials/profile/ProfileSidebar.vue | 16 ++-- resources/assets/js/app.js | 81 ++++++++++++------- resources/assets/js/components/Profile.vue | 34 ++++---- resources/lang/en/web.php | 18 +++++ resources/lang/pt/web.php | 18 +++++ 6 files changed, 128 insertions(+), 67 deletions(-) diff --git a/resources/assets/components/partials/profile/ProfileFeed.vue b/resources/assets/components/partials/profile/ProfileFeed.vue index c6c69efb0..748ec5a90 100644 --- a/resources/assets/components/partials/profile/ProfileFeed.vue +++ b/resources/assets/components/partials/profile/ProfileFeed.vue @@ -8,7 +8,7 @@ :class="[ tabIndex === 1 ? 'active' : '' ]" @click="toggleTab(1)" > - Posts + {{ $t("profile.posts")}}
@@ -421,10 +421,10 @@ }, getJoinedDate() { - let d = new Date(this.profile.created_at); - let month = new Intl.DateTimeFormat("en-US", { month: "long" }).format(d); - let year = d.getFullYear(); - return `${month} ${year}`; + return new Date(this.profile.created_at).toLocaleDateString(this.$i18n.locale, { + year: 'numeric', + month: 'long', + }); }, follow() { diff --git a/resources/assets/js/app.js b/resources/assets/js/app.js index 945834165..a3b36eec9 100644 --- a/resources/assets/js/app.js +++ b/resources/assets/js/app.js @@ -1,3 +1,5 @@ +import VueI18n from 'vue-i18n'; + require('./polyfill'); window._ = require('lodash'); window.Popper = require('popper.js').default; @@ -19,7 +21,7 @@ if (token) { window.App = window.App || {}; window.App.redirect = function() { - document.querySelectorAll('a').forEach(function(i,k) { + document.querySelectorAll('a').forEach(function(i,k) { let a = i.getAttribute('href'); if(a && a.length > 5 && a.startsWith('https://')) { let url = new URL(a); @@ -31,7 +33,23 @@ window.App.redirect = function() { } window.App.boot = function() { - new Vue({ el: '#content'}); + Vue.use(VueI18n); + + let i18nMessages = { + en: require('./i18n/en.json'), + pt: require('./i18n/pt.json'), + }; + let locale = document.querySelector('html').getAttribute('lang'); + + const i18n = new VueI18n({ + locale: locale, // set locale + fallbackLocale: 'en', + messages: i18nMessages +}); + new Vue({ + el: '#content', + i18n, + }); } window.addEventListener("load", () => { @@ -67,8 +85,8 @@ window.App.util = { console.log('Unsupported method.'); }), }, - time: (function() { - return new Date; + time: (function() { + return new Date; }), version: 1, format: { @@ -79,29 +97,34 @@ window.App.util = { return new Intl.NumberFormat(locale, { notation: notation , compactDisplay: "short" }).format(count); }), timeAgo: (function(ts) { - let date = Date.parse(ts); - let seconds = Math.floor((new Date() - date) / 1000); - let interval = Math.floor(seconds / 63072000); - if (interval >= 1) { - return interval + "y"; - } - interval = Math.floor(seconds / 604800); - if (interval >= 1) { - return interval + "w"; - } - interval = Math.floor(seconds / 86400); - if (interval >= 1) { - return interval + "d"; - } - interval = Math.floor(seconds / 3600); - if (interval >= 1) { - return interval + "h"; - } - interval = Math.floor(seconds / 60); - if (interval >= 1) { - return interval + "m"; - } - return Math.floor(seconds) + "s"; + let date = new Date(ts); + let now = new Date(); + let seconds = Math.floor((now - date) / 1000); + let interval = Math.floor(seconds / 31536000); + if (interval >= 1) { + return new Intl.RelativeTimeFormat('pt', { numeric: 'auto', style: 'short' }).format(-interval, 'year'); + } + interval = Math.floor(seconds / 2592000); + if (interval >= 1) { + return new Intl.RelativeTimeFormat('pt', { numeric: 'auto', style: 'short' }).format(-interval, 'month'); + } + interval = Math.floor(seconds / 604800); + if (interval >= 1) { + return new Intl.RelativeTimeFormat('pt', { numeric: 'auto', style: 'short' }).format(-interval, 'week'); + } + interval = Math.floor(seconds / 86400); + if (interval >= 1) { + return new Intl.RelativeTimeFormat('pt', { numeric: 'auto', style: 'short' }).format(-interval, 'day'); + } + interval = Math.floor(seconds / 3600); + if (interval >= 1) { + return new Intl.RelativeTimeFormat('pt', { numeric: 'auto', style: 'short' }).format(-interval, 'hour'); + } + interval = Math.floor(seconds / 60); + if (interval >= 1) { + return new Intl.RelativeTimeFormat('pt', { numeric: 'auto', style: 'short' }).format(-interval, 'minute'); + } + return new Intl.RelativeTimeFormat('pt', { numeric: 'auto', style: 'short' }).format(-seconds, 'second'); }), timeAhead: (function(ts, short = true) { let date = Date.parse(ts); @@ -145,9 +168,9 @@ window.App.util = { tag = '/i/redirect?url=' + encodeURIComponent(tag); } - return tag; + return tag; }) - }, + }, filters: [ ['1984','filter-1977'], ['Azen','filter-aden'], diff --git a/resources/assets/js/components/Profile.vue b/resources/assets/js/components/Profile.vue index 991d4ab62..36c2a3dac 100644 --- a/resources/assets/js/components/Profile.vue +++ b/resources/assets/js/components/Profile.vue @@ -16,7 +16,7 @@
-

You are blocking this account

+

{{ $t("profile.blocking")}}

Click here to view profile

@@ -49,7 +49,7 @@

{{formatCount(profile.statuses_count)}}

-

Posts

+

{{ $t("profile.posts")}}

@@ -57,7 +57,7 @@ @@ -65,7 +65,7 @@ @@ -86,7 +86,7 @@

@@ -106,7 +106,7 @@ - Edit Profile + {{ $t("profile.editProfile") }} @@ -117,19 +117,19 @@
{{formatCount(profile.statuses_count)}} - Posts + {{ $t("profile.posts")}}
@@ -141,11 +141,11 @@

{{formatWebsite(profile.website)}}

- Admin + {{ $t("profile.admin") }} - Follows You + {{ $t("profile.followYou") }} - Joined {{joinedAtFormat(profile.created_at)}} + {{$t("profile.joined")}} {{joinedAtFormat(profile.created_at)}}

@@ -156,7 +156,7 @@

- + - - - - - - - - -

- - -
- - - - - -
- - -
- -
- -
-
-
- -
-
-
-
- - - -
-
- - -
-
- - -
-
-
-

- - {{formatCount(s.favourites_count)}} -

- -

- - {{formatCount(s.reply_count)}} -

- -

- - {{formatCount(s.reblogs_count)}} -

-
-
-
- - - - - - - {{ timeago(s.created_at) }} - -
- -
-
-
-
- - - -
-
- - -
-
- - -
-
-
-

- - {{formatCount(s.favourites_count)}} -

- -

- - {{formatCount(s.reply_count)}} -

- -

- - {{formatCount(s.reblogs_count)}} -

-
-
-
- - - {{ timeago(s.created_at) }} - +
+
+
+
+ + + + + + + + + +
+
+ +
+ + + + + +
+
+ +
+ +
+ +
- -
-
- -

{{ $t("profile.private")}}

-
- Only approved followers can see @{{ profile.acct }}'s
- posts. To request access, click Follow. -
-
-
- - - -
-
-
- -
- -
- - - -
-
- -
-
- -

{{ $t("profile.emptyLikes")}}

-
-
-
- -
-
-
- -
- -
- - - -
-
- -
-
- -

{{ $t("profile.emptyBookmarks")}}

-
-
-
- -
-
-
- -
- -
- - - -
-
- -
-
- -

{{ $t("profile.emptyArchives") }}

-
-
-
- - - - - - - - -
+ + + +
+ + +
+
+
+
+
+
+ +
+ +
+
+ +
+ + +
+ +
+
+
+ +
+
+
+ +

{{ $t('profile.emptyPosts') }}

+
+
+
+
+ +
+
+ +

{{ $t("profile.private")}}

+
+ Only approved followers can see @{{ profile.acct }}'s
+ posts. To request access, click Follow. +
+
+
+ + + +
+
+
+ +
+ +
+ + + +
+
+ +
+
+ +

{{ $t("profile.emptyLikes")}}

+
+
+
+ +
+
+
+ +
+ +
+ + + +
+
+ +
+
+ +

{{ $t("profile.emptyBookmarks")}}

+
+
+
+ +
+
+
+ +
+ +
+ + + +
+
+ +
+
+ +

{{ $t("profile.emptyArchives") }}

+
+
+
+ + + + + + + + + From d5582bcedfb82dd53a40a1ddd23990dd6d159354 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 5 Apr 2025 01:33:26 -0600 Subject: [PATCH 14/22] Update docker readme, closes #5909 --- docker/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/README.md b/docker/README.md index 5230f60fd..5598908c6 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,5 +1,5 @@ # Pixelfed + Docker + Docker Compose -Please see the [Pixelfed Docs (Next)](https://jippi.github.io/pixelfed-docs-next/pr-preview/pr-1/running-pixelfed/) for current documentation on Docker usage. +Please see the [Pixelfed Docs (Next)](https://jippi.github.io/docker-pixelfed/) for current documentation on Docker usage. The docs can be [reviewed in the pixelfed/docs-next](https://github.com/pixelfed/docs-next/pull/1) repository. From 0f1819125cf122c1584653b47368200ab6499a82 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 5 Apr 2025 02:30:17 -0600 Subject: [PATCH 15/22] Update post pinning, and dispatch Notification cache warming to a job, and fix reblogged state on some endpoints --- CHANGELOG.md | 9 +- app/Http/Controllers/Api/ApiV1Controller.php | 10 +- .../Controllers/Api/BaseApiController.php | 183 ++++++++---------- app/Http/Controllers/PublicApiController.php | 61 ++++++ .../NotificationWarmUserCache.php | 90 +++++++++ .../components/partials/post/ContextMenu.vue | 4 +- routes/api.php | 2 + routes/web-api.php | 4 +- 8 files changed, 252 insertions(+), 111 deletions(-) create mode 100644 app/Jobs/NotificationPipeline/NotificationWarmUserCache.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 586f1eed6..4c0e8ea3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,14 @@ # Release Notes -## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.3...dev) +## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev) + +### Added +- Pinned Posts ([2f655d000](https://github.com/pixelfed/pixelfed/commit/2f655d000)) + +### Updates - ([](https://github.com/pixelfed/pixelfed/commit/)) -## [v0.12.5 (2024-03-23)](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev) +## [v0.12.5 (2025-03-23)](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev) ### Added - Add app register email verify resends ([dbd1e17](https://github.com/pixelfed/pixelfed/commit/dbd1e17)) diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index 4497a2d22..cb51fdf97 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -26,6 +26,7 @@ use App\Jobs\ImageOptimizePipeline\ImageOptimize; use App\Jobs\LikePipeline\LikePipeline; use App\Jobs\MediaPipeline\MediaDeletePipeline; use App\Jobs\MediaPipeline\MediaSyncLicensePipeline; +use App\Jobs\NotificationPipeline\NotificationWarmUserCache; use App\Jobs\SharePipeline\SharePipeline; use App\Jobs\SharePipeline\UndoSharePipeline; use App\Jobs\StatusPipeline\NewStatusPipeline; @@ -2388,7 +2389,7 @@ class ApiV1Controller extends Controller if (empty($res)) { if (! Cache::has('pf:services:notifications:hasSynced:'.$pid)) { Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600); - NotificationService::warmCache($pid, 400, true); + NotificationWarmUserCache::dispatch($pid); } } @@ -4438,11 +4439,12 @@ class ApiV1Controller extends Controller } /** - * GET /api/v2/statuses/{id}/pin + * GET /api/v1/statuses/{id}/pin */ public function statusPin(Request $request, $id) { abort_if(! $request->user(), 403); + abort_unless($request->user()->tokenCan('write'), 403); $user = $request->user(); $status = Status::whereScope('public')->find($id); @@ -4469,12 +4471,12 @@ class ApiV1Controller extends Controller } /** - * GET /api/v2/statuses/{id}/unpin + * GET /api/v1/statuses/{id}/unpin */ public function statusUnpin(Request $request, $id) { - abort_if(! $request->user(), 403); + abort_unless($request->user()->tokenCan('write'), 403); $status = Status::whereScope('public')->findOrFail($id); $user = $request->user(); diff --git a/app/Http/Controllers/Api/BaseApiController.php b/app/Http/Controllers/Api/BaseApiController.php index 7ac73b4d0..4fd66690e 100644 --- a/app/Http/Controllers/Api/BaseApiController.php +++ b/app/Http/Controllers/Api/BaseApiController.php @@ -2,46 +2,22 @@ namespace App\Http\Controllers\Api; -use Illuminate\Http\Request; -use App\Http\Controllers\{ - Controller, - AvatarController -}; -use Auth, Cache, Storage, URL; -use Carbon\Carbon; -use App\{ - Avatar, - Like, - Media, - Notification, - Profile, - Status, - StatusArchived -}; -use App\Transformer\Api\{ - AccountTransformer, - NotificationTransformer, - MediaTransformer, - MediaDraftTransformer, - StatusTransformer, - StatusStatelessTransformer -}; -use League\Fractal; -use App\Util\Media\Filter; -use League\Fractal\Serializer\ArraySerializer; -use League\Fractal\Pagination\IlluminatePaginatorAdapter; +use App\Avatar; +use App\Http\Controllers\AvatarController; +use App\Http\Controllers\Controller; use App\Jobs\AvatarPipeline\AvatarOptimize; -use App\Jobs\ImageOptimizePipeline\ImageOptimize; -use App\Jobs\VideoPipeline\{ - VideoOptimize, - VideoPostProcess, - VideoThumbnail -}; +use App\Jobs\NotificationPipeline\NotificationWarmUserCache; use App\Services\AccountService; use App\Services\NotificationService; -use App\Services\MediaPathService; -use App\Services\MediaBlocklistService; use App\Services\StatusService; +use App\Status; +use App\StatusArchived; +use App\Transformer\Api\StatusStatelessTransformer; +use Auth; +use Cache; +use Illuminate\Http\Request; +use League\Fractal; +use League\Fractal\Serializer\ArraySerializer; class BaseApiController extends Controller { @@ -50,47 +26,47 @@ class BaseApiController extends Controller public function __construct() { // $this->middleware('auth'); - $this->fractal = new Fractal\Manager(); - $this->fractal->setSerializer(new ArraySerializer()); + $this->fractal = new Fractal\Manager; + $this->fractal->setSerializer(new ArraySerializer); } public function notifications(Request $request) { - abort_if(!$request->user(), 403); - - $pid = $request->user()->profile_id; - $limit = $request->input('limit', 20); - - $since = $request->input('since_id'); - $min = $request->input('min_id'); - $max = $request->input('max_id'); - - if(!$since && !$min && !$max) { - $min = 1; - } - - $maxId = null; - $minId = null; - - if($max) { - $res = NotificationService::getMax($pid, $max, $limit); - $ids = NotificationService::getRankedMaxId($pid, $max, $limit); - if(!empty($ids)) { - $maxId = max($ids); - $minId = min($ids); - } - } else { - $res = NotificationService::getMin($pid, $min ?? $since, $limit); - $ids = NotificationService::getRankedMinId($pid, $min ?? $since, $limit); - if(!empty($ids)) { - $maxId = max($ids); - $minId = min($ids); - } - } - - if(empty($res) && !Cache::has('pf:services:notifications:hasSynced:'.$pid)) { - Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600); - NotificationService::warmCache($pid, 100, true); + abort_if(! $request->user(), 403); + + $pid = $request->user()->profile_id; + $limit = $request->input('limit', 20); + + $since = $request->input('since_id'); + $min = $request->input('min_id'); + $max = $request->input('max_id'); + + if (! $since && ! $min && ! $max) { + $min = 1; + } + + $maxId = null; + $minId = null; + + if ($max) { + $res = NotificationService::getMax($pid, $max, $limit); + $ids = NotificationService::getRankedMaxId($pid, $max, $limit); + if (! empty($ids)) { + $maxId = max($ids); + $minId = min($ids); + } + } else { + $res = NotificationService::getMin($pid, $min ?? $since, $limit); + $ids = NotificationService::getRankedMinId($pid, $min ?? $since, $limit); + if (! empty($ids)) { + $maxId = max($ids); + $minId = min($ids); + } + } + + if (empty($res) && ! Cache::has('pf:services:notifications:hasSynced:'.$pid)) { + Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600); + NotificationWarmUserCache::dispatch($pid); } return response()->json($res); @@ -98,17 +74,17 @@ class BaseApiController extends Controller public function avatarUpdate(Request $request) { - abort_if(!$request->user(), 403); + abort_if(! $request->user(), 403); $this->validate($request, [ - 'upload' => 'required|mimetypes:image/jpeg,image/jpg,image/png|max:'.config('pixelfed.max_avatar_size'), + 'upload' => 'required|mimetypes:image/jpeg,image/jpg,image/png|max:'.config('pixelfed.max_avatar_size'), ]); try { $user = Auth::user(); $profile = $user->profile; $file = $request->file('upload'); - $path = (new AvatarController())->getPath($user, $file); + $path = (new AvatarController)->getPath($user, $file); $dir = $path['root']; $name = $path['name']; $public = $path['storage']; @@ -129,13 +105,13 @@ class BaseApiController extends Controller return response()->json([ 'code' => 200, - 'msg' => 'Avatar successfully updated', + 'msg' => 'Avatar successfully updated', ]); } public function verifyCredentials(Request $request) { - abort_if(!$request->user(), 403); + abort_if(! $request->user(), 403); $user = $request->user(); if ($user->status != null) { @@ -143,47 +119,51 @@ class BaseApiController extends Controller abort(403); } $res = AccountService::get($user->profile_id); + return response()->json($res); } public function accountLikes(Request $request) { - abort_if(!$request->user(), 403); + abort_if(! $request->user(), 403); $this->validate($request, [ - 'page' => 'sometimes|int|min:1|max:20', - 'limit' => 'sometimes|int|min:1|max:10' + 'page' => 'sometimes|int|min:1|max:20', + 'limit' => 'sometimes|int|min:1|max:10', ]); $user = $request->user(); $limit = $request->input('limit', 10); $res = \DB::table('likes') - ->whereProfileId($user->profile_id) - ->latest() - ->simplePaginate($limit) - ->map(function($id) { - $status = StatusService::get($id->status_id, false); - $status['favourited'] = true; - return $status; - }) - ->filter(function($post) { - return $post && isset($post['account']); - }) - ->values(); + ->whereProfileId($user->profile_id) + ->latest() + ->simplePaginate($limit) + ->map(function ($id) use ($user) { + $status = StatusService::get($id->status_id, false); + $status['favourited'] = true; + $status['reblogged'] = (bool) StatusService::isShared($id->status_id, $user->profile_id); + + return $status; + }) + ->filter(function ($post) { + return $post && isset($post['account']); + }) + ->values(); + return response()->json($res); } public function archive(Request $request, $id) { - abort_if(!$request->user(), 403); + abort_if(! $request->user(), 403); $status = Status::whereNull('in_reply_to_id') ->whereNull('reblog_of_id') ->whereProfileId($request->user()->profile_id) ->findOrFail($id); - if($status->scope === 'archived') { + if ($status->scope === 'archived') { return [200]; } @@ -204,14 +184,14 @@ class BaseApiController extends Controller public function unarchive(Request $request, $id) { - abort_if(!$request->user(), 403); + abort_if(! $request->user(), 403); $status = Status::whereNull('in_reply_to_id') ->whereNull('reblog_of_id') ->whereProfileId($request->user()->profile_id) ->findOrFail($id); - if($status->scope !== 'archived') { + if ($status->scope !== 'archived') { return [200]; } @@ -231,16 +211,17 @@ class BaseApiController extends Controller public function archivedPosts(Request $request) { - abort_if(!$request->user(), 403); + abort_if(! $request->user(), 403); $statuses = Status::whereProfileId($request->user()->profile_id) ->whereScope('archived') ->orderByDesc('id') ->simplePaginate(10); - $fractal = new Fractal\Manager(); - $fractal->setSerializer(new ArraySerializer()); - $resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer()); + $fractal = new Fractal\Manager; + $fractal->setSerializer(new ArraySerializer); + $resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer); + return $fractal->createData($resource)->toArray(); } } diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index dbdf4d1f7..972c26edf 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -35,6 +35,11 @@ class PublicApiController extends Controller $this->fractal->setSerializer(new ArraySerializer); } + public function json($res, $code = 200, $headers = []) + { + return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES); + } + protected function getUserData($user) { if (! $user) { @@ -726,6 +731,61 @@ class PublicApiController extends Controller return response()->json($result, 200, $headers); } + /** + * GET /api/pixelfed/v1/statuses/{id}/pin + */ + public function statusPin(Request $request, $id) + { + abort_if(! $request->user(), 403); + $user = $request->user(); + $status = Status::whereScope('public')->find($id); + + if (! $status) { + return $this->json(['error' => 'Record not found'], 404); + } + + if ($status->profile_id != $user->profile_id) { + return $this->json(['error' => "Validation failed: Someone else's post cannot be pinned"], 422); + } + + $res = StatusService::markPin($status->id); + + if (! $res['success']) { + return $this->json([ + 'error' => $res['error'], + ], 422); + } + + $statusRes = StatusService::get($status->id, true, true); + $status['pinned'] = true; + + return $this->json($statusRes); + } + + /** + * GET /api/pixelfed/v1/statuses/{id}/unpin + */ + public function statusUnpin(Request $request, $id) + { + abort_if(! $request->user(), 403); + $status = Status::whereScope('public')->findOrFail($id); + $user = $request->user(); + + if ($status->profile_id != $user->profile_id) { + return $this->json(['error' => 'Record not found'], 404); + } + + $res = StatusService::unmarkPin($status->id); + if (! $res) { + return $this->json($res, 422); + } + + $status = StatusService::get($status->id, true, true); + $status['pinned'] = false; + + return $this->json($status); + } + private function determineVisibility($profile, $user) { if ($profile['id'] == $user->profile_id) { @@ -768,6 +828,7 @@ class PublicApiController extends Controller if ($user) { $mastodonStatus['favourited'] = (bool) LikeService::liked($user->profile_id, $status->id); + $mastodonStatus['reblogged'] = (bool) StatusService::isShared($status->id, $user->profile_id); } return $mastodonStatus; diff --git a/app/Jobs/NotificationPipeline/NotificationWarmUserCache.php b/app/Jobs/NotificationPipeline/NotificationWarmUserCache.php new file mode 100644 index 000000000..8cf4b5aaa --- /dev/null +++ b/app/Jobs/NotificationPipeline/NotificationWarmUserCache.php @@ -0,0 +1,90 @@ +pid = $pid; + } + + /** + * Get the unique ID for the job. + */ + public function uniqueId(): string + { + return 'notifications:profile_warm_cache:'.$this->pid; + } + + /** + * Execute the job. + */ + public function handle(): void + { + try { + NotificationService::warmCache($this->pid, 100, true); + } catch (\Exception $e) { + Log::error('Failed to warm notification cache', [ + 'profile_id' => $this->pid, + 'exception' => get_class($e), + 'message' => $e->getMessage(), + 'attempt' => $this->attempts(), + ]); + throw $e; + } + } +} diff --git a/resources/assets/components/partials/post/ContextMenu.vue b/resources/assets/components/partials/post/ContextMenu.vue index 7260f9578..ef6e28a35 100644 --- a/resources/assets/components/partials/post/ContextMenu.vue +++ b/resources/assets/components/partials/post/ContextMenu.vue @@ -999,7 +999,7 @@ this.closeModals(); - axios.post('/api/v2/statuses/' + status.id.toString() + '/pin') + axios.post('/api/pixelfed/v1/statuses/' + status.id.toString() + '/pin') .then(res => { const data = res.data; if(data.id && data.pinned) { @@ -1023,7 +1023,7 @@ } this.closeModals(); - axios.post('/api/v2/statuses/' + status.id.toString() + '/unpin') + axios.post('/api/pixelfed/v1/statuses/' + status.id.toString() + '/unpin') .then(res => { const data = res.data; if(data.id) { diff --git a/routes/api.php b/routes/api.php index 23feb5ba7..d3c2355dd 100644 --- a/routes/api.php +++ b/routes/api.php @@ -147,6 +147,8 @@ Route::group(['prefix' => 'api'], function () use ($middleware) { Route::post('statuses/{id}/unreblog', 'Api\ApiV1Controller@statusUnshare')->middleware($middleware); Route::post('statuses/{id}/bookmark', 'Api\ApiV1Controller@bookmarkStatus')->middleware($middleware); Route::post('statuses/{id}/unbookmark', 'Api\ApiV1Controller@unbookmarkStatus')->middleware($middleware); + Route::post('statuses/{id}/pin', 'Api\ApiV1Controller@statusPin')->middleware($middleware); + Route::post('statuses/{id}/unpin', 'Api\ApiV1Controller@statusUnpin')->middleware($middleware); Route::delete('statuses/{id}', 'Api\ApiV1Controller@statusDelete')->middleware($middleware); Route::get('statuses/{id}', 'Api\ApiV1Controller@statusById')->middleware($middleware); Route::post('statuses', 'Api\ApiV1Controller@statusCreate')->middleware($middleware); diff --git a/routes/web-api.php b/routes/web-api.php index 20d12148b..69b05a202 100644 --- a/routes/web-api.php +++ b/routes/web-api.php @@ -58,8 +58,6 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::get('discover/tag', 'DiscoverController@getHashtags'); Route::get('statuses/{id}/replies', 'Api\ApiV1Controller@statusReplies'); Route::get('statuses/{id}/state', 'Api\ApiV1Controller@statusState'); - Route::post('statuses/{id}/pin', 'Api\ApiV1Controller@statusPin'); - Route::post('statuses/{id}/unpin', 'Api\ApiV1Controller@statusUnpin'); }); Route::group(['prefix' => 'pixelfed'], function() { @@ -71,6 +69,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::post('accounts/{id}/block', 'Api\ApiV1Controller@accountBlockById'); Route::post('accounts/{id}/unblock', 'Api\ApiV1Controller@accountUnblockById'); Route::get('statuses/{id}', 'PublicApiController@getStatus'); + Route::post('statuses/{id}/pin', 'PublicApiController@statusPin'); + Route::post('statuses/{id}/unpin', 'PublicApiController@statusUnpin'); Route::get('accounts/{id}', 'PublicApiController@account'); Route::post('avatar/update', 'ApiController@avatarUpdate'); Route::get('custom_emojis', 'Api\ApiV1Controller@customEmojis'); From 8082c004bc8367c6c295f3fed1d81ab9ce51c543 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 5 Apr 2025 02:54:54 -0600 Subject: [PATCH 16/22] Refactor following check --- app/Http/Controllers/AccountController.php | 1148 +++++++++--------- app/Http/Controllers/PublicApiController.php | 16 +- 2 files changed, 585 insertions(+), 579 deletions(-) diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 7000ace07..9ef510296 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -2,592 +2,592 @@ namespace App\Http\Controllers; +use App\EmailVerification; +use App\Follower; +use App\FollowRequest; +use App\Jobs\FollowPipeline\FollowAcceptPipeline; +use App\Jobs\FollowPipeline\FollowPipeline; +use App\Jobs\FollowPipeline\FollowRejectPipeline; +use App\Mail\ConfirmEmail; +use App\Notification; +use App\Profile; +use App\Services\AccountService; +use App\Services\FollowerService; +use App\Services\NotificationService; +use App\Services\RelationshipService; +use App\Services\UserFilterService; +use App\Transformer\Api\Mastodon\v1\AccountTransformer; +use App\User; +use App\UserFilter; use Auth; use Cache; -use Mail; -use Illuminate\Support\Facades\Redis; -use Illuminate\Support\Str; use Carbon\Carbon; -use App\Mail\ConfirmEmail; use Illuminate\Http\Request; -use PragmaRX\Google2FA\Google2FA; -use App\Jobs\FollowPipeline\FollowPipeline; -use App\{ - DirectMessage, - EmailVerification, - Follower, - FollowRequest, - Media, - Notification, - Profile, - User, - UserDevice, - UserFilter, - UserSetting -}; +use Illuminate\Support\Str; use League\Fractal; use League\Fractal\Serializer\ArraySerializer; -use League\Fractal\Pagination\IlluminatePaginatorAdapter; -use App\Transformer\Api\Mastodon\v1\AccountTransformer; -use App\Services\AccountService; -use App\Services\FollowerService; -use App\Services\NotificationService; -use App\Services\UserFilterService; -use App\Services\RelationshipService; -use App\Jobs\FollowPipeline\FollowAcceptPipeline; -use App\Jobs\FollowPipeline\FollowRejectPipeline; +use Mail; +use PragmaRX\Google2FA\Google2FA; class AccountController extends Controller { - protected $filters = [ - 'user.mute', - 'user.block', - ]; - - const FILTER_LIMIT_MUTE_TEXT = 'You cannot mute more than '; - const FILTER_LIMIT_BLOCK_TEXT = 'You cannot block more than '; - - public function __construct() - { - $this->middleware('auth'); - } - - public function notifications(Request $request) - { - return view('account.activity'); - } - - public function followingActivity(Request $request) - { - $this->validate($request, [ - 'page' => 'nullable|min:1|max:3', - 'a' => 'nullable|alpha_dash', - ]); - - $action = $request->input('a'); - $allowed = ['like', 'follow']; - $timeago = Carbon::now()->subMonths(3); - - $profile = Auth::user()->profile; - $following = $profile->following->pluck('id'); - - $notifications = Notification::whereIn('actor_id', $following) - ->whereIn('action', $allowed) - ->where('actor_id', '<>', $profile->id) - ->where('profile_id', '<>', $profile->id) - ->whereDate('created_at', '>', $timeago) - ->orderBy('notifications.created_at', 'desc') - ->simplePaginate(30); - - return view('account.following', compact('profile', 'notifications')); - } - - public function verifyEmail(Request $request) - { - $recentSent = EmailVerification::whereUserId(Auth::id()) - ->whereDate('created_at', '>', now()->subHours(12))->count(); - - return view('account.verify_email', compact('recentSent')); - } - - public function sendVerifyEmail(Request $request) - { - $recentAttempt = EmailVerification::whereUserId(Auth::id()) - ->whereDate('created_at', '>', now()->subHours(12))->count(); - - if ($recentAttempt > 0) { - return redirect()->back()->with('error', 'A verification email has already been sent recently. Please check your email, or try again later.'); - } - - EmailVerification::whereUserId(Auth::id())->delete(); - - $user = User::whereNull('email_verified_at')->find(Auth::id()); - $utoken = Str::uuid() . Str::random(mt_rand(5,9)); - $rtoken = Str::random(mt_rand(64, 70)); - - $verify = new EmailVerification(); - $verify->user_id = $user->id; - $verify->email = $user->email; - $verify->user_token = $utoken; - $verify->random_token = $rtoken; - $verify->save(); - - Mail::to($user->email)->send(new ConfirmEmail($verify)); - - return redirect()->back()->with('status', 'Verification email sent!'); - } - - public function confirmVerifyEmail(Request $request, $userToken, $randomToken) - { - $verify = EmailVerification::where('user_token', $userToken) - ->where('created_at', '>', now()->subHours(24)) - ->where('random_token', $randomToken) - ->firstOrFail(); - - if (Auth::id() === $verify->user_id && $verify->user_token === $userToken && $verify->random_token === $randomToken) { - $user = User::find(Auth::id()); - $user->email_verified_at = Carbon::now(); - $user->save(); - - return redirect('/'); - } else { - abort(403); - } - } - - public function direct() - { - return view('account.direct'); - } - - public function directMessage(Request $request, $id) - { - $profile = Profile::where('id', '!=', $request->user()->profile_id) - // ->whereNull('domain') - ->findOrFail($id); - return view('account.directmessage', compact('id')); - } - - public function mute(Request $request) - { - $this->validate($request, [ - 'type' => 'required|string|in:user', - 'item' => 'required|integer|min:1', - ]); - - $pid = $request->user()->profile_id; - $count = UserFilterService::muteCount($pid); - $maxLimit = (int) config_cache('instance.user_filters.max_user_mutes'); - abort_if($count >= $maxLimit, 422, self::FILTER_LIMIT_MUTE_TEXT . $maxLimit . ' accounts'); - if($count == 0) { - $filterCount = UserFilter::whereUserId($pid)->count(); - abort_if($filterCount >= $maxLimit, 422, self::FILTER_LIMIT_MUTE_TEXT . $maxLimit . ' accounts'); - } - $type = $request->input('type'); - $item = $request->input('item'); - $action = $type . '.mute'; - - if (!in_array($action, $this->filters)) { - return abort(406); - } - $filterable = []; - switch ($type) { - case 'user': - $profile = Profile::findOrFail($item); - if ($profile->id == $pid) { - return abort(403); - } - $class = get_class($profile); - $filterable['id'] = $profile->id; - $filterable['type'] = $class; - break; - } - - $filter = UserFilter::firstOrCreate([ - 'user_id' => $pid, - 'filterable_id' => $filterable['id'], - 'filterable_type' => $filterable['type'], - 'filter_type' => 'mute', - ]); - - UserFilterService::mute($pid, $filterable['id']); - $res = RelationshipService::refresh($pid, $profile->id); - - if($request->wantsJson()) { - return response()->json($res); - } else { - return redirect()->back(); - } - } - - public function unmute(Request $request) - { - $this->validate($request, [ - 'type' => 'required|string|in:user', - 'item' => 'required|integer|min:1', - ]); - - $pid = $request->user()->profile_id; - $type = $request->input('type'); - $item = $request->input('item'); - $action = $type . '.mute'; - - if (!in_array($action, $this->filters)) { - return abort(406); - } - $filterable = []; - switch ($type) { - case 'user': - $profile = Profile::findOrFail($item); - if ($profile->id == $pid) { - return abort(403); - } - $class = get_class($profile); - $filterable['id'] = $profile->id; - $filterable['type'] = $class; - break; - - default: - abort(400); - break; - } - - $filter = UserFilter::whereUserId($pid) - ->whereFilterableId($filterable['id']) - ->whereFilterableType($filterable['type']) - ->whereFilterType('mute') - ->first(); - - if($filter) { - UserFilterService::unmute($pid, $filterable['id']); - $filter->delete(); - } - - $res = RelationshipService::refresh($pid, $profile->id); - - if($request->wantsJson()) { - return response()->json($res); - } else { - return redirect()->back(); - } - } - - public function block(Request $request) - { - $this->validate($request, [ - 'type' => 'required|string|in:user', - 'item' => 'required|integer|min:1', - ]); - $pid = $request->user()->profile_id; - $count = UserFilterService::blockCount($pid); - $maxLimit = (int) config_cache('instance.user_filters.max_user_blocks'); - abort_if($count >= $maxLimit, 422, self::FILTER_LIMIT_BLOCK_TEXT . $maxLimit . ' accounts'); - if($count == 0) { - $filterCount = UserFilter::whereUserId($pid)->whereFilterType('block')->count(); - abort_if($filterCount >= $maxLimit, 422, self::FILTER_LIMIT_BLOCK_TEXT . $maxLimit . ' accounts'); - } - $type = $request->input('type'); - $item = $request->input('item'); - $action = $type.'.block'; - if (!in_array($action, $this->filters)) { - return abort(406); - } - $filterable = []; - switch ($type) { - case 'user': - $profile = Profile::findOrFail($item); - if ($profile->id == $pid || ($profile->user && $profile->user->is_admin == true)) { - return abort(403); - } - $class = get_class($profile); - $filterable['id'] = $profile->id; - $filterable['type'] = $class; - - $followed = Follower::whereProfileId($profile->id)->whereFollowingId($pid)->first(); - if($followed) { - $followed->delete(); - $profile->following_count = Follower::whereProfileId($profile->id)->count(); - $profile->save(); - $selfProfile = $request->user()->profile; - $selfProfile->followers_count = Follower::whereFollowingId($pid)->count(); - $selfProfile->save(); - FollowerService::remove($profile->id, $pid); - AccountService::del($pid); - AccountService::del($profile->id); - } - - $following = Follower::whereProfileId($pid)->whereFollowingId($profile->id)->first(); - if($following) { - $following->delete(); - $profile->followers_count = Follower::whereFollowingId($profile->id)->count(); - $profile->save(); - $selfProfile = $request->user()->profile; - $selfProfile->following_count = Follower::whereProfileId($pid)->count(); - $selfProfile->save(); - FollowerService::remove($pid, $profile->pid); - AccountService::del($pid); - AccountService::del($profile->id); - } - - Notification::whereProfileId($pid) - ->whereActorId($profile->id) - ->get() - ->map(function($n) use($pid) { - NotificationService::del($pid, $n['id']); - $n->forceDelete(); - }); - break; - } - - $filter = UserFilter::firstOrCreate([ - 'user_id' => $pid, - 'filterable_id' => $filterable['id'], - 'filterable_type' => $filterable['type'], - 'filter_type' => 'block', - ]); - - UserFilterService::block($pid, $filterable['id']); - $res = RelationshipService::refresh($pid, $profile->id); - - if($request->wantsJson()) { - return response()->json($res); - } else { - return redirect()->back(); - } - } - - public function unblock(Request $request) - { - $this->validate($request, [ - 'type' => 'required|string|in:user', - 'item' => 'required|integer|min:1', - ]); - - $pid = $request->user()->profile_id; - $type = $request->input('type'); - $item = $request->input('item'); - $action = $type . '.block'; - if (!in_array($action, $this->filters)) { - return abort(406); - } - $filterable = []; - switch ($type) { - case 'user': - $profile = Profile::findOrFail($item); - if ($profile->id == $pid) { - return abort(403); - } - $class = get_class($profile); - $filterable['id'] = $profile->id; - $filterable['type'] = $class; - break; - - default: - abort(400); - break; - } - - - $filter = UserFilter::whereUserId($pid) - ->whereFilterableId($filterable['id']) - ->whereFilterableType($filterable['type']) - ->whereFilterType('block') - ->first(); - - if($filter) { - $filter->delete(); - UserFilterService::unblock($pid, $filterable['id']); - } - - $res = RelationshipService::refresh($pid, $profile->id); - - if($request->wantsJson()) { - return response()->json($res); - } else { - return redirect()->back(); - } - } - - public function followRequests(Request $request) - { - $pid = Auth::user()->profile->id; - $followers = FollowRequest::whereFollowingId($pid)->orderBy('id','desc')->whereIsRejected(0)->simplePaginate(10); - return view('account.follow-requests', compact('followers')); - } - - public function followRequestsJson(Request $request) - { - $pid = Auth::user()->profile_id; - $followers = FollowRequest::whereFollowingId($pid)->orderBy('id','desc')->whereIsRejected(0)->get(); - $res = [ - 'count' => $followers->count(), - 'accounts' => $followers->take(10)->map(function($a) { - $actor = $a->actor; - return [ - 'rid' => (string) $a->id, - 'id' => (string) $actor->id, - 'username' => $actor->username, - 'avatar' => $actor->avatarUrl(), - 'url' => $actor->url(), - 'local' => $actor->domain == null, - 'account' => AccountService::get($actor->id) - ]; - }) - ]; - return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); - } - - public function followRequestHandle(Request $request) - { - $this->validate($request, [ - 'action' => 'required|string|max:10', - 'id' => 'required|integer|min:1' - ]); - - $pid = Auth::user()->profile->id; - $action = $request->input('action') === 'accept' ? 'accept' : 'reject'; - $id = $request->input('id'); - $followRequest = FollowRequest::whereFollowingId($pid)->findOrFail($id); - $follower = $followRequest->follower; - - switch ($action) { - case 'accept': - $follow = new Follower(); - $follow->profile_id = $follower->id; - $follow->following_id = $pid; - $follow->save(); - - $profile = Profile::findOrFail($pid); - $profile->followers_count++; - $profile->save(); - AccountService::del($profile->id); - - $profile = Profile::findOrFail($follower->id); - $profile->following_count++; - $profile->save(); - AccountService::del($profile->id); - - if($follower->domain != null && $follower->private_key === null) { - FollowAcceptPipeline::dispatch($followRequest)->onQueue('follow'); - } else { - FollowPipeline::dispatch($follow); - $followRequest->delete(); - } - break; - - case 'reject': - if($follower->domain != null && $follower->private_key === null) { - FollowRejectPipeline::dispatch($followRequest)->onQueue('follow'); - } else { - $followRequest->delete(); - } - break; - } - - Cache::forget('profile:follower_count:'.$pid); - Cache::forget('profile:following_count:'.$pid); - RelationshipService::refresh($pid, $follower->id); - - return response()->json(['msg' => 'success'], 200); - } - - public function sudoMode(Request $request) - { - if($request->session()->has('sudoModeAttempts') && $request->session()->get('sudoModeAttempts') >= 3) { - $request->session()->pull('2fa.session.active'); + protected $filters = [ + 'user.mute', + 'user.block', + ]; + + const FILTER_LIMIT_MUTE_TEXT = 'You cannot mute more than '; + + const FILTER_LIMIT_BLOCK_TEXT = 'You cannot block more than '; + + public function __construct() + { + $this->middleware('auth'); + } + + public function notifications(Request $request) + { + return view('account.activity'); + } + + public function followingActivity(Request $request) + { + $this->validate($request, [ + 'page' => 'nullable|min:1|max:3', + 'a' => 'nullable|alpha_dash', + ]); + + $action = $request->input('a'); + $allowed = ['like', 'follow']; + $timeago = Carbon::now()->subMonths(3); + + $profile = Auth::user()->profile; + $following = $profile->following->pluck('id'); + + $notifications = Notification::whereIn('actor_id', $following) + ->whereIn('action', $allowed) + ->where('actor_id', '<>', $profile->id) + ->where('profile_id', '<>', $profile->id) + ->whereDate('created_at', '>', $timeago) + ->orderBy('notifications.created_at', 'desc') + ->simplePaginate(30); + + return view('account.following', compact('profile', 'notifications')); + } + + public function verifyEmail(Request $request) + { + $recentSent = EmailVerification::whereUserId(Auth::id()) + ->whereDate('created_at', '>', now()->subHours(12))->count(); + + return view('account.verify_email', compact('recentSent')); + } + + public function sendVerifyEmail(Request $request) + { + $recentAttempt = EmailVerification::whereUserId(Auth::id()) + ->whereDate('created_at', '>', now()->subHours(12))->count(); + + if ($recentAttempt > 0) { + return redirect()->back()->with('error', 'A verification email has already been sent recently. Please check your email, or try again later.'); + } + + EmailVerification::whereUserId(Auth::id())->delete(); + + $user = User::whereNull('email_verified_at')->find(Auth::id()); + $utoken = Str::uuid().Str::random(mt_rand(5, 9)); + $rtoken = Str::random(mt_rand(64, 70)); + + $verify = new EmailVerification; + $verify->user_id = $user->id; + $verify->email = $user->email; + $verify->user_token = $utoken; + $verify->random_token = $rtoken; + $verify->save(); + + Mail::to($user->email)->send(new ConfirmEmail($verify)); + + return redirect()->back()->with('status', 'Verification email sent!'); + } + + public function confirmVerifyEmail(Request $request, $userToken, $randomToken) + { + $verify = EmailVerification::where('user_token', $userToken) + ->where('created_at', '>', now()->subHours(24)) + ->where('random_token', $randomToken) + ->firstOrFail(); + + if (Auth::id() === $verify->user_id && $verify->user_token === $userToken && $verify->random_token === $randomToken) { + $user = User::find(Auth::id()); + $user->email_verified_at = Carbon::now(); + $user->save(); + + return redirect('/'); + } else { + abort(403); + } + } + + public function direct() + { + return view('account.direct'); + } + + public function directMessage(Request $request, $id) + { + $profile = Profile::where('id', '!=', $request->user()->profile_id) + ->findOrFail($id); + + return view('account.directmessage', compact('id')); + } + + public function mute(Request $request) + { + $this->validate($request, [ + 'type' => 'required|string|in:user', + 'item' => 'required|integer|min:1', + ]); + + $pid = $request->user()->profile_id; + $count = UserFilterService::muteCount($pid); + $maxLimit = (int) config_cache('instance.user_filters.max_user_mutes'); + abort_if($count >= $maxLimit, 422, self::FILTER_LIMIT_MUTE_TEXT.$maxLimit.' accounts'); + if ($count == 0) { + $filterCount = UserFilter::whereUserId($pid)->count(); + abort_if($filterCount >= $maxLimit, 422, self::FILTER_LIMIT_MUTE_TEXT.$maxLimit.' accounts'); + } + $type = $request->input('type'); + $item = $request->input('item'); + $action = $type.'.mute'; + + if (! in_array($action, $this->filters)) { + return abort(406); + } + $filterable = []; + switch ($type) { + case 'user': + $profile = Profile::findOrFail($item); + if ($profile->id == $pid) { + return abort(403); + } + $class = get_class($profile); + $filterable['id'] = $profile->id; + $filterable['type'] = $class; + break; + } + + $filter = UserFilter::firstOrCreate([ + 'user_id' => $pid, + 'filterable_id' => $filterable['id'], + 'filterable_type' => $filterable['type'], + 'filter_type' => 'mute', + ]); + + UserFilterService::mute($pid, $filterable['id']); + $res = RelationshipService::refresh($pid, $profile->id); + + if ($request->wantsJson()) { + return response()->json($res); + } else { + return redirect()->back(); + } + } + + public function unmute(Request $request) + { + $this->validate($request, [ + 'type' => 'required|string|in:user', + 'item' => 'required|integer|min:1', + ]); + + $pid = $request->user()->profile_id; + $type = $request->input('type'); + $item = $request->input('item'); + $action = $type.'.mute'; + + if (! in_array($action, $this->filters)) { + return abort(406); + } + $filterable = []; + switch ($type) { + case 'user': + $profile = Profile::findOrFail($item); + if ($profile->id == $pid) { + return abort(403); + } + $class = get_class($profile); + $filterable['id'] = $profile->id; + $filterable['type'] = $class; + break; + + default: + abort(400); + break; + } + + $filter = UserFilter::whereUserId($pid) + ->whereFilterableId($filterable['id']) + ->whereFilterableType($filterable['type']) + ->whereFilterType('mute') + ->first(); + + if ($filter) { + UserFilterService::unmute($pid, $filterable['id']); + $filter->delete(); + } + + $res = RelationshipService::refresh($pid, $profile->id); + + if ($request->wantsJson()) { + return response()->json($res); + } else { + return redirect()->back(); + } + } + + public function block(Request $request) + { + $this->validate($request, [ + 'type' => 'required|string|in:user', + 'item' => 'required|integer|min:1', + ]); + $pid = $request->user()->profile_id; + $count = UserFilterService::blockCount($pid); + $maxLimit = (int) config_cache('instance.user_filters.max_user_blocks'); + abort_if($count >= $maxLimit, 422, self::FILTER_LIMIT_BLOCK_TEXT.$maxLimit.' accounts'); + if ($count == 0) { + $filterCount = UserFilter::whereUserId($pid)->whereFilterType('block')->count(); + abort_if($filterCount >= $maxLimit, 422, self::FILTER_LIMIT_BLOCK_TEXT.$maxLimit.' accounts'); + } + $type = $request->input('type'); + $item = $request->input('item'); + $action = $type.'.block'; + if (! in_array($action, $this->filters)) { + return abort(406); + } + $filterable = []; + switch ($type) { + case 'user': + $profile = Profile::findOrFail($item); + if ($profile->id == $pid || ($profile->user && $profile->user->is_admin == true)) { + return abort(403); + } + $class = get_class($profile); + $filterable['id'] = $profile->id; + $filterable['type'] = $class; + + $followed = Follower::whereProfileId($profile->id)->whereFollowingId($pid)->first(); + if ($followed) { + $followed->delete(); + $profile->following_count = Follower::whereProfileId($profile->id)->count(); + $profile->save(); + $selfProfile = $request->user()->profile; + $selfProfile->followers_count = Follower::whereFollowingId($pid)->count(); + $selfProfile->save(); + FollowerService::remove($profile->id, $pid); + AccountService::del($pid); + AccountService::del($profile->id); + } + + $following = Follower::whereProfileId($pid)->whereFollowingId($profile->id)->first(); + if ($following) { + $following->delete(); + $profile->followers_count = Follower::whereFollowingId($profile->id)->count(); + $profile->save(); + $selfProfile = $request->user()->profile; + $selfProfile->following_count = Follower::whereProfileId($pid)->count(); + $selfProfile->save(); + FollowerService::remove($pid, $profile->pid); + AccountService::del($pid); + AccountService::del($profile->id); + } + + Notification::whereProfileId($pid) + ->whereActorId($profile->id) + ->get() + ->map(function ($n) use ($pid) { + NotificationService::del($pid, $n['id']); + $n->forceDelete(); + }); + break; + } + + $filter = UserFilter::firstOrCreate([ + 'user_id' => $pid, + 'filterable_id' => $filterable['id'], + 'filterable_type' => $filterable['type'], + 'filter_type' => 'block', + ]); + + UserFilterService::block($pid, $filterable['id']); + $res = RelationshipService::refresh($pid, $profile->id); + + if ($request->wantsJson()) { + return response()->json($res); + } else { + return redirect()->back(); + } + } + + public function unblock(Request $request) + { + $this->validate($request, [ + 'type' => 'required|string|in:user', + 'item' => 'required|integer|min:1', + ]); + + $pid = $request->user()->profile_id; + $type = $request->input('type'); + $item = $request->input('item'); + $action = $type.'.block'; + if (! in_array($action, $this->filters)) { + return abort(406); + } + $filterable = []; + switch ($type) { + case 'user': + $profile = Profile::findOrFail($item); + if ($profile->id == $pid) { + return abort(403); + } + $class = get_class($profile); + $filterable['id'] = $profile->id; + $filterable['type'] = $class; + break; + + default: + abort(400); + break; + } + + $filter = UserFilter::whereUserId($pid) + ->whereFilterableId($filterable['id']) + ->whereFilterableType($filterable['type']) + ->whereFilterType('block') + ->first(); + + if ($filter) { + $filter->delete(); + UserFilterService::unblock($pid, $filterable['id']); + } + + $res = RelationshipService::refresh($pid, $profile->id); + + if ($request->wantsJson()) { + return response()->json($res); + } else { + return redirect()->back(); + } + } + + public function followRequests(Request $request) + { + $pid = Auth::user()->profile->id; + $followers = FollowRequest::whereFollowingId($pid)->orderBy('id', 'desc')->whereIsRejected(0)->simplePaginate(10); + + return view('account.follow-requests', compact('followers')); + } + + public function followRequestsJson(Request $request) + { + $pid = Auth::user()->profile_id; + $followers = FollowRequest::whereFollowingId($pid)->orderBy('id', 'desc')->whereIsRejected(0)->get(); + $res = [ + 'count' => $followers->count(), + 'accounts' => $followers->take(10)->map(function ($a) { + $actor = $a->actor; + + return [ + 'rid' => (string) $a->id, + 'id' => (string) $actor->id, + 'username' => $actor->username, + 'avatar' => $actor->avatarUrl(), + 'url' => $actor->url(), + 'local' => $actor->domain == null, + 'account' => AccountService::get($actor->id), + ]; + }), + ]; + + return response()->json($res, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + public function followRequestHandle(Request $request) + { + $this->validate($request, [ + 'action' => 'required|string|max:10', + 'id' => 'required|integer|min:1', + ]); + + $pid = Auth::user()->profile->id; + $action = $request->input('action') === 'accept' ? 'accept' : 'reject'; + $id = $request->input('id'); + $followRequest = FollowRequest::whereFollowingId($pid)->findOrFail($id); + $follower = $followRequest->follower; + + switch ($action) { + case 'accept': + $follow = new Follower; + $follow->profile_id = $follower->id; + $follow->following_id = $pid; + $follow->save(); + + $profile = Profile::findOrFail($pid); + $profile->followers_count++; + $profile->save(); + AccountService::del($profile->id); + + $profile = Profile::findOrFail($follower->id); + $profile->following_count++; + $profile->save(); + AccountService::del($profile->id); + + if ($follower->domain != null && $follower->private_key === null) { + FollowAcceptPipeline::dispatch($followRequest)->onQueue('follow'); + } else { + FollowPipeline::dispatch($follow); + $followRequest->delete(); + } + break; + + case 'reject': + if ($follower->domain != null && $follower->private_key === null) { + FollowRejectPipeline::dispatch($followRequest)->onQueue('follow'); + } else { + $followRequest->delete(); + } + break; + } + + Cache::forget('profile:follower_count:'.$pid); + Cache::forget('profile:following_count:'.$pid); + RelationshipService::refresh($pid, $follower->id); + + return response()->json(['msg' => 'success'], 200); + } + + public function sudoMode(Request $request) + { + if ($request->session()->has('sudoModeAttempts') && $request->session()->get('sudoModeAttempts') >= 3) { + $request->session()->pull('2fa.session.active'); $request->session()->pull('redirectNext'); $request->session()->pull('sudoModeAttempts'); Auth::logout(); + return redirect(route('login')); } - return view('auth.sudo'); - } - - public function sudoModeVerify(Request $request) - { - $this->validate($request, [ - 'password' => 'required|string|max:500', - 'trustDevice' => 'nullable' - ]); - - $user = Auth::user(); - $password = $request->input('password'); - $trustDevice = $request->input('trustDevice') == 'on'; - $next = $request->session()->get('redirectNext', '/'); - if($request->session()->has('sudoModeAttempts')) { - $count = (int) $request->session()->get('sudoModeAttempts'); - $request->session()->put('sudoModeAttempts', $count + 1); - } else { - $request->session()->put('sudoModeAttempts', 1); - } - if(password_verify($password, $user->password) === true) { - $request->session()->put('sudoMode', time()); - if($trustDevice == true) { - $request->session()->put('sudoTrustDevice', 1); - } - - //Fix wrong scheme when using reverse proxy - if(!str_contains($next, 'https') && config('instance.force_https_urls', true)) { + + return view('auth.sudo'); + } + + public function sudoModeVerify(Request $request) + { + $this->validate($request, [ + 'password' => 'required|string|max:500', + 'trustDevice' => 'nullable', + ]); + + $user = Auth::user(); + $password = $request->input('password'); + $trustDevice = $request->input('trustDevice') == 'on'; + $next = $request->session()->get('redirectNext', '/'); + if ($request->session()->has('sudoModeAttempts')) { + $count = (int) $request->session()->get('sudoModeAttempts'); + $request->session()->put('sudoModeAttempts', $count + 1); + } else { + $request->session()->put('sudoModeAttempts', 1); + } + if (password_verify($password, $user->password) === true) { + $request->session()->put('sudoMode', time()); + if ($trustDevice == true) { + $request->session()->put('sudoTrustDevice', 1); + } + + // Fix wrong scheme when using reverse proxy + if (! str_contains($next, 'https') && config('instance.force_https_urls', true)) { $next = Str::of($next)->replace('http', 'https')->toString(); } - return redirect($next); - } else { - return redirect() - ->back() - ->withErrors(['password' => __('auth.failed')]); - } - } - - public function twoFactorCheckpoint(Request $request) - { - return view('auth.checkpoint'); - } - - public function twoFactorVerify(Request $request) - { - $this->validate($request, [ - 'code' => 'required|string|max:32' - ]); - $user = Auth::user(); - $code = $request->input('code'); - $google2fa = new Google2FA(); - $verify = $google2fa->verifyKey($user->{'2fa_secret'}, $code); - if($verify) { - $request->session()->push('2fa.session.active', true); - return redirect('/'); - } else { - - if($this->twoFactorBackupCheck($request, $code, $user)) { - return redirect('/'); - } - - if($request->session()->has('2fa.attempts')) { - $count = (int) $request->session()->get('2fa.attempts'); - if($count == 3) { - Auth::logout(); - return redirect('/'); - } - $request->session()->put('2fa.attempts', $count + 1); - } else { - $request->session()->put('2fa.attempts', 1); - } - return redirect('/i/auth/checkpoint')->withErrors([ - 'code' => 'Invalid code' - ]); - } - } - - protected function twoFactorBackupCheck($request, $code, User $user) - { - $backupCodes = $user->{'2fa_backup_codes'}; - if($backupCodes) { - $codes = json_decode($backupCodes, true); - foreach ($codes as $c) { - if(hash_equals($c, $code)) { - $codes = array_flatten(array_diff($codes, [$code])); - $user->{'2fa_backup_codes'} = json_encode($codes); - $user->save(); - $request->session()->push('2fa.session.active', true); - return true; - } - } + return redirect($next); + } else { + return redirect() + ->back() + ->withErrors(['password' => __('auth.failed')]); + } + } + + public function twoFactorCheckpoint(Request $request) + { + return view('auth.checkpoint'); + } + + public function twoFactorVerify(Request $request) + { + $this->validate($request, [ + 'code' => 'required|string|max:32', + ]); + $user = Auth::user(); + $code = $request->input('code'); + $google2fa = new Google2FA; + $verify = $google2fa->verifyKey($user->{'2fa_secret'}, $code); + if ($verify) { + $request->session()->push('2fa.session.active', true); + + return redirect('/'); + } else { + + if ($this->twoFactorBackupCheck($request, $code, $user)) { + return redirect('/'); + } + + if ($request->session()->has('2fa.attempts')) { + $count = (int) $request->session()->get('2fa.attempts'); + if ($count == 3) { + Auth::logout(); + + return redirect('/'); + } + $request->session()->put('2fa.attempts', $count + 1); + } else { + $request->session()->put('2fa.attempts', 1); + } + + return redirect('/i/auth/checkpoint')->withErrors([ + 'code' => 'Invalid code', + ]); + } + } + + protected function twoFactorBackupCheck($request, $code, User $user) + { + $backupCodes = $user->{'2fa_backup_codes'}; + if ($backupCodes) { + $codes = json_decode($backupCodes, true); + foreach ($codes as $c) { + if (hash_equals($c, $code)) { + $codes = array_flatten(array_diff($codes, [$code])); + $user->{'2fa_backup_codes'} = json_encode($codes); + $user->save(); + $request->session()->push('2fa.session.active', true); + + return true; + } + } + return false; - } else { - return false; - } - } + } else { + return false; + } + } - public function accountRestored(Request $request) - { - } + public function accountRestored(Request $request) {} - public function accountMutes(Request $request) + public function accountMutes(Request $request) { - abort_if(!$request->user(), 403); + abort_if(! $request->user(), 403); $this->validate($request, [ - 'limit' => 'nullable|integer|min:1|max:40' + 'limit' => 'nullable|integer|min:1|max:40', ]); $user = $request->user(); @@ -600,31 +600,32 @@ class AccountController extends Controller ->pluck('filterable_id'); $accounts = Profile::find($mutes); - $fractal = new Fractal\Manager(); - $fractal->setSerializer(new ArraySerializer()); - $resource = new Fractal\Resource\Collection($accounts, new AccountTransformer()); + $fractal = new Fractal\Manager; + $fractal->setSerializer(new ArraySerializer); + $resource = new Fractal\Resource\Collection($accounts, new AccountTransformer); $res = $fractal->createData($resource)->toArray(); $url = $request->url(); $page = $request->input('page', 1); $next = $page < 40 ? $page + 1 : 40; $prev = $page > 1 ? $page - 1 : 1; $links = '<'.$url.'?page='.$next.'&limit='.$limit.'>; rel="next", <'.$url.'?page='.$prev.'&limit='.$limit.'>; rel="prev"'; + return response()->json($res, 200, ['Link' => $links]); } public function accountBlocks(Request $request) { - abort_if(!$request->user(), 403); + abort_if(! $request->user(), 403); $this->validate($request, [ - 'limit' => 'nullable|integer|min:1|max:40', - 'page' => 'nullable|integer|min:1|max:10' + 'limit' => 'nullable|integer|min:1|max:40', + 'page' => 'nullable|integer|min:1|max:10', ]); $user = $request->user(); $limit = $request->input('limit') ?? 40; - $blocked = UserFilter::select('filterable_id','filterable_type','filter_type','user_id') + $blocked = UserFilter::select('filterable_id', 'filterable_type', 'filter_type', 'user_id') ->whereUserId($user->profile_id) ->whereFilterableType('App\Profile') ->whereFilterType('block') @@ -632,15 +633,16 @@ class AccountController extends Controller ->pluck('filterable_id'); $profiles = Profile::findOrFail($blocked); - $fractal = new Fractal\Manager(); - $fractal->setSerializer(new ArraySerializer()); - $resource = new Fractal\Resource\Collection($profiles, new AccountTransformer()); + $fractal = new Fractal\Manager; + $fractal->setSerializer(new ArraySerializer); + $resource = new Fractal\Resource\Collection($profiles, new AccountTransformer); $res = $fractal->createData($resource)->toArray(); $url = $request->url(); $page = $request->input('page', 1); $next = $page < 40 ? $page + 1 : 40; $prev = $page > 1 ? $page - 1 : 1; $links = '<'.$url.'?page='.$next.'&limit='.$limit.'>; rel="next", <'.$url.'?page='.$prev.'&limit='.$limit.'>; rel="prev"'; + return response()->json($res, 200, ['Link' => $links]); } diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index 972c26edf..2426a2d3d 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -788,6 +788,14 @@ class PublicApiController extends Controller private function determineVisibility($profile, $user) { + if (! $user || ! isset($user->profile_id)) { + return []; + } + + if (! $profile || ! isset($profile['id'])) { + return []; + } + if ($profile['id'] == $user->profile_id) { return ['public', 'unlisted', 'private']; } @@ -798,17 +806,13 @@ class PublicApiController extends Controller } $pid = $user->profile_id; - $isFollowing = Follower::whereProfileId($pid) - ->whereFollowingId($profile['id']) - ->exists(); + $isFollowing = FollowerService::follows($pid, $profile['id']); return $isFollowing ? ['public', 'unlisted', 'private'] : ['public']; } else { if ($user) { $pid = $user->profile_id; - $isFollowing = Follower::whereProfileId($pid) - ->whereFollowingId($profile['id']) - ->exists(); + $isFollowing = FollowerService::follows($pid, $profile['id']); return $isFollowing ? ['public', 'unlisted', 'private'] : ['public', 'unlisted']; } else { From a25afa0442c6042a951bca5daec2cc912de8284f Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 5 Apr 2025 03:40:13 -0600 Subject: [PATCH 17/22] Fix ProfileFeed bookmark, likes and shares. Closes #5879 --- .../partials/profile/ProfileFeed.vue | 282 ++++++++++++------ 1 file changed, 184 insertions(+), 98 deletions(-) diff --git a/resources/assets/components/partials/profile/ProfileFeed.vue b/resources/assets/components/partials/profile/ProfileFeed.vue index 2b0ee168e..12945ca47 100644 --- a/resources/assets/components/partials/profile/ProfileFeed.vue +++ b/resources/assets/components/partials/profile/ProfileFeed.vue @@ -270,14 +270,14 @@ :key="'prs'+s.id+':'+index" :profile="user" :status="s" - v-on:like="likeStatus(index)" - v-on:unlike="unlikeStatus(index)" - v-on:share="shareStatus(index)" - v-on:unshare="unshareStatus(index)" - v-on:menu="openContextMenu(index)" + v-on:like="likeStatus(index, 'feed')" + v-on:unlike="unlikeStatus(index, 'feed')" + v-on:share="shareStatus(index, 'feed')" + v-on:unshare="unshareStatus(index, 'feed')" + v-on:menu="openContextMenu(index, 'feed')" v-on:counter-change="counterChange(index, $event)" - v-on:likes-modal="openLikesModal(index)" - v-on:shares-modal="openSharesModal(index)" + v-on:likes-modal="openLikesModal(index, 'feed')" + v-on:shares-modal="openSharesModal(index, 'feed')" v-on:comment-likes-modal="openCommentLikesModal" v-on:bookmark="handleBookmark(index)" v-on:handle-report="handleReport" /> @@ -361,12 +361,15 @@ :key="'prs'+s.id+':'+index" :profile="user" :status="s" - v-on:like="likeStatus(index)" - v-on:unlike="unlikeStatus(index)" - v-on:share="shareStatus(index)" - v-on:unshare="unshareStatus(index)" + v-on:like="likeStatus(index, 'likes')" + v-on:unlike="unlikeStatus(index, 'likes')" + v-on:menu="openContextMenu(index, 'likes')" + v-on:share="shareStatus(index, 'likes')" + v-on:unshare="unshareStatus(index, 'likes')" v-on:counter-change="counterChange(index, $event)" - v-on:likes-modal="openLikesModal(index)" + v-on:likes-modal="openLikesModal(index, 'likes')" + v-on:shares-modal="openSharesModal(index, 'likes')" + v-on:bookmark="handleBookmark(index, 'likes')" v-on:comment-likes-modal="openCommentLikesModal" v-on:handle-report="handleReport" /> @@ -395,10 +398,15 @@ :profile="user" :new-reactions="true" :status="s" - v-on:menu="openContextMenu(index)" + v-on:like="likeStatus(index, 'bookmarks')" + v-on:unlike="unlikeStatus(index, 'bookmarks')" + v-on:menu="openContextMenu(index, 'bookmarks')" v-on:counter-change="counterChange(index, $event)" - v-on:likes-modal="openLikesModal(index)" - v-on:bookmark="handleBookmark(index)" + v-on:share="shareStatus(index, 'bookmarks')" + v-on:unshare="unshareStatus(index, 'bookmarks')" + v-on:likes-modal="openLikesModal(index, 'bookmarks')" + v-on:bookmark="handleBookmark(index, 'bookmarks')" + v-on:shares-modal="openSharesModal(index, 'bookmarks')" v-on:comment-likes-modal="openCommentLikesModal" v-on:handle-report="handleReport" /> @@ -556,7 +564,8 @@ archivesLoaded: false, archivesPage: 1, canLoadMoreArchives: false, - contextMenuPost: {} + contextMenuPost: {}, + contextMenuType: undefined, } }, @@ -888,64 +897,113 @@ return App.util.format.timeAgo(ts); }, - likeStatus(index) { - let status = this.feed[index]; - let state = status.favourited; - let count = status.favourites_count; - this.feed[index].favourites_count = count + 1; - this.feed[index].favourited = !status.favourited; - - axios.post('/api/v1/statuses/' + status.id + '/favourite') - .catch(err => { - this.feed[index].favourites_count = count; - this.feed[index].favourited = false; - }) + likeStatus(index, source = 'feed') { + const sourceMap = { + 'feed': this.feed, + 'likes': this.favourites, + 'bookmarks': this.bookmarks + }; + + const sourceArray = sourceMap[source] || this.feed; + const status = sourceArray[index]; + const originalFavourited = status.favourited; + const originalCount = status.favourites_count; + + sourceArray[index].favourites_count = originalCount + 1; + sourceArray[index].favourited = !originalFavourited; + + axios.post(`/api/v1/statuses/${status.id}/favourite`) + .catch(err => { + sourceArray[index].favourites_count = originalCount; + sourceArray[index].favourited = originalFavourited; + }); }, - unlikeStatus(index) { - let status = this.feed[index]; - let state = status.favourited; - let count = status.favourites_count; - this.feed[index].favourites_count = count - 1; - this.feed[index].favourited = !status.favourited; - - axios.post('/api/v1/statuses/' + status.id + '/unfavourite') - .catch(err => { - this.feed[index].favourites_count = count; - this.feed[index].favourited = false; - }) + unlikeStatus(index, source = 'feed') { + const sourceMap = { + 'feed': this.feed, + 'likes': this.favourites, + 'bookmarks': this.bookmarks + }; + + const sourceArray = sourceMap[source] || this.feed; + const status = sourceArray[index]; + const originalFavourited = status.favourited; + const originalCount = status.favourites_count; + + sourceArray[index].favourites_count = originalCount - 1; + sourceArray[index].favourited = !originalFavourited; + + axios.post(`/api/v1/statuses/${status.id}/unfavourite`) + .catch(err => { + sourceArray[index].favourites_count = originalCount; + sourceArray[index].favourited = originalFavourited; + }); }, openContextMenu(idx, type = 'feed') { - switch(type) { - case 'feed': - this.postIndex = idx; - this.contextMenuPost = this.feed[idx]; - break; + const sourceMap = { + 'feed': this.feed, + 'likes': this.favourites, + 'bookmarks': this.bookmarks, + 'archive': this.archives + }; - case 'archive': - this.postIndex = idx; - this.contextMenuPost = this.archives[idx]; - break; - } + const sourceArray = sourceMap[type] || this.feed; + + this.postIndex = idx; + this.contextMenuPost = sourceArray[idx]; + this.contextMenuType = type; this.showMenu = true; this.$nextTick(() => { this.$refs.contextMenu.open(); }); }, - openLikesModal(idx) { + openLikesModal(idx, source = 'feed') { this.postIndex = idx; - this.likesModalPost = this.feed[this.postIndex]; + switch(source) { + case 'feed': + this.likesModalPost = this.feed[this.postIndex]; + break; + + case 'likes': + this.likesModalPost = this.favourites[this.postIndex]; + break; + + case 'bookmarks': + this.likesModalPost = this.bookmarks[this.postIndex]; + break; + + default: + this.likesModalPost = this.feed[this.postIndex]; + break; + } this.showLikesModal = true; this.$nextTick(() => { this.$refs.likesModal.open(); }); }, - openSharesModal(idx) { + openSharesModal(idx, source = 'feed') { this.postIndex = idx; - this.sharesModalPost = this.feed[this.postIndex]; + switch(source) { + case 'feed': + this.sharesModalPost = this.feed[this.postIndex]; + break; + + case 'likes': + this.sharesModalPost = this.favourites[this.postIndex]; + break; + + case 'bookmarks': + this.sharesModalPost = this.bookmarks[this.postIndex]; + break; + + default: + this.sharesModalPost = this.feed[this.postIndex]; + break; + } this.showSharesModal = true; this.$nextTick(() => { this.$refs.sharesModal.open(); @@ -955,23 +1013,32 @@ commitModeration(type) { let idx = this.postIndex; + const sourceMap = { + 'feed': this.feed, + 'likes': this.favourites, + 'bookmarks': this.bookmarks, + 'archive': this.archives + }; + + const sourceType = this.contextMenuType; + switch(type) { case 'addcw': - this.feed[idx].sensitive = true; + sourceMap[sourceType][idx].sensitive = true; break; case 'remcw': - this.feed[idx].sensitive = false; + sourceMap[sourceType][idx].sensitive = false; break; case 'unlist': - this.feed.splice(idx, 1); + sourceMap[sourceType].splice(idx, 1); break; case 'spammer': - let id = this.feed[idx].account.id; + let id = sourceMap[sourceType][idx].account.id; - this.feed = this.feed.filter(post => { + sourceMap[sourceType] = sourceMap[sourceType].filter(post => { return post.account.id != id; }); break; @@ -998,32 +1065,48 @@ }); }, - shareStatus(index) { - let status = this.feed[index]; - let state = status.reblogged; - let count = status.reblogs_count; - this.feed[index].reblogs_count = count + 1; - this.feed[index].reblogged = !status.reblogged; - - axios.post('/api/v1/statuses/' + status.id + '/reblog') - .catch(err => { - this.feed[index].reblogs_count = count; - this.feed[index].reblogged = false; - }) + shareStatus(index, source = 'feed') { + const sourceMap = { + 'feed': this.feed, + 'likes': this.favourites, + 'bookmarks': this.bookmarks + }; + + const sourceArray = sourceMap[source] || this.feed; + const status = sourceArray[index]; + const originalReblogged = status.reblogged; + const originalCount = status.reblogs_count; + + sourceArray[index].reblogs_count = originalCount + 1; + sourceArray[index].reblogged = !originalReblogged; + + axios.post(`/api/v1/statuses/${status.id}/reblog`) + .catch(err => { + sourceArray[index].reblogs_count = originalCount; + sourceArray[index].reblogged = originalReblogged; + }); }, - unshareStatus(index) { - let status = this.feed[index]; - let state = status.reblogged; - let count = status.reblogs_count; - this.feed[index].reblogs_count = count - 1; - this.feed[index].reblogged = !status.reblogged; - - axios.post('/api/v1/statuses/' + status.id + '/unreblog') - .catch(err => { - this.feed[index].reblogs_count = count; - this.feed[index].reblogged = false; - }) + unshareStatus(index, source = 'feed') { + const sourceMap = { + 'feed': this.feed, + 'likes': this.favourites, + 'bookmarks': this.bookmarks + }; + + const sourceArray = sourceMap[source] || this.feed; + const status = sourceArray[index]; + const originalReblogged = status.reblogged; + const originalCount = status.reblogs_count; + + sourceArray[index].reblogs_count = originalCount - 1; + sourceArray[index].reblogged = !originalReblogged; + + axios.post(`/api/v1/statuses/${status.id}/unreblog`) + .catch(err => { + sourceArray[index].reblogs_count = originalCount; + sourceArray[index].reblogged = originalReblogged; + }); }, handleReport(post) { @@ -1069,25 +1152,29 @@ }) }, - handleBookmark(index) { - if(!window.confirm('Are you sure you want to unbookmark this post?')) { - return; - } + handleBookmark(index, source = 'feed') { + this.postIndex = index; + + const sourceMap = { + 'feed': this.feed, + 'likes': this.favourites, + 'bookmarks': this.bookmarks + }; - let p = this.bookmarks[index]; + const sourceArray = sourceMap[source] || this.feed; + const item = sourceArray[this.postIndex]; + + if(item.bookmarked) { + if(!window.confirm('Are you sure you want to unbookmark this post?')) { + return; + } + } axios.post('/i/bookmark', { - item: p.id + item: item.id }) .then(res => { - this.bookmarks = this.bookmarks.map(post => { - if(post.id == p.id) { - post.bookmarked = false; - delete post.bookmarked_at; - } - return post; - }); - this.bookmarks.splice(index, 1); + item.bookmarked = !item.bookmarked; }) .catch(err => { this.$bvToast.toast('Cannot bookmark post at this time.', { @@ -1097,7 +1184,6 @@ }); }); }, - } } From 5ddb6d8427a76f719d26867eb7539536b1918861 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 5 Apr 2025 03:41:10 -0600 Subject: [PATCH 18/22] Update PublicApiController, use pixelfed entities for /api/pixelfed/v1/accounts/id/statuses with bookmarked state --- app/Http/Controllers/PublicApiController.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index 2426a2d3d..bd9a80e68 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -825,13 +825,14 @@ class PublicApiController extends Controller { return collect($statuses)->map(function ($status) use ($user) { try { - $mastodonStatus = StatusService::getMastodon($status->id, false); + $mastodonStatus = StatusService::get($status->id, false); if (! $mastodonStatus) { return null; } if ($user) { $mastodonStatus['favourited'] = (bool) LikeService::liked($user->profile_id, $status->id); + $mastodonStatus['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $status->id); $mastodonStatus['reblogged'] = (bool) StatusService::isShared($status->id, $user->profile_id); } From 388d1ba36a2a1303f8ea47966a30532d0bb53ecd Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 5 Apr 2025 03:41:54 -0600 Subject: [PATCH 19/22] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0e8ea3e..09aab7fdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Pinned Posts ([2f655d000](https://github.com/pixelfed/pixelfed/commit/2f655d000)) ### Updates +- Update PublicApiController, use pixelfed entities for /api/pixelfed/v1/accounts/id/statuses with bookmarked state ([5ddb6d842](https://github.com/pixelfed/pixelfed/commit/5ddb6d842)) - ([](https://github.com/pixelfed/pixelfed/commit/)) ## [v0.12.5 (2025-03-23)](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev) From 2c3feb91c0bf7840c77c3e0159574aa4370634c2 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 5 Apr 2025 03:45:29 -0600 Subject: [PATCH 20/22] Update compiled assets --- public/js/app.js | 2 +- ...iscover~hashtag.bundle.b783a54ac20f3e93.js | 1 + ...iscover~hashtag.bundle.c8eb86fb63ede45e.js | 1 - ...cover~myhashtags.chunk.03a9fc477579fd24.js | 1 + ...cover~myhashtags.chunk.f4257bc65189fde3.js | 1 - public/js/home.chunk.3d9801a7722f4dfb.js | 2 -- public/js/home.chunk.fec949c588d3a0ec.js | 2 ++ ...ome.chunk.fec949c588d3a0ec.js.LICENSE.txt} | 0 public/js/landing.js | 2 +- public/js/manifest.js | 2 +- .../notifications.chunk.a755ad4eb2972fbf.js | 1 + .../notifications.chunk.bd37ed834e650fd7.js | 1 - public/js/post.chunk.48fdffa21ac83f3a.js | 2 ++ ...ost.chunk.48fdffa21ac83f3a.js.LICENSE.txt} | 0 public/js/post.chunk.c699382772550b42.js | 2 -- public/js/profile.chunk.239231da0003f8d9.js | 1 - public/js/profile.chunk.25876d18c9eeb7c6.js | 1 + public/js/profile.js | 2 +- public/js/spa.js | 2 +- public/mix-manifest.json | 22 +++++++++---------- 20 files changed, 24 insertions(+), 24 deletions(-) create mode 100644 public/js/discover~hashtag.bundle.b783a54ac20f3e93.js delete mode 100644 public/js/discover~hashtag.bundle.c8eb86fb63ede45e.js create mode 100644 public/js/discover~myhashtags.chunk.03a9fc477579fd24.js delete mode 100644 public/js/discover~myhashtags.chunk.f4257bc65189fde3.js delete mode 100644 public/js/home.chunk.3d9801a7722f4dfb.js create mode 100644 public/js/home.chunk.fec949c588d3a0ec.js rename public/js/{home.chunk.3d9801a7722f4dfb.js.LICENSE.txt => home.chunk.fec949c588d3a0ec.js.LICENSE.txt} (100%) create mode 100644 public/js/notifications.chunk.a755ad4eb2972fbf.js delete mode 100644 public/js/notifications.chunk.bd37ed834e650fd7.js create mode 100644 public/js/post.chunk.48fdffa21ac83f3a.js rename public/js/{post.chunk.c699382772550b42.js.LICENSE.txt => post.chunk.48fdffa21ac83f3a.js.LICENSE.txt} (100%) delete mode 100644 public/js/post.chunk.c699382772550b42.js delete mode 100644 public/js/profile.chunk.239231da0003f8d9.js create mode 100644 public/js/profile.chunk.25876d18c9eeb7c6.js diff --git a/public/js/app.js b/public/js/app.js index 6454cb628..8d5d146b7 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,2 +1,2 @@ /*! For license information please see app.js.LICENSE.txt */ -(self.webpackChunkpixelfed=self.webpackChunkpixelfed||[]).push([[5847],{7640:(e,t,r)=>{"use strict";r.r(t)},9901:function(){function e(t){return e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},e(t)}!function(){var t="object"===("undefined"==typeof window?"undefined":e(window))?window:"object"===("undefined"==typeof self?"undefined":e(self))?self:this,r=t.BlobBuilder||t.WebKitBlobBuilder||t.MSBlobBuilder||t.MozBlobBuilder;t.URL=t.URL||t.webkitURL||function(e,t){return(t=document.createElement("a")).href=e,t};var o=t.Blob,n=URL.createObjectURL,i=URL.revokeObjectURL,a=t.Symbol&&t.Symbol.toStringTag,s=!1,f=!1,c=!!t.ArrayBuffer,u=r&&r.prototype.append&&r.prototype.getBlob;try{s=2===new Blob(["ä"]).size,f=2===new Blob([new Uint8Array([1,2])]).size}catch(e){}function p(e){return e.map((function(e){if(e.buffer instanceof ArrayBuffer){var t=e.buffer;if(e.byteLength!==t.byteLength){var r=new Uint8Array(e.byteLength);r.set(new Uint8Array(t,e.byteOffset,e.byteLength)),t=r.buffer}return t}return e}))}function d(e,t){t=t||{};var o=new r;return p(e).forEach((function(e){o.append(e)})),t.type?o.getBlob(t.type):o.getBlob()}function h(e,t){return new o(p(e),t||{})}t.Blob&&(d.prototype=Blob.prototype,h.prototype=Blob.prototype);var b="function"==typeof TextEncoder?TextEncoder.prototype.encode.bind(new TextEncoder):function(e){for(var r=0,o=e.length,n=t.Uint8Array||Array,i=0,a=Math.max(32,o+(o>>1)+7),s=new n(a>>3<<3);r=55296&&l<=56319){if(r=55296&&l<=56319)continue}if(i+4>s.length){a+=8,a=(a*=1+r/e.length*2)>>3<<3;var c=new Uint8Array(a);c.set(s),s=c}if(4294967168&l){if(4294965248&l)if(4294901760&l){if(4292870144&l)continue;s[i++]=l>>18&7|240,s[i++]=l>>12&63|128,s[i++]=l>>6&63|128}else s[i++]=l>>12&15|224,s[i++]=l>>6&63|128;else s[i++]=l>>6&31|192;s[i++]=63&l|128}else s[i++]=l}return s.slice(0,i)},y="function"==typeof TextDecoder?TextDecoder.prototype.decode.bind(new TextDecoder):function(e){for(var t=e.length,r=[],o=0;o239?4:l>223?3:l>191?2:1;if(o+c<=t)switch(c){case 1:l<128&&(f=l);break;case 2:128==(192&(n=e[o+1]))&&(s=(31&l)<<6|63&n)>127&&(f=s);break;case 3:n=e[o+1],i=e[o+2],128==(192&n)&&128==(192&i)&&(s=(15&l)<<12|(63&n)<<6|63&i)>2047&&(s<55296||s>57343)&&(f=s);break;case 4:n=e[o+1],i=e[o+2],a=e[o+3],128==(192&n)&&128==(192&i)&&128==(192&a)&&(s=(15&l)<<18|(63&n)<<12|(63&i)<<6|63&a)>65535&&s<1114112&&(f=s)}null===f?(f=65533,c=1):f>65535&&(f-=65536,r.push(f>>>10&1023|55296),f=56320|1023&f),r.push(f),o+=c}var u=r.length,p="";for(o=0;o>2,c=(3&n)<<4|a>>4,u=(15&a)<<2|l>>6,p=63&l;s||(p=64,i||(u=64)),r.push(t[f],t[c],t[u],t[p])}return r.join("")}var o=Object.create||function(e){function t(){}return t.prototype=e,new t};if(c)var a=["[object Int8Array]","[object Uint8Array]","[object Uint8ClampedArray]","[object Int16Array]","[object Uint16Array]","[object Int32Array]","[object Uint32Array]","[object Float32Array]","[object Float64Array]"],s=ArrayBuffer.isView||function(e){return e&&a.indexOf(Object.prototype.toString.call(e))>-1};function f(r,o){o=null==o?{}:o;for(var n=0,i=(r=r||[]).length;n=t.size&&r.close()}))}})}}catch(e){try{new ReadableStream({}),g=function(e){var t=0;e=this;return new ReadableStream({pull:function(r){return e.slice(t,t+524288).arrayBuffer().then((function(o){t+=o.byteLength;var n=new Uint8Array(o);r.enqueue(n),t==e.size&&r.close()}))}})}}catch(e){try{new Response("").body.getReader().read(),g=function(){return new Response(this).body}}catch(e){g=function(){throw new Error("Include https://github.com/MattiasBuelens/web-streams-polyfill")}}}}m.arrayBuffer||(m.arrayBuffer=function(){var e=new FileReader;return e.readAsArrayBuffer(this),v(e)}),m.text||(m.text=function(){var e=new FileReader;return e.readAsText(this),v(e)}),m.stream||(m.stream=g)}(),function(e){"use strict";var t,r=e.Uint8Array,o=e.HTMLCanvasElement,n=o&&o.prototype,i=/\s*;\s*base64\s*(?:;|$)/i,a="toDataURL",s=function(e){for(var o,n,i=e.length,a=new r(i/4*3|0),s=0,l=0,f=[0,0],c=0,u=0;i--;)n=e.charCodeAt(s++),255!==(o=t[n-43])&&undefined!==o&&(f[1]=f[0],f[0]=n,u=u<<6|o,4===++c&&(a[l++]=u>>>16,61!==f[1]&&(a[l++]=u>>>8),61!==f[0]&&(a[l++]=u),c=0));return a};r&&(t=new r([62,-1,-1,-1,63,52,53,54,55,56,57,58,59,60,61,-1,-1,-1,0,-1,-1,-1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,-1,-1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51])),!o||n.toBlob&&n.toBlobHD||(n.toBlob||(n.toBlob=function(e,t){if(t||(t="image/png"),this.mozGetAsFile)e(this.mozGetAsFile("canvas",t));else if(this.msToBlob&&/^\s*image\/png\s*(?:$|;)/i.test(t))e(this.msToBlob());else{var o,n=Array.prototype.slice.call(arguments,1),l=this[a].apply(this,n),f=l.indexOf(","),c=l.substring(f+1),u=i.test(l.substring(0,f));Blob.fake?((o=new Blob).encoding=u?"base64":"URI",o.data=c,o.size=c.length):r&&(o=u?new Blob([s(c)],{type:t}):new Blob([decodeURIComponent(c)],{type:t})),e(o)}}),!n.toBlobHD&&n.toDataURLHD?n.toBlobHD=function(){a="toDataURLHD";var e=this.toBlob();return a="toDataURL",e}:n.toBlobHD=n.toBlob)}("undefined"!=typeof self&&self||"undefined"!=typeof window&&window||this.content||this)},51595:(e,t,r)=>{"use strict";r.r(t)},55994:(e,t,r)=>{"use strict";r.r(t)},71751:(e,t,r)=>{r(74692);var o=r(74692);r(9901),window._=r(2543),window.Popper=r(48851).default,window.pixelfed=window.pixelfed||{},window.$=r(74692),r(52754),window.axios=r(86425),window.axios.defaults.headers.common["X-Requested-With"]="XMLHttpRequest",r(63899),window.blurhash=r(95341);var n=document.head.querySelector('meta[name="csrf-token"]');n?window.axios.defaults.headers.common["X-CSRF-TOKEN"]=n.content:console.error("CSRF token not found."),window.App=window.App||{},window.App.redirect=function(){document.querySelectorAll("a").forEach((function(e,t){var r=e.getAttribute("href");if(r&&r.length>5&&r.startsWith("https://")){var o=new URL(r);o.host!==window.location.host&&"/i/redirect"!==o.pathname&&e.setAttribute("href","/i/redirect?url="+encodeURIComponent(r))}}))},window.App.boot=function(){new Vue({el:"#content"})},window.addEventListener("load",(function(){"serviceWorker"in navigator&&navigator.serviceWorker.register("/sw.js")})),window.App.util={compose:{post:function(){var e=window.location.pathname;["/","/timeline/public"].includes(e)?o("#composeModal").modal("show"):window.location.href="/?a=co"},circle:function(){console.log("Unsupported method.")},collection:function(){console.log("Unsupported method.")},loop:function(){console.log("Unsupported method.")},story:function(){console.log("Unsupported method.")}},time:function(){return new Date},version:1,format:{count:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:0,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"en-GB",r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"compact";return e<1?0:new Intl.NumberFormat(t,{notation:r,compactDisplay:"short"}).format(e)},timeAgo:function(e){var t=new Date(e),r=new Date,o=Math.floor((r-t)/1e3),n=Math.floor(o/31557600);return n>=1?n+"y":(n=Math.floor(o/604800))>=1?n+"w":(n=Math.floor(o/86400))>=1?n+"d":(n=Math.floor(o/3600))>=1?n+"h":(n=Math.floor(o/60))>=1?n+"m":Math.floor(o)+"s"},timeAhead:function(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],r=Date.parse(e)-Date.parse(new Date),o=Math.floor(r/1e3),n=Math.floor(o/63072e3);return n>=1?n+(t?"y":" years"):(n=Math.floor(o/604800))>=1?n+(t?"w":" weeks"):(n=Math.floor(o/86400))>=1?n+(t?"d":" days"):(n=Math.floor(o/3600))>=1?n+(t?"h":" hours"):(n=Math.floor(o/60))>=1?n+(t?"m":" minutes"):Math.floor(o)+(t?"s":" seconds")},rewriteLinks:function(e){var t=e.innerText;return e.href.startsWith(window.location.origin)?e.href:t=1==t.startsWith("#")?"/discover/tags/"+t.substr(1)+"?src=rph":1==t.startsWith("@")?"/"+e.innerText+"?src=rpp":"/i/redirect?url="+encodeURIComponent(t)}},filters:[["1984","filter-1977"],["Azen","filter-aden"],["Astairo","filter-amaro"],["Grassbee","filter-ashby"],["Bookrun","filter-brannan"],["Borough","filter-brooklyn"],["Farms","filter-charmes"],["Hairsadone","filter-clarendon"],["Cleana ","filter-crema"],["Catpatch","filter-dogpatch"],["Earlyworm","filter-earlybird"],["Plaid","filter-gingham"],["Kyo","filter-ginza"],["Yefe","filter-hefe"],["Goddess","filter-helena"],["Yards","filter-hudson"],["Quill","filter-inkwell"],["Rankine","filter-kelvin"],["Juno","filter-juno"],["Mark","filter-lark"],["Chill","filter-lofi"],["Van","filter-ludwig"],["Apache","filter-maven"],["May","filter-mayfair"],["Ceres","filter-moon"],["Knoxville","filter-nashville"],["Felicity","filter-perpetua"],["Sandblast","filter-poprocket"],["Daisy","filter-reyes"],["Elevate","filter-rise"],["Nevada","filter-sierra"],["Futura","filter-skyline"],["Sleepy","filter-slumber"],["Steward","filter-stinson"],["Savoy","filter-sutro"],["Blaze","filter-toaster"],["Apricot","filter-valencia"],["Gloming","filter-vesper"],["Walter","filter-walden"],["Poplar","filter-willow"],["Xenon","filter-xpro-ii"]],filterCss:{"filter-1977":"sepia(.5) hue-rotate(-30deg) saturate(1.4)","filter-aden":"sepia(.2) brightness(1.15) saturate(1.4)","filter-amaro":"sepia(.35) contrast(1.1) brightness(1.2) saturate(1.3)","filter-ashby":"sepia(.5) contrast(1.2) saturate(1.8)","filter-brannan":"sepia(.4) contrast(1.25) brightness(1.1) saturate(.9) hue-rotate(-2deg)","filter-brooklyn":"sepia(.25) contrast(1.25) brightness(1.25) hue-rotate(5deg)","filter-charmes":"sepia(.25) contrast(1.25) brightness(1.25) saturate(1.35) hue-rotate(-5deg)","filter-clarendon":"sepia(.15) contrast(1.25) brightness(1.25) hue-rotate(5deg)","filter-crema":"sepia(.5) contrast(1.25) brightness(1.15) saturate(.9) hue-rotate(-2deg)","filter-dogpatch":"sepia(.35) saturate(1.1) contrast(1.5)","filter-earlybird":"sepia(.25) contrast(1.25) brightness(1.15) saturate(.9) hue-rotate(-5deg)","filter-gingham":"contrast(1.1) brightness(1.1)","filter-ginza":"sepia(.25) contrast(1.15) brightness(1.2) saturate(1.35) hue-rotate(-5deg)","filter-hefe":"sepia(.4) contrast(1.5) brightness(1.2) saturate(1.4) hue-rotate(-10deg)","filter-helena":"sepia(.5) contrast(1.05) brightness(1.05) saturate(1.35)","filter-hudson":"sepia(.25) contrast(1.2) brightness(1.2) saturate(1.05) hue-rotate(-15deg)","filter-inkwell":"brightness(1.25) contrast(.85) grayscale(1)","filter-kelvin":"sepia(.15) contrast(1.5) brightness(1.1) hue-rotate(-10deg)","filter-juno":"sepia(.35) contrast(1.15) brightness(1.15) saturate(1.8)","filter-lark":"sepia(.25) contrast(1.2) brightness(1.3) saturate(1.25)","filter-lofi":"saturate(1.1) contrast(1.5)","filter-ludwig":"sepia(.25) contrast(1.05) brightness(1.05) saturate(2)","filter-maven":"sepia(.35) contrast(1.05) brightness(1.05) saturate(1.75)","filter-mayfair":"contrast(1.1) brightness(1.15) saturate(1.1)","filter-moon":"brightness(1.4) contrast(.95) saturate(0) sepia(.35)","filter-nashville":"sepia(.25) contrast(1.5) brightness(.9) hue-rotate(-15deg)","filter-perpetua":"contrast(1.1) brightness(1.25) saturate(1.1)","filter-poprocket":"sepia(.15) brightness(1.2)","filter-reyes":"sepia(.75) contrast(.75) brightness(1.25) saturate(1.4)","filter-rise":"sepia(.25) contrast(1.25) brightness(1.2) saturate(.9)","filter-sierra":"sepia(.25) contrast(1.5) brightness(.9) hue-rotate(-15deg)","filter-skyline":"sepia(.15) contrast(1.25) brightness(1.25) saturate(1.2)","filter-slumber":"sepia(.35) contrast(1.25) saturate(1.25)","filter-stinson":"sepia(.35) contrast(1.25) brightness(1.1) saturate(1.25)","filter-sutro":"sepia(.4) contrast(1.2) brightness(.9) saturate(1.4) hue-rotate(-10deg)","filter-toaster":"sepia(.25) contrast(1.5) brightness(.95) hue-rotate(-15deg)","filter-valencia":"sepia(.25) contrast(1.1) brightness(1.1)","filter-vesper":"sepia(.35) contrast(1.15) brightness(1.2) saturate(1.3)","filter-walden":"sepia(.35) contrast(.8) brightness(1.25) saturate(1.4)","filter-willow":"brightness(1.2) contrast(.85) saturate(.05) sepia(.2)","filter-xpro-ii":"sepia(.45) contrast(1.25) brightness(1.75) saturate(1.3) hue-rotate(-5deg)"},emoji:["😂","💯","❤️","🙌","👏","👌","😍","😯","😢","😅","😁","🙂","😎","😀","🤣","😃","😄","😆","😉","😊","😋","😘","😗","😙","😚","🤗","🤩","🤔","🤨","😐","😑","😶","🙄","😏","😣","😥","😮","🤐","😪","😫","😴","😌","😛","😜","😝","🤤","😒","😓","😔","😕","🙃","🤑","😲","🙁","😖","😞","😟","😤","😭","😦","😧","😨","😩","🤯","😬","😰","😱","😳","🤪","😵","😡","😠","🤬","😷","🤒","🤕","🤢","🤮","🤧","😇","🤠","🤡","🤥","🤫","🤭","🧐","🤓","😈","👿","👹","👺","💀","👻","👽","🤖","💩","😺","😸","😹","😻","😼","😽","🙀","😿","😾","🤲","👐","🤝","👍","👎","👊","✊","🤛","🤜","🤞","✌️","🤟","🤘","👈","👉","👆","👇","☝️","✋","🤚","🖐","🖖","👋","🤙","💪","🖕","✍️","🙏","💍","💄","💋","👄","👅","👂","👃","👣","👁","👀","🧠","🗣","👤","👥"],embed:{post:function(e){var t=e+"/embed?";return t+=!(arguments.length>1&&void 0!==arguments[1])||arguments[1]?"caption=true&":"caption=false&",t+=arguments.length>2&&void 0!==arguments[2]&&arguments[2]?"likes=true&":"likes=false&",'