From 2c9930bd5b5c1279e0890aeba673ad6b5ce2af18 Mon Sep 17 00:00:00 2001
From: NEETzsche <neetzsche@tutanota.com>
Date: Thu, 9 Nov 2023 15:03:21 -0700
Subject: Display the latest scrobble under a user's name

---
 src/components/status/status.vue | 20 +++++++++++++++++++-
 1 file changed, 19 insertions(+), 1 deletion(-)

(limited to 'src/components/status/status.vue')

diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index c49a9e7b..ad625643 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -249,6 +249,25 @@
                 </button>
               </span>
             </div>
+            <div class="status-rich-presence" v-if="scrobblePresent">
+              <FAIcon
+                class="fa-scale-110 fa-old-padding"
+                icon="music"
+              />
+              {{ scrobble.artist }} — {{ scrobble.title }}
+              <FAIcon
+                class="fa-scale-110 fa-old-padding"
+                icon="play"
+              />
+              <span class="status-rich-presence-time">
+                <Timeago
+                  template-key="time.in_past"
+                  :time="scrobble.created_at"
+                  :auto-update="60"
+                />
+              </span>
+              </div>
+            </div>
             <div
               v-if="isReply || hasMentionsLine"
               class="heading-reply-row"
@@ -345,7 +364,6 @@
                 </template>
               </i18n-t>
             </div>
-          </div>
 
           <StatusContent
             ref="content"
-- 
cgit v1.2.3-70-g09d2


From 55d4ea3643a3bdb9e350c559d2d9a5f8608a7910 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Fri, 10 Nov 2023 15:24:13 +0200
Subject: fix layout

---
 src/components/status/status.vue | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

(limited to 'src/components/status/status.vue')

diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index ad625643..d66de562 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -266,7 +266,6 @@
                   :auto-update="60"
                 />
               </span>
-              </div>
             </div>
             <div
               v-if="isReply || hasMentionsLine"
@@ -364,6 +363,7 @@
                 </template>
               </i18n-t>
             </div>
+          </div>
 
           <StatusContent
             ref="content"
-- 
cgit v1.2.3-70-g09d2


From ec2937ec1f3b0ae153f79604eb35b57ffe0f9af2 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 13 Nov 2023 17:29:25 +0200
Subject: add options for marking single notification as read

---
 src/components/notification/notification.js        |  6 ++++++
 src/components/notification/notification.vue       |  1 +
 src/components/notifications/notifications.js      | 20 ++++++++++++++++++++
 src/components/notifications/notifications.vue     |  6 +++++-
 src/components/status/status.js                    |  3 +++
 src/components/status/status.vue                   |  3 +++
 src/modules/statuses.js                            |  8 ++++++++
 .../desktop_notification_utils.js                  | 22 +++++++++++++++++++++-
 .../notification_utils/notification_utils.js       |  5 +++--
 src/services/sw/sw.js                              |  9 +++++++++
 src/sw.js                                          |  6 ++++++
 11 files changed, 85 insertions(+), 4 deletions(-)

(limited to 'src/components/status/status.vue')

diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 420db4f0..0e938c42 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -50,6 +50,7 @@ const Notification = {
     }
   },
   props: ['notification'],
+  emits: ['interacted'],
   components: {
     StatusContent,
     UserAvatar,
@@ -72,6 +73,9 @@ const Notification = {
     getUser (notification) {
       return this.$store.state.users.usersObject[notification.from_profile.id]
     },
+    interacted () {
+      this.$emit('interacted')
+    },
     toggleMute () {
       this.unmuted = !this.unmuted
     },
@@ -95,6 +99,7 @@ const Notification = {
       }
     },
     doApprove () {
+      this.$emit('interacted')
       this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
       this.$store.dispatch('removeFollowRequest', this.user)
       this.$store.dispatch('markSingleNotificationAsSeen', { id: this.notification.id })
@@ -114,6 +119,7 @@ const Notification = {
       }
     },
     doDeny () {
+      this.$emit('interacted')
       this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
         .then(() => {
           this.$store.dispatch('dismissNotificationLocal', { id: this.notification.id })
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 6b3315f9..01ad395f 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -6,6 +6,7 @@
       class="Notification"
       :compact="true"
       :statusoid="notification.status"
+      @interacted="interacted"
     />
   </article>
   <article v-else>
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index 571df0f1..4cbe8093 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -159,6 +159,26 @@ const Notifications = {
     updateScrollPosition () {
       this.showScrollTop = this.$refs.root.offsetTop < this.scrollerRef.scrollTop
     },
+    notificationClicked (notification) {
+      const { type, id, seen } = notification
+      if (!seen) {
+        switch (type) {
+          case 'mention':
+          case 'pleroma:report':
+          case 'follow_request':
+            break
+          default:
+            this.markOneAsSeen(id)
+        }
+      }
+    },
+    notificationInteracted (notification) {
+      const { id, seen } = notification
+      if (!seen) this.markOneAsSeen(id)
+    },
+    markOneAsSeen (id) {
+      this.$store.dispatch('markSingleNotificationAsSeen', { id })
+    },
     markAsSeen () {
       this.$store.dispatch('markNotificationsAsSeen')
       this.seenToDisplayCount = DEFAULT_SEEN_TO_DISPLAY_COUNT
diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue
index 999f8e9c..27ae23cf 100644
--- a/src/components/notifications/notifications.vue
+++ b/src/components/notifications/notifications.vue
@@ -67,9 +67,13 @@
             role="listitem"
             class="notification"
             :class="{unseen: !minimalMode && !notification.seen}"
+            @click="e => notificationClicked(notification)"
           >
             <div class="notification-overlay" />
-            <notification :notification="notification" />
+            <notification
+              :notification="notification"
+              @interacted="e => notificationInteracted(notification)"
+            />
           </div>
         </div>
         <div class="panel-footer">
diff --git a/src/components/status/status.js b/src/components/status/status.js
index a339694d..129929a3 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -154,6 +154,7 @@ const Status = {
     'controlledSetMediaPlaying',
     'dive'
   ],
+  emits: ['interacted'],
   data () {
     return {
       uncontrolledReplying: false,
@@ -442,9 +443,11 @@ const Status = {
       this.error = error
     },
     clearError () {
+      this.$emit('interacted')
       this.error = undefined
     },
     toggleReplying () {
+      this.$emit('interacted')
       controlledOrUncontrolledToggle(this, 'replying')
     },
     gotoOriginal (id) {
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index d66de562..a8477fa3 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -508,14 +508,17 @@
               :visibility="status.visibility"
               :logged-in="loggedIn"
               :status="status"
+              @click="$emit('interacted')"
             />
             <favorite-button
               :logged-in="loggedIn"
               :status="status"
+              @click="$emit('interacted')"
             />
             <ReactButton
               v-if="loggedIn"
               :status="status"
+              @click="$emit('interacted')"
             />
             <extra-buttons
               :status="status"
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index 9b4cd175..6e331d16 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -17,6 +17,10 @@ import {
   isValidNotification,
   maybeShowNotification
 } from '../services/notification_utils/notification_utils.js'
+import {
+  closeDesktopNotification,
+  closeAllDesktopNotifications
+} from '../services/desktop_notification_utils/desktop_notification_utils.js'
 import apiService from '../services/api/api.service.js'
 
 const emptyTl = (userId = 0) => ({
@@ -726,6 +730,8 @@ const statuses = {
       apiService.markNotificationsAsSeen({
         id: rootState.statuses.notifications.maxId,
         credentials: rootState.users.currentUser.credentials
+      }).then(() => {
+        closeAllDesktopNotifications(rootState)
       })
     },
     markSingleNotificationAsSeen ({ rootState, commit }, { id }) {
@@ -734,6 +740,8 @@ const statuses = {
         single: true,
         id,
         credentials: rootState.users.currentUser.credentials
+      }).then(() => {
+        closeDesktopNotification(rootState, id)
       })
     },
     dismissNotificationLocal ({ rootState, commit }, { id }) {
diff --git a/src/services/desktop_notification_utils/desktop_notification_utils.js b/src/services/desktop_notification_utils/desktop_notification_utils.js
index eb58f39b..dbca4173 100644
--- a/src/services/desktop_notification_utils/desktop_notification_utils.js
+++ b/src/services/desktop_notification_utils/desktop_notification_utils.js
@@ -1,4 +1,8 @@
-import { showDesktopNotification as swDesktopNotification, isSWSupported } from '../sw/sw.js'
+import {
+  showDesktopNotification as swDesktopNotification,
+  closeDesktopNotification as swCloseDesktopNotification,
+  isSWSupported
+} from '../sw/sw.js'
 const state = { failCreateNotif: false }
 
 export const showDesktopNotification = (rootState, desktopNotificationOpts) => {
@@ -16,3 +20,19 @@ export const showDesktopNotification = (rootState, desktopNotificationOpts) => {
     }
   }
 }
+
+export const closeDesktopNotification = (rootState, id) => {
+  if (!('Notification' in window && window.Notification.permission === 'granted')) return
+
+  if (isSWSupported()) {
+    swCloseDesktopNotification({ id })
+  }
+}
+
+export const closeAllDesktopNotifications = (rootState) => {
+  if (!('Notification' in window && window.Notification.permission === 'granted')) return
+
+  if (isSWSupported()) {
+    swCloseDesktopNotification()
+  }
+}
diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
index fbd5c014..22ef6098 100644
--- a/src/services/notification_utils/notification_utils.js
+++ b/src/services/notification_utils/notification_utils.js
@@ -1,6 +1,7 @@
 import { filter, sortBy, includes } from 'lodash'
 import { muteWordHits } from '../status_parser/status_parser.js'
 import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
+
 import FaviconService from 'src/services/favicon_service/favicon_service.js'
 
 let cachedBadgeUrl = null
@@ -68,8 +69,8 @@ export const maybeShowNotification = (store, notification) => {
 
 export const filteredNotificationsFromStore = (store, types) => {
   // map is just to clone the array since sort mutates it and it causes some issues
-  let sortedNotifications = notificationsFromStore(store).map(_ => _).sort(sortById)
-  sortedNotifications = sortBy(sortedNotifications, 'seen')
+  const sortedNotifications = notificationsFromStore(store).map(_ => _).sort(sortById)
+  // TODO implement sorting elsewhere and make it optional
   return sortedNotifications.filter(
     (notification) => (types || visibleTypes(store)).includes(notification.type)
   )
diff --git a/src/services/sw/sw.js b/src/services/sw/sw.js
index 2875d36e..d4ae8f4f 100644
--- a/src/services/sw/sw.js
+++ b/src/services/sw/sw.js
@@ -96,6 +96,15 @@ export async function showDesktopNotification (content) {
   sw.postMessage({ type: 'desktopNotification', content })
 }
 
+export async function closeDesktopNotification ({ id }) {
+  const { active: sw } = await window.navigator.serviceWorker.getRegistration()
+  if (id >= 0) {
+    sw.postMessage({ type: 'desktopNotificationClose', content: { id } })
+  } else {
+    sw.postMessage({ type: 'desktopNotificationClose', content: { all: true } })
+  }
+}
+
 export async function updateFocus () {
   const { active: sw } = await window.navigator.serviceWorker.getRegistration()
   sw.postMessage({ type: 'updateFocus' })
diff --git a/src/sw.js b/src/sw.js
index b95d56a4..43c1c2e2 100644
--- a/src/sw.js
+++ b/src/sw.js
@@ -66,6 +66,12 @@ self.addEventListener('message', async (event) => {
     self.registration.showNotification(title, rest)
   }
 
+  if (type === 'desktopNotificationClose') {
+    const { id, all } = content
+    const search = all ? null : { tag: id }
+    self.registration.getNotifications(search).forEach(n => n.close())
+  }
+
   if (type === 'updateFocus') {
     state.lastFocused = event.source.id
 
-- 
cgit v1.2.3-70-g09d2


From 730351dcd19540afff89355a2014cd9793fe07bf Mon Sep 17 00:00:00 2001
From: NEETzsche <neetzsche@tutanota.com>
Date: Wed, 15 Nov 2023 01:13:46 -0700
Subject: Accommodate scrobble URLs when present

---
 changelog.d/create-link-when-url-present.add |  1 +
 src/components/status/status.vue             | 57 ++++++++++++++++++++--------
 2 files changed, 43 insertions(+), 15 deletions(-)
 create mode 100644 changelog.d/create-link-when-url-present.add

(limited to 'src/components/status/status.vue')

diff --git a/changelog.d/create-link-when-url-present.add b/changelog.d/create-link-when-url-present.add
new file mode 100644
index 00000000..11aa3758
--- /dev/null
+++ b/changelog.d/create-link-when-url-present.add
@@ -0,0 +1 @@
+Create a link to the URL of the scrobble when it's present
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index d66de562..eb10a0b0 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -249,22 +249,49 @@
                 </button>
               </span>
             </div>
-            <div class="status-rich-presence" v-if="scrobblePresent">
-              <FAIcon
-                class="fa-scale-110 fa-old-padding"
-                icon="music"
-              />
-              {{ scrobble.artist }} — {{ scrobble.title }}
-              <FAIcon
-                class="fa-scale-110 fa-old-padding"
-                icon="play"
-              />
-              <span class="status-rich-presence-time">
-                <Timeago
-                  template-key="time.in_past"
-                  :time="scrobble.created_at"
-                  :auto-update="60"
+            <div
+              v-if="scrobblePresent"
+              class="status-rich-presence"
+            >
+              <a
+                v-if="scrobble.externalLink"
+                :href="scrobble.externalLink"
+                target="_blank"
+              >
+                <img
+                  :src="faviconUrl"
+                  class="status-rich-presence-favicon"
+                />
+                {{ scrobble.artist }} — {{ scrobble.title }}
+                <FAIcon
+                  class="fa-scale-110 fa-old-padding"
+                  icon="play"
                 />
+                <span class="status-rich-presence-time">
+                  <Timeago
+                    template-key="time.in_past"
+                    :time="scrobble.created_at"
+                    :auto-update="60"
+                  />
+                </span>
+              </a>
+              <span v-if="!scrobble.externalLink">
+                <FAIcon
+                  class="fa-scale-110 fa-old-padding"
+                  icon="music"
+                />
+                {{ scrobble.artist }} — {{ scrobble.title }}
+                <FAIcon
+                  class="fa-scale-110 fa-old-padding"
+                  icon="play"
+                />
+                <span class="status-rich-presence-time">
+                  <Timeago
+                    template-key="time.in_past"
+                    :time="scrobble.created_at"
+                    :auto-update="60"
+                  />
+                </span>
               </span>
             </div>
             <div
-- 
cgit v1.2.3-70-g09d2


From ce46b97c596dcb985dcba41126afac38e26fabcb Mon Sep 17 00:00:00 2001
From: HJ <30-hj@users.noreply.git.pleroma.social>
Date: Thu, 30 Nov 2023 17:47:32 +0000
Subject: Remove <img> for favicon

---
 src/components/status/status.vue | 4 ----
 1 file changed, 4 deletions(-)

(limited to 'src/components/status/status.vue')

diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index eb10a0b0..26fafc91 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -258,10 +258,6 @@
                 :href="scrobble.externalLink"
                 target="_blank"
               >
-                <img
-                  :src="faviconUrl"
-                  class="status-rich-presence-favicon"
-                />
                 {{ scrobble.artist }} — {{ scrobble.title }}
                 <FAIcon
                   class="fa-scale-110 fa-old-padding"
-- 
cgit v1.2.3-70-g09d2


From cc4aaccf3834a18a32606ca175dc6bb910a78b60 Mon Sep 17 00:00:00 2001
From: tusooa <tusooa@kazv.moe>
Date: Wed, 27 Dec 2023 22:54:44 -0500
Subject: Implement indicator for groups

---
 src/components/settings_modal/tabs/filtering_tab.vue |  2 +-
 src/components/status/status.js                      | 10 ++--------
 src/components/status/status.vue                     |  6 +++---
 src/components/user_avatar/user_avatar.js            |  8 +++++---
 src/components/user_avatar/user_avatar.vue           | 11 ++++++++---
 src/i18n/en.json                                     |  2 +-
 6 files changed, 20 insertions(+), 19 deletions(-)

(limited to 'src/components/status/status.vue')

diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue
index 89fdef1a..9e82fcfd 100644
--- a/src/components/settings_modal/tabs/filtering_tab.vue
+++ b/src/components/settings_modal/tabs/filtering_tab.vue
@@ -51,7 +51,7 @@
         </li>
         <li>
           <BooleanSetting path="hideBotIndication">
-            {{ $t('settings.hide_bot_indication') }}
+            {{ $t('settings.hide_actor_type_indication') }}
           </BooleanSetting>
         </li>
         <ChoiceSetting
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 129929a3..458c8554 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -232,17 +232,11 @@ const Status = {
     muteWordHits () {
       return muteWordHits(this.status, this.muteWords)
     },
-    rtBotStatus () {
-      return this.statusoid.user.bot
-    },
     botStatus () {
       return this.status.user.bot
     },
-    botIndicator () {
-      return this.botStatus && !this.hideBotIndication
-    },
-    rtBotIndicator () {
-      return this.rtBotStatus && !this.hideBotIndication
+    showActorTypeIndicator () {
+      return !this.hideBotIndication
     },
     mentionsLine () {
       if (!this.headTailLinks) return []
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index fa9d6f0b..1c91c36c 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -79,7 +79,7 @@
         <UserAvatar
           v-if="retweet"
           class="left-side repeater-avatar"
-          :bot="rtBotIndicator"
+          :show-actor-type-indicator="showActorTypeIndicator"
           :better-shadow="betterShadow"
           :user="statusoid.user"
         />
@@ -133,7 +133,7 @@
             >
               <UserAvatar
                 class="post-avatar"
-                :bot="botIndicator"
+                :show-actor-type-indicator="showActorTypeIndicator"
                 :compact="compact"
                 :better-shadow="betterShadow"
                 :user="status.user"
@@ -559,7 +559,7 @@
           <UserAvatar
             class="post-avatar"
             :compact="compact"
-            :bot="botIndicator"
+            :show-actor-type-indicator="showActorTypeIndicator"
           />
         </div>
         <div class="right-side">
diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js
index 33d9a258..ffd81f87 100644
--- a/src/components/user_avatar/user_avatar.js
+++ b/src/components/user_avatar/user_avatar.js
@@ -3,11 +3,13 @@ import StillImage from '../still-image/still-image.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 
 import {
-  faRobot
+  faRobot,
+  faPeopleGroup
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
-  faRobot
+  faRobot,
+  faPeopleGroup
 )
 
 const UserAvatar = {
@@ -15,7 +17,7 @@ const UserAvatar = {
     'user',
     'betterShadow',
     'compact',
-    'bot'
+    'showActorTypeIndicator'
   ],
   data () {
     return {
diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue
index 91c17611..3cbccec3 100644
--- a/src/components/user_avatar/user_avatar.vue
+++ b/src/components/user_avatar/user_avatar.vue
@@ -18,9 +18,14 @@
       :class="{ '-compact': compact }"
     />
     <FAIcon
-      v-if="bot"
+      v-if="showActorTypeIndicator && user?.actor_type === 'Service'"
       icon="robot"
-      class="bot-indicator"
+      class="actor-type-indicator"
+    />
+    <FAIcon
+      v-if="showActorTypeIndicator && user?.actor_type === 'Group'"
+      icon="people-group"
+      class="actor-type-indicator"
     />
   </span>
 </template>
@@ -79,7 +84,7 @@
     height: 100%;
   }
 
-  .bot-indicator {
+  .actor-type-indicator {
     position: absolute;
     bottom: 0;
     right: 0;
diff --git a/src/i18n/en.json b/src/i18n/en.json
index bb93f26f..71098822 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -499,7 +499,7 @@
     "hide_media_previews": "Hide media previews",
     "hide_muted_posts": "Hide posts of muted users",
     "mute_bot_posts": "Mute bot posts",
-    "hide_bot_indication": "Hide bot indication in posts",
+    "hide_actor_type_indication": "Hide actor type (bots, groups, etc.) indication in posts",
     "hide_scrobbles": "Hide scrobbles",
     "hide_all_muted_posts": "Hide muted posts",
     "max_thumbnails": "Maximum amount of thumbnails per post (empty = no limit)",
-- 
cgit v1.2.3-70-g09d2


From 6c4c8fe51f0e2c4ce55f5915a2bc5434aef5e5e7 Mon Sep 17 00:00:00 2001
From: marcin mikołajczak <git@mkljczk.pl>
Date: Thu, 4 Jan 2024 22:46:04 +0100
Subject: Display quotes count on posts and add quotes list page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
---
 changelog.d/quotes-count.add                       |  1 +
 src/boot/routes.js                                 |  2 ++
 src/components/quotes_timeline/quotes_timeline.js  | 26 ++++++++++++++++++++++
 src/components/quotes_timeline/quotes_timeline.vue | 10 +++++++++
 src/components/status/status.scss                  |  1 +
 src/components/status/status.vue                   | 13 +++++++++++
 src/components/timeline/timeline.js                |  3 +++
 src/components/timeline_menu/timeline_menu.js      |  3 ++-
 src/i18n/en.json                                   |  4 +++-
 src/modules/api.js                                 |  5 +++--
 src/services/api/api.service.js                    |  9 +++++++-
 .../backend_interactor_service.js                  |  4 ++--
 .../entity_normalizer/entity_normalizer.service.js |  1 +
 .../timeline_fetcher/timeline_fetcher.service.js   |  8 ++++---
 14 files changed, 80 insertions(+), 10 deletions(-)
 create mode 100644 changelog.d/quotes-count.add
 create mode 100644 src/components/quotes_timeline/quotes_timeline.js
 create mode 100644 src/components/quotes_timeline/quotes_timeline.vue

(limited to 'src/components/status/status.vue')

diff --git a/changelog.d/quotes-count.add b/changelog.d/quotes-count.add
new file mode 100644
index 00000000..86779b96
--- /dev/null
+++ b/changelog.d/quotes-count.add
@@ -0,0 +1 @@
+Display quotes count on posts and add quotes list page
\ No newline at end of file
diff --git a/src/boot/routes.js b/src/boot/routes.js
index 2dc900e7..31e3dbb0 100644
--- a/src/boot/routes.js
+++ b/src/boot/routes.js
@@ -25,6 +25,7 @@ import ListsTimeline from 'components/lists_timeline/lists_timeline.vue'
 import ListsEdit from 'components/lists_edit/lists_edit.vue'
 import NavPanel from 'src/components/nav_panel/nav_panel.vue'
 import AnnouncementsPage from 'components/announcements_page/announcements_page.vue'
+import QuotesTimeline from '../components/quotes_timeline/quotes_timeline.vue'
 
 export default (store) => {
   const validateAuthenticatedRoute = (to, from, next) => {
@@ -51,6 +52,7 @@ export default (store) => {
     { name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
     { name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
     { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
+    { name: 'quotes', path: '/notice/:id/quotes', component: QuotesTimeline },
     {
       name: 'remote-user-profile-acct',
       path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)',
diff --git a/src/components/quotes_timeline/quotes_timeline.js b/src/components/quotes_timeline/quotes_timeline.js
new file mode 100644
index 00000000..a5f42da5
--- /dev/null
+++ b/src/components/quotes_timeline/quotes_timeline.js
@@ -0,0 +1,26 @@
+import Timeline from '../timeline/timeline.vue'
+
+const QuotesTimeline = {
+  created () {
+    this.$store.commit('clearTimeline', { timeline: 'quotes' })
+    this.$store.dispatch('startFetchingTimeline', { timeline: 'quotes', statusId: this.statusId })
+  },
+  components: {
+    Timeline
+  },
+  computed: {
+    statusId () { return this.$route.params.id },
+    timeline () { return this.$store.state.statuses.timelines.quotes }
+  },
+  watch: {
+    statusId () {
+      this.$store.commit('clearTimeline', { timeline: 'quotes' })
+      this.$store.dispatch('startFetchingTimeline', { timeline: 'quotes', statusId: this.statusId })
+    }
+  },
+  unmounted () {
+    this.$store.dispatch('stopFetchingTimeline', 'quotes')
+  }
+}
+
+export default QuotesTimeline
diff --git a/src/components/quotes_timeline/quotes_timeline.vue b/src/components/quotes_timeline/quotes_timeline.vue
new file mode 100644
index 00000000..835abd12
--- /dev/null
+++ b/src/components/quotes_timeline/quotes_timeline.vue
@@ -0,0 +1,10 @@
+<template>
+  <Timeline
+    :title="$t('nav.quotes')"
+    :timeline="timeline"
+    :timeline-name="'quotes'"
+    :status-id="statusId"
+  />
+</template>
+
+<script src='./quotes_timeline.js'></script>
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 760c6ac1..54c4b4ff 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -398,6 +398,7 @@
       font-weight: bolder;
       font-size: 1.1em;
       line-height: 1em;
+      color: var(--selectedPostText, $fallback--text);
     }
 
     &:hover .stat-title {
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index fa9d6f0b..11dc1e2e 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -506,6 +506,19 @@
                     </div>
                   </div>
                 </UserListPopover>
+                <router-link
+                  v-if="statusFromGlobalRepository.quotes_count > 0"
+                  :to="{ name: 'quotes', params: { id: status.id } }"
+                >
+                  <div
+                    class="stat-count"
+                  >
+                    <a class="stat-title">{{ $t('status.quotes') }}</a>
+                    <div class="stat-number">
+                      {{ statusFromGlobalRepository.quotes_count }}
+                    </div>
+                  </div>
+                </router-link>
                 <div class="avatar-row">
                   <AvatarList :users="combinedFavsAndRepeatsUsers" />
                 </div>
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 1050b87a..6cb9d1f1 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -25,6 +25,7 @@ const Timeline = {
     'title',
     'userId',
     'listId',
+    'statusId',
     'tag',
     'embedded',
     'count',
@@ -121,6 +122,7 @@ const Timeline = {
       showImmediately,
       userId: this.userId,
       listId: this.listId,
+      statusId: this.statusId,
       tag: this.tag
     })
   },
@@ -183,6 +185,7 @@ const Timeline = {
         showImmediately: true,
         userId: this.userId,
         listId: this.listId,
+        statusId: this.statusId,
         tag: this.tag
       }).then(({ statuses }) => {
         if (statuses && statuses.length === 0) {
diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js
index 5a2a86c2..c4586b32 100644
--- a/src/components/timeline_menu/timeline_menu.js
+++ b/src/components/timeline_menu/timeline_menu.js
@@ -19,7 +19,8 @@ export const timelineNames = () => {
     bookmarks: 'nav.bookmarks',
     dms: 'nav.dms',
     'public-timeline': 'nav.public_tl',
-    'public-external-timeline': 'nav.twkn'
+    'public-external-timeline': 'nav.twkn',
+    quotes: 'nav.quotes'
   }
 }
 
diff --git a/src/i18n/en.json b/src/i18n/en.json
index f4c9de18..96b84c90 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -190,7 +190,8 @@
     "mobile_notifications": "Open notifications (there are unread ones)",
     "mobile_notifications_close": "Close notifications",
     "mobile_notifications_mark_as_seen": "Mark all as seen",
-    "announcements": "Announcements"
+    "announcements": "Announcements",
+    "quotes": "Quotes"
   },
   "notifications": {
     "broken_favorite": "Unknown status, searching for it…",
@@ -996,6 +997,7 @@
   "status": {
     "favorites": "Favorites",
     "repeats": "Repeats",
+    "quotes": "Quotes",
     "repeat_confirm": "Do you really want to repeat this status?",
     "repeat_confirm_title": "Repeat confirmation",
     "repeat_confirm_accept_button": "Repeat",
diff --git a/src/modules/api.js b/src/modules/api.js
index fee584e8..3dbead5b 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -202,12 +202,13 @@ const api = {
       timeline = 'friends',
       tag = false,
       userId = false,
-      listId = false
+      listId = false,
+      statusId = false
     }) {
       if (store.state.fetchers[timeline]) return
 
       const fetcher = store.state.backendInteractor.startFetchingTimeline({
-        timeline, store, userId, listId, tag
+        timeline, store, userId, listId, statusId, tag
       })
       store.commit('addFetcher', { fetcherName: timeline, fetcher })
     },
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index bde2e163..004698e7 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -108,6 +108,7 @@ const PLEROMA_POST_ANNOUNCEMENT_URL = '/api/v1/pleroma/admin/announcements'
 const PLEROMA_EDIT_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}`
 const PLEROMA_DELETE_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}`
 const PLEROMA_SCROBBLES_URL = id => `/api/v1/pleroma/accounts/${id}/scrobbles`
+const PLEROMA_STATUS_QUOTES_URL = id => `/api/v1/pleroma/statuses/${id}/quotes`
 
 const PLEROMA_ADMIN_CONFIG_URL = '/api/pleroma/admin/config'
 const PLEROMA_ADMIN_DESCRIPTIONS_URL = '/api/pleroma/admin/config/descriptions'
@@ -675,6 +676,7 @@ const fetchTimeline = ({
   until = false,
   userId = false,
   listId = false,
+  statusId = false,
   tag = false,
   withMuted = false,
   replyVisibility = 'all',
@@ -691,7 +693,8 @@ const fetchTimeline = ({
     list: MASTODON_LIST_TIMELINE_URL,
     favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
     tag: MASTODON_TAG_TIMELINE_URL,
-    bookmarks: MASTODON_BOOKMARK_TIMELINE_URL
+    bookmarks: MASTODON_BOOKMARK_TIMELINE_URL,
+    quotes: PLEROMA_STATUS_QUOTES_URL
   }
   const isNotifications = timeline === 'notifications'
   const params = []
@@ -706,6 +709,10 @@ const fetchTimeline = ({
     url = url(listId)
   }
 
+  if (timeline === 'quotes') {
+    url = url(statusId)
+  }
+
   if (minId) {
     params.push(['min_id', minId])
   }
diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js
index 62ee8549..8ceb897d 100644
--- a/src/services/backend_interactor_service/backend_interactor_service.js
+++ b/src/services/backend_interactor_service/backend_interactor_service.js
@@ -5,8 +5,8 @@ import followRequestFetcher from '../../services/follow_request_fetcher/follow_r
 import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js'
 
 const backendInteractorService = credentials => ({
-  startFetchingTimeline ({ timeline, store, userId = false, listId = false, tag }) {
-    return timelineFetcher.startFetching({ timeline, store, credentials, userId, listId, tag })
+  startFetchingTimeline ({ timeline, store, userId = false, listId = false, statusId = false, tag }) {
+    return timelineFetcher.startFetching({ timeline, store, credentials, userId, listId, statusId, tag })
   },
 
   fetchTimeline (args) {
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 85da5223..4d671c5d 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -329,6 +329,7 @@ export const parseStatus = (data) => {
       output.quote_id = pleroma.quote_id ? pleroma.quote_id : (output.quote ? output.quote.id : undefined)
       output.quote_url = pleroma.quote_url
       output.quote_visible = pleroma.quote_visible
+      output.quotes_count = pleroma.quotes_count
     } else {
       output.text = data.content
       output.summary = data.spoiler_text
diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js
index 8501907e..2fed14bc 100644
--- a/src/services/timeline_fetcher/timeline_fetcher.service.js
+++ b/src/services/timeline_fetcher/timeline_fetcher.service.js
@@ -24,6 +24,7 @@ const fetchAndUpdate = ({
   showImmediately = false,
   userId = false,
   listId = false,
+  statusId = false,
   tag = false,
   until,
   since
@@ -47,6 +48,7 @@ const fetchAndUpdate = ({
 
   args.userId = userId
   args.listId = listId
+  args.statusId = statusId
   args.tag = tag
   args.withMuted = !hideMutedPosts
   if (loggedIn && ['friends', 'public', 'publicAndExternal'].includes(timeline)) {
@@ -78,15 +80,15 @@ const fetchAndUpdate = ({
     })
 }
 
-const startFetching = ({ timeline = 'friends', credentials, store, userId = false, listId = false, tag = false }) => {
+const startFetching = ({ timeline = 'friends', credentials, store, userId = false, listId = false, statusId = false, tag = false }) => {
   const rootState = store.rootState || store.state
   const timelineData = rootState.statuses.timelines[camelCase(timeline)]
   const showImmediately = timelineData.visibleStatuses.length === 0
   timelineData.userId = userId
   timelineData.listId = listId
-  fetchAndUpdate({ timeline, credentials, store, showImmediately, userId, listId, tag })
+  fetchAndUpdate({ timeline, credentials, store, showImmediately, userId, listId, statusId, tag })
   const boundFetchAndUpdate = () =>
-    fetchAndUpdate({ timeline, credentials, store, userId, listId, tag })
+    fetchAndUpdate({ timeline, credentials, store, userId, listId, statusId, tag })
   return promiseInterval(boundFetchAndUpdate, 10000)
 }
 const timelineFetcher = {
-- 
cgit v1.2.3-70-g09d2


From f6d3a66a5b7bcbbecc72a66532866ee096c7752d Mon Sep 17 00:00:00 2001
From: marcin mikołajczak <git@mkljczk.pl>
Date: Fri, 19 Jan 2024 19:01:11 +0100
Subject: shouldDisplayFavsAndRepeats
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
---
 src/components/status/status.js  | 3 +++
 src/components/status/status.vue | 2 +-
 2 files changed, 4 insertions(+), 1 deletion(-)

(limited to 'src/components/status/status.vue')

diff --git a/src/components/status/status.js b/src/components/status/status.js
index 129929a3..9e6c9528 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -374,6 +374,9 @@ const Status = {
     hidePostStats () {
       return this.mergedConfig.hidePostStats
     },
+    shouldDisplayFavsAndRepeats () {
+      return !this.hidePostStats && this.isFocused && (this.combinedFavsAndRepeatsUsers.length > 0 || this.statusFromGlobalRepository.quotes_count)
+    },
     muteBotStatuses () {
       return this.mergedConfig.muteBotStatuses
     },
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 11dc1e2e..54fcc60f 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -478,7 +478,7 @@
 
           <transition name="fade">
             <div
-              v-if="!hidePostStats && isFocused && combinedFavsAndRepeatsUsers.length > 0"
+              v-if="shouldDisplayFavsAndRepeats"
               class="favs-repeated-users"
             >
               <div class="stats">
-- 
cgit v1.2.3-70-g09d2


From 53a4b1f9a6a9aa6bc044609c3accb074d924daf9 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Wed, 31 Jan 2024 17:39:51 +0200
Subject: better virtual components and stuff

---
 src/App.scss                                       |  28 +-
 src/components/icon.style.js                       |  13 +-
 src/components/link.style.js                       |  25 ++
 src/components/notification/notification.vue       |   2 +-
 src/components/notifications/notifications.scss    |  16 +-
 src/components/panel.style.js                      |   1 +
 src/components/panel_header.style.js               |   1 +
 src/components/status/status.vue                   |   2 +-
 src/components/status_body/status_body.scss        |   5 +-
 src/components/text.style.js                       |  38 ++-
 src/components/underlay.style.js                   |   3 +-
 src/panel.scss                                     |  11 -
 src/services/color_convert/color_convert.js        |   3 +-
 src/services/style_setter/style_setter.js          |  19 +-
 src/services/theme_data/pleromafe.t3.js            |  16 +-
 src/services/theme_data/theme_data_3.service.js    | 304 ++++++++++++++++-----
 .../specs/services/theme_data/theme_data3.spec.js  |   2 +-
 17 files changed, 353 insertions(+), 136 deletions(-)
 create mode 100644 src/components/link.style.js

(limited to 'src/components/status/status.vue')

diff --git a/src/App.scss b/src/App.scss
index ef68ac50..8e9f3171 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -24,8 +24,7 @@ body {
   font-family: sans-serif;
   font-family: var(--interfaceFont, sans-serif);
   margin: 0;
-  color: $fallback--text;
-  color: var(--text, $fallback--text);
+  color: var(--text);
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
   overscroll-behavior-y: none;
@@ -111,8 +110,7 @@ body {
 
 a {
   text-decoration: none;
-  color: $fallback--link;
-  color: var(--link, $fallback--link);
+  color: var(--link);
 }
 
 h4 {
@@ -128,8 +126,7 @@ h4 {
 i[class*="icon-"],
 .svg-inline--fa,
 .iconLetter {
-  color: $fallback--icon;
-  color: var(--icon, $fallback--icon);
+  color: var(--icon);
 }
 
 .button-unstyled:hover,
@@ -763,17 +760,11 @@ option {
 }
 
 .faint {
-  color: $fallback--faint;
-  color: var(--faint, $fallback--faint);
-}
-
-.faint-link {
-  color: $fallback--faint;
-  color: var(--faint, $fallback--faint);
+  --text: var(--textFaint);
+  --textGreentext: var(--textGreentextFaint);
+  --link: var(--linkFaint);
 
-  &:hover {
-    text-decoration: underline;
-  }
+  color: var(--text);
 }
 
 .visibility-notice {
@@ -816,6 +807,11 @@ option {
   opacity: 0.25;
 }
 
+.timeago {
+  --link: var(--text);
+  --linkFaint: var(--textFaint);
+}
+
 .login-hint {
   text-align: center;
 
diff --git a/src/components/icon.style.js b/src/components/icon.style.js
index 732cf16f..adc72fc5 100644
--- a/src/components/icon.style.js
+++ b/src/components/icon.style.js
@@ -1,4 +1,15 @@
 export default {
   name: 'Icon',
-  selector: '.icon'
+  virtual: true,
+  selector: '.svg-inline--fa',
+  defaultRules: [
+    {
+      component: 'Icon',
+      directives: {
+        textColor: '--text',
+        textOpacity: 0.5,
+        textOpacityMode: 'mixrgb'
+      }
+    }
+  ]
 }
diff --git a/src/components/link.style.js b/src/components/link.style.js
new file mode 100644
index 00000000..0128fd91
--- /dev/null
+++ b/src/components/link.style.js
@@ -0,0 +1,25 @@
+export default {
+  name: 'Link',
+  selector: 'a',
+  virtual: true,
+  states: {
+    faint: '.faint'
+  },
+  defaultRules: [
+    {
+      component: 'Link',
+      directives: {
+        textColor: '--link'
+      }
+    },
+    {
+      component: 'Link',
+      state: ['faint'],
+      directives: {
+        textColor: '--link',
+        textOpacity: 0.5,
+        textOpacityMode: 'fake'
+      }
+    }
+  ]
+}
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index a8eceab0..5c425200 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -155,7 +155,7 @@
             <router-link
               v-if="notification.status"
               :to="{ name: 'conversation', params: { id: notification.status.id } }"
-              class="timeago-link faint-link"
+              class="timeago-link faint"
             >
               <Timeago
                 :time="notification.created_at"
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index 5749e430..708d111b 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -7,8 +7,7 @@
   }
 
   .loadmore-error {
-    color: $fallback--text;
-    color: var(--text, $fallback--text);
+    color: var(--text);
   }
 
   .notification {
@@ -25,7 +24,7 @@
 
     &.unseen {
       .notification-overlay {
-        background-image: linear-gradient(135deg, var(--badgeNotification, $fallback--cRed) 4px, transparent 10px);
+        background-image: linear-gradient(135deg, var(--badgeNotification) 4px, transparent 10px);
       }
     }
   }
@@ -60,24 +59,17 @@
       width: 32px;
       height: 32px;
     }
-
-    .faint {
-      --link: var(--faintLink);
-      --text: var(--faint);
-    }
   }
 
   .follow-request-accept {
     &:hover {
-      color: $fallback--text;
-      color: var(--text, $fallback--text);
+      color: var(--text);
     }
   }
 
   .follow-request-reject {
     &:hover {
-      color: $fallback--cRed;
-      color: var(--cRed, $fallback--cRed);
+      color: var(--cRed);
     }
   }
 
diff --git a/src/components/panel.style.js b/src/components/panel.style.js
index e3da4d1a..870f5099 100644
--- a/src/components/panel.style.js
+++ b/src/components/panel.style.js
@@ -3,6 +3,7 @@ export default {
   selector: '.panel',
   validInnerComponents: [
     'Text',
+    'Link',
     'Icon',
     'Button',
     'PanelHeader'
diff --git a/src/components/panel_header.style.js b/src/components/panel_header.style.js
index aaa8bea9..e83e1f15 100644
--- a/src/components/panel_header.style.js
+++ b/src/components/panel_header.style.js
@@ -3,6 +3,7 @@ export default {
   selector: '.panel-heading',
   validInnerComponents: [
     'Text',
+    'Link',
     'Icon',
     'Button'
   ]
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index fa9d6f0b..7c56430f 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -180,7 +180,7 @@
 
               <span class="heading-right">
                 <router-link
-                  class="timeago faint-link"
+                  class="timeago faint"
                   :to="{ name: 'conversation', params: { id: status.id } }"
                 >
                   <Timeago
diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss
index 8fd60afc..a8878745 100644
--- a/src/components/status_body/status_body.scss
+++ b/src/components/status_body/status_body.scss
@@ -113,12 +113,11 @@
   }
 
   .greentext {
-    color: $fallback--cGreen;
-    color: var(--postGreentext, $fallback--cGreen);
+    color: var(--textGreentext);
   }
 
   .cyantext {
-    color: var(--postCyantext, $fallback--cBlue);
+    color: var(--textCyantext);
   }
 
   &.-compact {
diff --git a/src/components/text.style.js b/src/components/text.style.js
index 050194cb..e52b6f68 100644
--- a/src/components/text.style.js
+++ b/src/components/text.style.js
@@ -1,7 +1,43 @@
 export default {
   name: 'Text',
   selector: '/*text*/',
+  virtual: true,
+  variants: {
+    greentext: '.greentext'
+  },
   states: {
     faint: '.faint'
-  }
+  },
+  defaultRules: [
+    {
+      component: 'Text',
+      directives: {
+        textColor: '--text'
+      }
+    },
+    {
+      component: 'Text',
+      state: ['faint'],
+      directives: {
+        textColor: '--text',
+        textOpacity: 0.5
+      }
+    },
+    {
+      component: 'Text',
+      variant: 'greentext',
+      directives: {
+        textColor: '--cGreen'
+      }
+    },
+    {
+      component: 'Text',
+      variant: 'greentext',
+      state: ['faint'],
+      directives: {
+        textColor: '--cGreen',
+        textOpacity: 0.5
+      }
+    }
+  ]
 }
diff --git a/src/components/underlay.style.js b/src/components/underlay.style.js
index be1ecc56..c35fbada 100644
--- a/src/components/underlay.style.js
+++ b/src/components/underlay.style.js
@@ -1,6 +1,7 @@
 export default {
   name: 'Underlay',
-  selector: '#app',
+  selector: '#content',
+  outOfTreeSelector: '.underlay',
   validInnerComponents: [
     'Panel'
   ]
diff --git a/src/panel.scss b/src/panel.scss
index 9471da11..d6816937 100644
--- a/src/panel.scss
+++ b/src/panel.scss
@@ -143,12 +143,6 @@
     box-shadow: var(--panelHeaderShadow);
   }
 
-  a,
-  .-link {
-    color: $fallback--link;
-    color: var(--panelLink, $fallback--link);
-  }
-
   .button-unstyled:hover,
   a:hover {
     i[class*="icon-"],
@@ -164,11 +158,6 @@
     color: var(--panelFaint, $fallback--faint);
   }
 
-  .faint-link {
-    color: $fallback--faint;
-    color: var(--faintLink, $fallback--faint);
-  }
-
   &:not(.-flexible-height) {
     > .button-default {
       flex-shrink: 0;
diff --git a/src/services/color_convert/color_convert.js b/src/services/color_convert/color_convert.js
index 23e4ca61..d92bbbe0 100644
--- a/src/services/color_convert/color_convert.js
+++ b/src/services/color_convert/color_convert.js
@@ -173,7 +173,7 @@ export const mixrgb = (a, b) => {
  * @returns {String} CSS rgba() color
  */
 export const rgba2css = function (rgba) {
-  return `rgba(${Math.floor(rgba.r)}, ${Math.floor(rgba.g)}, ${Math.floor(rgba.b)}, ${rgba.a})`
+  return `rgba(${Math.floor(rgba.r)}, ${Math.floor(rgba.g)}, ${Math.floor(rgba.b)}, ${rgba.a ?? 1})`
 }
 
 /**
@@ -188,7 +188,6 @@ export const rgba2css = function (rgba) {
  */
 export const getTextColor = function (bg, text, preserve) {
   const contrast = getContrastRatio(bg, text)
-  console.log(contrast)
 
   if (contrast < 4.5) {
     const base = typeof text.a !== 'undefined' ? { a: text.a } : {}
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index 43fe3c73..ba98c4d9 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -1,10 +1,15 @@
 import { convert } from 'chromatism'
 import { rgb2hex, hex2rgb, rgba2css, getCssColor, relativeLuminance } from '../color_convert/color_convert.js'
 import { getColors, computeDynamicColor, getOpacitySlot } from '../theme_data/theme_data.service.js'
+import { init } from '../theme_data/theme_data_3.service.js'
+import {
+  sampleRules
+} from 'src/services/theme_data/pleromafe.t3.js'
 import { defaultState } from '../../modules/config.js'
 
 export const applyTheme = (input) => {
-  const { rules } = generatePreset(input)
+  const { rules, t3b } = generatePreset(input)
+  const themes3 = init(sampleRules, t3b)
   const head = document.head
   const body = document.body
   body.classList.add('hidden')
@@ -18,6 +23,10 @@ export const applyTheme = (input) => {
   styleSheet.insertRule(`:root { ${rules.colors} }`, 'index-max')
   styleSheet.insertRule(`:root { ${rules.shadows} }`, 'index-max')
   styleSheet.insertRule(`:root { ${rules.fonts} }`, 'index-max')
+  themes3.css.forEach(rule => {
+    console.log(rule)
+    styleSheet.insertRule(rule, 'index-max')
+  })
   body.classList.remove('hidden')
 }
 
@@ -326,7 +335,7 @@ export const generateShadows = (input, colors) => {
   }
 }
 
-export const composePreset = (colors, radii, shadows, fonts) => {
+export const composePreset = (colors, radii, shadows, fonts, t3b) => {
   return {
     rules: {
       ...shadows.rules,
@@ -339,7 +348,8 @@ export const composePreset = (colors, radii, shadows, fonts) => {
       ...colors.theme,
       ...radii.theme,
       ...fonts.theme
-    }
+    },
+    t3b
   }
 }
 
@@ -349,7 +359,8 @@ export const generatePreset = (input) => {
     colors,
     generateRadii(input),
     generateShadows(input, colors.theme.colors, colors.mod),
-    generateFonts(input)
+    generateFonts(input),
+    colors.theme.colors
   )
 }
 
diff --git a/src/services/theme_data/pleromafe.t3.js b/src/services/theme_data/pleromafe.t3.js
index 8d8e19cd..9fadf0ee 100644
--- a/src/services/theme_data/pleromafe.t3.js
+++ b/src/services/theme_data/pleromafe.t3.js
@@ -11,30 +11,30 @@ export const sampleRules = [
   {
     component: 'Panel',
     directives: {
-      background: '#FFFFFF',
-      opacity: 0.9
+      background: '--fg'
+      // opacity: 0.9
     }
   },
   {
     component: 'PanelHeader',
     directives: {
-      background: '#000000',
-      opacity: 0.9
+      background: '--fg'
+      // opacity: 0.9
     }
   },
   {
     component: 'Button',
     directives: {
-      background: '#000000',
-      opacity: 0.8
+      background: '--fg'
+      // opacity: 0.8
     }
   },
   {
     component: 'Button',
     state: ['hover'],
     directives: {
-      background: '#FF00FF',
-      opacity: 0.9
+      background: '#FFFFFF'
+      // opacity: 0.9
     }
   }
 ]
diff --git a/src/services/theme_data/theme_data_3.service.js b/src/services/theme_data/theme_data_3.service.js
index 2ef5e17f..c9468d07 100644
--- a/src/services/theme_data/theme_data_3.service.js
+++ b/src/services/theme_data/theme_data_3.service.js
@@ -1,11 +1,12 @@
 import { convert } from 'chromatism'
-import { alphaBlend, getTextColor, rgba2css } from '../color_convert/color_convert.js'
+import { alphaBlend, getTextColor, rgba2css, mixrgb } from '../color_convert/color_convert.js'
 
 import Underlay from 'src/components/underlay.style.js'
 import Panel from 'src/components/panel.style.js'
 import PanelHeader from 'src/components/panel_header.style.js'
 import Button from 'src/components/button.style.js'
 import Text from 'src/components/text.style.js'
+import Link from 'src/components/link.style.js'
 import Icon from 'src/components/icon.style.js'
 
 const root = Underlay
@@ -15,6 +16,7 @@ const components = {
   PanelHeader,
   Button,
   Text,
+  Link,
   Icon
 }
 
@@ -35,9 +37,9 @@ export const getAllPossibleCombinations = (array) => {
   return combos.reduce((acc, x) => [...acc, ...x], [])
 }
 
-export const ruleToSelector = (rule) => {
+export const ruleToSelector = (rule, isParent) => {
   const component = components[rule.component]
-  const { states, variants, selector } = component
+  const { states, variants, selector, outOfTreeSelector } = component
 
   const applicableStates = ((rule.state || []).filter(x => x !== 'normal')).map(state => states[state])
 
@@ -47,56 +49,104 @@ export const ruleToSelector = (rule) => {
     applicableVariant = variants[applicableVariantName]
   }
 
-  const selectors = [selector, applicableVariant, ...applicableStates]
+  let realSelector
+  if (isParent) {
+    realSelector = selector
+  } else {
+    if (outOfTreeSelector) realSelector = outOfTreeSelector
+    else realSelector = selector
+  }
+
+  const selectors = [realSelector, applicableVariant, ...applicableStates]
     .toSorted((a, b) => {
       if (a.startsWith(':')) return 1
-      else return -1
+      if (!a.startsWith('.')) return -1
+      else return 0
     })
     .join('')
 
   if (rule.parent) {
-    return ruleToSelector(rule.parent) + ' ' + selectors
+    return ruleToSelector(rule.parent, true) + ' ' + selectors
   }
   return selectors
 }
 
-export const init = (ruleset) => {
+export const init = (extraRuleset, palette) => {
   const rootName = root.name
   const rules = []
   const rulesByComponent = {}
 
+  const ruleset = [
+    ...Object.values(components).map(c => c.defaultRules || []).reduce((acc, arr) => [...acc, ...arr], []),
+    ...extraRuleset
+  ]
+
   const addRule = (rule) => {
     rules.push(rule)
     rulesByComponent[rule.component] = rulesByComponent[rule.component] || []
     rulesByComponent[rule.component].push(rule)
   }
 
-  const findRules = (combination) => rule => {
-    if (combination.component !== rule.component) return false
-    if (Object.prototype.hasOwnProperty.call(rule, 'variant')) {
-      if (combination.variant !== rule.variant) return false
-    } else {
-      if (combination.variant !== 'normal') return false
+  const findRules = (combination, parent) => rule => {
+    // inexact search
+    const doesCombinationMatch = () => {
+      if (combination.component !== rule.component) return false
+      if (Object.prototype.hasOwnProperty.call(rule, 'variant')) {
+        if (combination.variant !== rule.variant) return false
+      } else {
+        if (combination.variant !== 'normal') return false
+      }
+
+      if (Object.prototype.hasOwnProperty.call(rule, 'state')) {
+        const ruleStatesSet = new Set(['normal', ...(rule.state || [])])
+        const combinationSet = new Set(['normal', ...combination.state])
+        const setsAreEqual = combination.state.every(state => ruleStatesSet.has(state)) &&
+              [...ruleStatesSet].every(state => combinationSet.has(state))
+        return setsAreEqual
+      } else {
+        if (combination.state.length !== 1 || combination.state[0] !== 'normal') return false
+        return true
+      }
     }
+    const combinationMatches = doesCombinationMatch()
+    if (!parent || !combinationMatches) return combinationMatches
 
-    if (Object.prototype.hasOwnProperty.call(rule, 'state')) {
-      const ruleStatesSet = new Set(['normal', ...(rule.state || [])])
-      const combinationSet = new Set(['normal', ...combination.state])
-      const setsAreEqual = combination.state.every(state => ruleStatesSet.has(state)) &&
-            [...ruleStatesSet].every(state => combinationSet.has(state))
-      return setsAreEqual
-    } else {
-      if (combination.state.length !== 1 || combination.state[0] !== 'normal') return false
-      return true
+    // exact search
+
+    // unroll parents into array
+    const unroll = (item) => {
+      const out = []
+      let currentParent = item.parent
+      while (currentParent) {
+        const { parent: newParent, ...rest } = currentParent
+        out.push(rest)
+        currentParent = newParent
+      }
+      return out
     }
+    const { parent: _, ...rest } = parent
+    const pathSearch = [rest, ...unroll(parent)]
+    const pathRule = unroll(rule)
+    if (pathSearch.length !== pathRule.length) return false
+    const pathsMatch = pathSearch.every((searchRule, i) => {
+      const existingRule = pathRule[i]
+      if (existingRule.component !== searchRule.component) return false
+      if (existingRule.variant !== searchRule.variant) return false
+      const existingRuleStatesSet = new Set(['normal', ...(existingRule.state || [])])
+      const searchStatesSet = new Set(['normal', ...(searchRule.state || [])])
+      const setsAreEqual = existingRule.state.every(state => searchStatesSet.has(state)) &&
+              [...searchStatesSet].every(state => existingRuleStatesSet.has(state))
+      return setsAreEqual
+    })
+    return pathsMatch
   }
 
   const findLowerLevelRule = (parent, filter = () => true) => {
     let lowerLevelComponent = null
     let currentParent = parent
     while (currentParent) {
-      const rulesParent = ruleset.filter(findRules(currentParent, true))
-      rulesParent > 1 && console.log('OOPS')
+      const rulesParent = ruleset.filter(findRules(currentParent))
+      rulesParent > 1 && console.warn('OOPS')
       lowerLevelComponent = rulesParent[rulesParent.length - 1]
       currentParent = currentParent.parent
       if (lowerLevelComponent && filter(lowerLevelComponent)) currentParent = null
@@ -104,6 +154,35 @@ export const init = (ruleset) => {
     return filter(lowerLevelComponent) ? lowerLevelComponent : null
   }
 
+  const findColor = (color) => {
+    if (typeof color === 'string' && color.startsWith('--')) {
+      const name = color.substring(2)
+      return palette[name]
+    }
+    return color
+  }
+
+  const getTextColorAlpha = (rule, lowerRule, value) => {
+    const opacity = rule.directives.textOpacity
+    const textColor = convert(findColor(value)).rgb
+    if (opacity === null || opacity === undefined || opacity >= 1) {
+      return convert(textColor).hex
+    }
+    const backgroundColor = convert(lowerRule.cache.background).rgb
+    if (opacity === 0) {
+      return convert(backgroundColor).hex
+    }
+    const opacityMode = rule.directives.textOpacityMode
+    switch (opacityMode) {
+      case 'fake':
+        return convert(alphaBlend(textColor, opacity, backgroundColor)).hex
+      case 'mixrgb':
+        return convert(mixrgb(backgroundColor, textColor)).hex
+      default:
+        return rgba2css({ a: opacity, ...textColor })
+    }
+  }
+
   const processInnerComponent = (component, parent) => {
     const {
       validInnerComponents = [],
@@ -124,79 +203,156 @@ export const init = (ruleset) => {
     const VIRTUAL_COMPONENTS = new Set(['Text', 'Link', 'Icon'])
 
     stateVariantCombination.forEach(combination => {
-      const existingRules = ruleset.filter(findRules({ component: component.name, ...combination }))
-      const lastRule = existingRules[existingRules.length - 1]
+      let needRuleAdd = false
 
-      if (existingRules.length !== 0) {
-        const { directives } = lastRule
-        const rgb = convert(directives.background).rgb
+      if (VIRTUAL_COMPONENTS.has(component.name)) {
+        const selector = component.name + ruleToSelector({ component: component.name, ...combination })
+        const virtualName = [
+          '--',
+          component.name.toLowerCase(),
+          combination.variant === 'normal'
+            ? ''
+            : combination.variant[0].toUpperCase() + combination.variant.slice(1).toLowerCase(),
+          ...combination.state.filter(x => x !== 'normal').toSorted().map(state => state[0].toUpperCase() + state.slice(1).toLowerCase())
+        ].join('')
 
-        // TODO: DEFAULT TEXT COLOR
-        const bg = findLowerLevelRule(parent)?.cache.background || convert('#FFFFFF').rgb
+        const lowerLevel = findLowerLevelRule(parent, (r) => {
+          if (components[r.component].validInnerComponents.indexOf(component.name) < 0) return false
+          if (r.cache.background === undefined) return false
+          if (r.cache.textDefined) {
+            return !r.cache.textDefined[selector]
+          }
+          return true
+        })
 
-        if (!lastRule.cache?.background) {
-          const blend = directives.opacity < 1 ? alphaBlend(rgb, directives.opacity, bg) : rgb
-          lastRule.cache = lastRule.cache || {}
-          lastRule.cache.background = blend
+        if (!lowerLevel) return
+
+        let inheritedTextColorRule
+        const inheritedTextColorRules = findLowerLevelRule(parent, (r) => {
+          return r.cache?.textDefined?.[selector]
+        })
+
+        if (!inheritedTextColorRule) {
+          const generalTextColorRules = ruleset.filter(findRules({ component: component.name, ...combination }, null, true))
+          inheritedTextColorRule = generalTextColorRules[generalTextColorRules.length - 1]
+        } else {
+          inheritedTextColorRule = inheritedTextColorRules[inheritedTextColorRules.length - 1]
+        }
 
-          addRule(lastRule)
+        let inheritedTextColor
+        let inheritedTextOpacity = {}
+        if (inheritedTextColorRule) {
+          inheritedTextColor = findColor(inheritedTextColorRule.directives.textColor)
+          // also inherit opacity settings
+          const { textOpacity, textOpacityMode } = inheritedTextColorRule.directives
+          inheritedTextOpacity = { textOpacity, textOpacityMode }
+        } else {
+          // Emergency fallback
+          inheritedTextColor = '#000000'
         }
+
+        const textColor = getTextColor(
+          convert(lowerLevel.cache.background).rgb,
+          convert(inheritedTextColor).rgb,
+          component.name === 'Link' // make it configurable?
+        )
+
+        lowerLevel.cache.textDefined = lowerLevel.cache.textDefined || {}
+        lowerLevel.cache.textDefined[selector] = textColor
+        lowerLevel.virtualDirectives = lowerLevel.virtualDirectives || {}
+        lowerLevel.virtualDirectives[virtualName] = getTextColorAlpha(inheritedTextColorRule, lowerLevel, textColor)
+
+        const directives = {
+          textColor,
+          ...inheritedTextOpacity
+        }
+
+        // Debug: lets you see what it think background color should be
+        directives.background = convert(lowerLevel.cache.background).hex
+
+        addRule({
+          parent,
+          virtual: true,
+          component: component.name,
+          ...combination,
+          cache: { background: lowerLevel.cache.background },
+          directives
+        })
       } else {
-        if (VIRTUAL_COMPONENTS.has(component.name)) {
-          const selector = component.name + ruleToSelector({ component: component.name, ...combination })
-
-          const lowerLevel = findLowerLevelRule(parent, (r) => {
-            if (components[r.component].validInnerComponents.indexOf(component.name) < 0) return false
-            if (r.cache?.background === undefined) return false
-            if (r.cache.textDefined) {
-              return !r.cache.textDefined[selector]
-            }
-            return true
-          })
-          if (!lowerLevel) return
-          lowerLevel.cache.textDefined = lowerLevel.cache.textDefined || {}
-          lowerLevel.cache.textDefined[selector] = true
-          addRule({
-            parent,
-            component: component.name,
-            ...combination,
-            directives: {
-              // TODO: DEFAULT TEXT COLOR
-              textColor: getTextColor(convert(lowerLevel.cache.background).rgb, convert('#FFFFFF').rgb, component.name === 'Link'),
-              // Debug: lets you see what it think background color should be
-              background: convert(lowerLevel.cache.background).hex
+        const existingGlobalRules = ruleset.filter(findRules({ component: component.name, ...combination }, null))
+        const existingRules = ruleset.filter(findRules({ component: component.name, ...combination }, parent))
+
+        // Global (general) rules
+        if (existingGlobalRules.length !== 0) {
+          const lastRule = existingGlobalRules[existingGlobalRules.length - 1]
+          const { directives } = lastRule
+          lastRule.cache = lastRule.cache || {}
+
+          if (directives.background) {
+            const rgb = convert(findColor(directives.background)).rgb
+
+            // TODO: DEFAULT TEXT COLOR
+            const bg = findLowerLevelRule(parent)?.cache.background || convert('#FFFFFF').rgb
+
+            if (!lastRule.cache.background) {
+              const blend = directives.opacity < 1 ? alphaBlend(rgb, directives.opacity, bg) : rgb
+              lastRule.cache.background = blend
+
+              needRuleAdd = true
             }
-          })
+          }
+
+          if (needRuleAdd) {
+            addRule(lastRule)
+          }
         }
-      }
 
+        if (existingRules.length !== 0) {
+          console.warn('MORE EXISTING RULES', existingRules)
+        }
+      }
       innerComponents.forEach(innerComponent => processInnerComponent(innerComponent, { parent, component: name, ...combination }))
     })
   }
 
   processInnerComponent(components[rootName])
 
-  // console.info(rules.map(x => [
-  //   (parent?.component || 'root') + ' -> ' + x.component,
-  //   // 'Cached background:' + convert(bg).hex,
-  //   // 'Color: ' + convert(x.directives.background).hex + ' A:' + x.directives.opacity,
-  //   JSON.stringify(x.directives)
-  //   // '=> Blend: ' + convert(x.cache.background).hex
-  // ].join(' ')))
-
   return {
     raw: rules,
     css: rules.map(rule => {
-      const header = ruleToSelector(rule) + ' {'
+      if (rule.virtual) return ''
+
+      let selector = ruleToSelector(rule).replace(/\/\*.*\*\//g, '')
+      if (!selector) {
+        selector = 'body'
+      }
+      const header = selector + ' {'
       const footer = '}'
+
+      const virtualDirectives = Object.entries(rule.virtualDirectives || {}).map(([k, v]) => {
+        return '  ' + k + ': ' + v
+      }).join(';\n')
+
       const directives = Object.entries(rule.directives).map(([k, v]) => {
         switch (k) {
-          case 'background': return 'background-color: ' + rgba2css({ ...convert(v).rgb, a: rule.directives.opacity ?? 1 })
-          case 'textColor': return 'color: ' + rgba2css({ ...convert(v).rgb, a: rule.directives.opacity ?? 1 })
+          case 'background': {
+            return 'background-color: ' + rgba2css({ ...convert(findColor(v)).rgb, a: rule.directives.opacity ?? 1 })
+          }
+          case 'textColor': {
+            return 'color: ' + v
+          }
           default: return ''
         }
       }).filter(x => x).map(x => '  ' + x).join(';\n')
-      return [header, directives, footer].join('\n')
-    })
+
+      return [
+        header,
+        directives + ';',
+        '  color: var(--text);',
+        '',
+        virtualDirectives,
+        footer
+      ].join('\n')
+    }).filter(x => x)
   }
 }
diff --git a/test/unit/specs/services/theme_data/theme_data3.spec.js b/test/unit/specs/services/theme_data/theme_data3.spec.js
index 915bb5ce..0d5870cc 100644
--- a/test/unit/specs/services/theme_data/theme_data3.spec.js
+++ b/test/unit/specs/services/theme_data/theme_data3.spec.js
@@ -17,7 +17,7 @@ describe.only('Theme Data 3', () => {
 
   describe('init', () => {
     it('test simple case', () => {
-      const out = init(sampleRules)
+      const out = init(sampleRules, palette)
       // console.log(JSON.stringify(out, null, 2))
       console.log('\n' + out.css.join('\n') + '\n')
     })
-- 
cgit v1.2.3-70-g09d2


From f4d48e401abd7789968e0b3decd50fc62bd7bc29 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 27 Feb 2024 22:20:41 +0200
Subject: fix reply link being faint (didn't work before, no reason to keep it
 "faint" imo)

---
 src/components/status/status.vue | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

(limited to 'src/components/status/status.vue')

diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 7c56430f..2a46c5b9 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -450,7 +450,7 @@
           >
             <button
               v-if="showOtherRepliesAsButton && replies.length > 1"
-              class="button-unstyled -link faint"
+              class="button-unstyled -link"
               :title="$tc('status.ancestor_follow', replies.length - 1, { numReplies: replies.length - 1 })"
               @click.prevent="dive"
             >
-- 
cgit v1.2.3-70-g09d2


From 046678086fcdddf05c2c10f3494bf87e67f2fd6a Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Wed, 24 Apr 2024 15:22:19 +0300
Subject: add explanation to why post is muted for sensitive muting

---
 src/components/status/status.vue | 6 ++++++
 src/i18n/en.json                 | 1 +
 2 files changed, 7 insertions(+)

(limited to 'src/components/status/status.vue')

diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 4d9e1c3c..2a17bb13 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -30,6 +30,12 @@
             :at="false"
           />
         </small>
+        <small
+          v-if="muteSensitiveStatuses && status.nsfw"
+          class="mute-thread"
+        >
+          {{ $t('status.sensitive_muted') }}
+        </small>
         <small
           v-if="showReasonMutedThread"
           class="mute-thread"
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 6377e994..978fda37 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1075,6 +1075,7 @@
     "external_source": "External source",
     "thread_muted": "Thread muted",
     "thread_muted_and_words": ", has words:",
+    "sensitive_muted": "Muting sensitive content",
     "show_full_subject": "Show full subject",
     "hide_full_subject": "Hide full subject",
     "show_content": "Show content",
-- 
cgit v1.2.3-70-g09d2

