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.vue | 1 +
 1 file changed, 1 insertion(+)

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

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>
-- 
cgit v1.2.3-70-g09d2


From aad3225d25460170a8dd48f8ffcbc63f99a28b7f Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Thu, 16 Nov 2023 19:26:18 +0200
Subject: refactored notifications into their own module separate from statuses
 (WIP)

---
 src/components/notification/notification.vue       |   2 +-
 src/components/notifications/notifications.js      |  16 +--
 src/main.js                                        |   2 +
 src/modules/notifications.js                       | 158 +++++++++++++++++++++
 src/modules/statuses.js                            | 141 +-----------------
 src/modules/users.js                               |   2 +-
 .../desktop_notification_utils.js                  |   2 +-
 .../entity_normalizer/entity_normalizer.service.js |   1 -
 .../notification_utils/notification_utils.js       |   2 +-
 .../notifications_fetcher.service.js               |   2 +-
 10 files changed, 170 insertions(+), 158 deletions(-)
 create mode 100644 src/modules/notifications.js

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

diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 01ad395f..a8eceab0 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -249,7 +249,7 @@
           <StatusContent
             :class="{ faint: !statusExpanded }"
             :compact="!statusExpanded"
-            :status="notification.action"
+            :status="notification.status"
           />
         </template>
       </div>
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index 4cbe8093..a1088dff 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -65,7 +65,7 @@ const Notifications = {
       return notificationsFromStore(this.$store)
     },
     error () {
-      return this.$store.state.statuses.notifications.error
+      return this.$store.state.notifications.error
     },
     unseenNotifications () {
       return unseenNotificationsFromStore(this.$store)
@@ -86,7 +86,7 @@ const Notifications = {
       return this.unseenNotifications.length + (this.unreadChatCount) + this.unreadAnnouncementCount
     },
     loading () {
-      return this.$store.state.statuses.notifications.loading
+      return this.$store.state.notifications.loading
     },
     noHeading () {
       const { layoutType } = this.$store.state.interface
@@ -160,17 +160,7 @@ const Notifications = {
       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)
-        }
-      }
+      // const { type, id, seen } = notification
     },
     notificationInteracted (notification) {
       const { id, seen } = notification
diff --git a/src/main.js b/src/main.js
index 0b7c7674..85eb1f4c 100644
--- a/src/main.js
+++ b/src/main.js
@@ -6,6 +6,7 @@ import './lib/event_target_polyfill.js'
 import interfaceModule from './modules/interface.js'
 import instanceModule from './modules/instance.js'
 import statusesModule from './modules/statuses.js'
+import notificationsModule from './modules/notifications.js'
 import listsModule from './modules/lists.js'
 import usersModule from './modules/users.js'
 import apiModule from './modules/api.js'
@@ -78,6 +79,7 @@ const persistedStateOptions = {
       // TODO refactor users/statuses modules, they depend on each other
       users: usersModule,
       statuses: statusesModule,
+      notifications: notificationsModule,
       lists: listsModule,
       api: apiModule,
       config: configModule,
diff --git a/src/modules/notifications.js b/src/modules/notifications.js
new file mode 100644
index 00000000..03f220c7
--- /dev/null
+++ b/src/modules/notifications.js
@@ -0,0 +1,158 @@
+import apiService from '../services/api/api.service.js'
+
+import {
+  isStatusNotification,
+  isValidNotification,
+  maybeShowNotification
+} from '../services/notification_utils/notification_utils.js'
+
+import {
+  closeDesktopNotification,
+  closeAllDesktopNotifications
+} from '../services/desktop_notification_utils/desktop_notification_utils.js'
+
+const emptyNotifications = () => ({
+  desktopNotificationSilence: true,
+  maxId: 0,
+  minId: Number.POSITIVE_INFINITY,
+  data: [],
+  idStore: {},
+  loading: false
+})
+
+export const defaultState = () => ({
+  ...emptyNotifications()
+})
+
+export const notifications = {
+  state: defaultState(),
+  mutations: {
+    addNewNotifications (state, { notifications }) {
+      notifications.forEach(notification => {
+        state.data.push(notification)
+        state.idStore[notification.id] = notification
+      })
+    },
+    clearNotifications (state) {
+      state = emptyNotifications()
+    },
+    updateNotificationsMinMaxId (state, id) {
+      state.maxId = id > state.maxId ? id : state.maxId
+      state.minId = id < state.minId ? id : state.minId
+    },
+    setNotificationsLoading (state, { value }) {
+      state.loading = value
+    },
+    setNotificationsSilence (state, { value }) {
+      state.desktopNotificationSilence = value
+    },
+    markNotificationsAsSeen (state) {
+      state.data.forEach((notification) => {
+        notification.seen = true
+      })
+    },
+    markSingleNotificationAsSeen (state, { id }) {
+      const notification = find(state.data, n => n.id === id)
+      if (notification) notification.seen = true
+    },
+    dismissNotification (state, { id }) {
+      state.data = state.data.filter(n => n.id !== id)
+    },
+    dismissNotifications (state, { finder }) {
+      state.data = state.data.filter(n => finder)
+    },
+    updateNotification (state, { id, updater }) {
+      const notification = find(state.data, n => n.id === id)
+      notification && updater(notification)
+    }
+  },
+  actions: {
+    addNewNotifications (store, { notifications, older }) {
+      const { commit, dispatch, state, rootState } = store
+      const validNotifications = notifications.filter((notification) => {
+        // If invalid notification, update ids but don't add it to store
+        if (!isValidNotification(notification)) {
+          console.error('Invalid notification:', notification)
+          commit('updateNotificationsMinMaxId', notification.id)
+          return false
+        }
+        return true
+      })
+
+      const statusNotifications = validNotifications.filter(notification => isStatusNotification(notification.type) && notification.status)
+
+      // Synchronous commit to add all the statuses
+      commit('addNewStatuses', { statuses: statusNotifications.map(notification => notification.status) })
+
+      // Update references to statuses in notifications to ones in the store
+      statusNotifications.forEach(notification => {
+        const id = notification.status.id
+        const referenceStatus = rootState.statuses.allStatusesObject[id]
+        console.log()
+
+        if (referenceStatus) {
+          notification.status = referenceStatus
+        }
+      })
+
+      validNotifications.forEach(notification => {
+        if (notification.type === 'pleroma:report') {
+          dispatch('addReport', notification.report)
+        }
+
+        if (notification.type === 'pleroma:emoji_reaction') {
+          dispatch('fetchEmojiReactionsBy', notification.status.id)
+        }
+
+        // Only add a new notification if we don't have one for the same action
+        // eslint-disable-next-line no-prototype-builtins
+        if (!state.idStore.hasOwnProperty(notification.id)) {
+          commit('updateNotificationsMinMaxId', notification.id)
+
+          maybeShowNotification(store, notification)
+        } else if (notification.seen) {
+          state.idStore[notification.id].seen = true
+        }
+      })
+
+      commit('addNewNotifications', { notifications })
+    },
+    setNotificationsLoading ({ rootState, commit }, { value }) {
+      commit('setNotificationsLoading', { value })
+    },
+    setNotificationsSilence ({ rootState, commit }, { value }) {
+      commit('setNotificationsSilence', { value })
+    },
+    markNotificationsAsSeen ({ rootState, state, commit }) {
+      commit('markNotificationsAsSeen')
+      apiService.markNotificationsAsSeen({
+        id: state.maxId,
+        credentials: rootState.users.currentUser.credentials
+      }).then(() => {
+        closeAllDesktopNotifications(rootState)
+      })
+    },
+    markSingleNotificationAsSeen ({ rootState, commit }, { id }) {
+      commit('markSingleNotificationAsSeen', { id })
+      apiService.markNotificationsAsSeen({
+        single: true,
+        id,
+        credentials: rootState.users.currentUser.credentials
+      }).then(() => {
+        closeDesktopNotification(rootState, id)
+      })
+    },
+    dismissNotificationLocal ({ rootState, commit }, { id }) {
+      commit('dismissNotification', { id })
+    },
+    dismissNotification ({ rootState, commit }, { id }) {
+      commit('dismissNotification', { id })
+      rootState.api.backendInteractor.dismissNotification({ id })
+    },
+    updateNotification ({ rootState, commit }, { id, updater }) {
+      commit('updateNotification', { id, updater })
+    }
+  }
+}
+
+export default notifications
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index 6e331d16..d6f19589 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -12,15 +12,6 @@ import {
   isArray,
   omitBy
 } from 'lodash'
-import {
-  isStatusNotification,
-  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) => ({
@@ -40,22 +31,12 @@ const emptyTl = (userId = 0) => ({
   flushMarker: 0
 })
 
-const emptyNotifications = () => ({
-  desktopNotificationSilence: true,
-  maxId: 0,
-  minId: Number.POSITIVE_INFINITY,
-  data: [],
-  idStore: {},
-  loading: false
-})
-
 export const defaultState = () => ({
   allStatuses: [],
   scrobblesNextFetch: {},
   allStatusesObject: {},
   conversationsObject: {},
   maxId: 0,
-  notifications: emptyNotifications(),
   favorites: new Set(),
   timelines: {
     mentions: emptyTl(),
@@ -164,9 +145,6 @@ const removeStatusFromGlobalStorage = (state, status) => {
 
   // TODO: Need to remove from allStatusesObject?
 
-  // Remove possible notification
-  remove(state.notifications.data, ({ action: { id } }) => id === status.id)
-
   // Remove from conversation
   const conversationId = status.statusnet_conversation_id
   if (state.conversationsObject[conversationId]) {
@@ -342,52 +320,6 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
   }
 }
 
-const updateNotificationsMinMaxId = (state, notification) => {
-  state.notifications.maxId = notification.id > state.notifications.maxId
-    ? notification.id
-    : state.notifications.maxId
-  state.notifications.minId = notification.id < state.notifications.minId
-    ? notification.id
-    : state.notifications.minId
-}
-
-const addNewNotifications = (state, { dispatch, notifications, older, visibleNotificationTypes, rootGetters, newNotificationSideEffects }) => {
-  each(notifications, (notification) => {
-    // If invalid notification, update ids but don't add it to store
-    if (!isValidNotification(notification)) {
-      console.error('Invalid notification:', notification)
-      updateNotificationsMinMaxId(state, notification)
-      return
-    }
-
-    if (isStatusNotification(notification.type)) {
-      notification.action = addStatusToGlobalStorage(state, notification.action).item
-      notification.status = notification.status && addStatusToGlobalStorage(state, notification.status).item
-    }
-
-    if (notification.type === 'pleroma:report') {
-      dispatch('addReport', notification.report)
-    }
-
-    if (notification.type === 'pleroma:emoji_reaction') {
-      dispatch('fetchEmojiReactionsBy', notification.status.id)
-    }
-
-    // Only add a new notification if we don't have one for the same action
-    // eslint-disable-next-line no-prototype-builtins
-    if (!state.notifications.idStore.hasOwnProperty(notification.id)) {
-      updateNotificationsMinMaxId(state, notification)
-
-      state.notifications.data.push(notification)
-      state.notifications.idStore[notification.id] = notification
-
-      newNotificationSideEffects(notification)
-    } else if (notification.seen) {
-      state.notifications.idStore[notification.id].seen = true
-    }
-  })
-}
-
 const removeStatus = (state, { timeline, userId }) => {
   const timelineObject = state.timelines[timeline]
   if (userId) {
@@ -400,7 +332,6 @@ const removeStatus = (state, { timeline, userId }) => {
 
 export const mutations = {
   addNewStatuses,
-  addNewNotifications,
   removeStatus,
   showNewStatuses (state, { timeline }) {
     const oldTimeline = (state.timelines[timeline])
@@ -422,9 +353,6 @@ export const mutations = {
     const userId = excludeUserId ? state.timelines[timeline].userId : undefined
     state.timelines[timeline] = emptyTl(userId)
   },
-  clearNotifications (state) {
-    state.notifications = emptyNotifications()
-  },
   setFavorited (state, { status, value }) {
     const newStatus = state.allStatusesObject[status.id]
 
@@ -507,31 +435,6 @@ export const mutations = {
     const newStatus = state.allStatusesObject[id]
     newStatus.nsfw = nsfw
   },
-  setNotificationsLoading (state, { value }) {
-    state.notifications.loading = value
-  },
-  setNotificationsSilence (state, { value }) {
-    state.notifications.desktopNotificationSilence = value
-  },
-  markNotificationsAsSeen (state) {
-    each(state.notifications.data, (notification) => {
-      notification.seen = true
-    })
-  },
-  markSingleNotificationAsSeen (state, { id }) {
-    const notification = find(state.notifications.data, n => n.id === id)
-    if (notification) notification.seen = true
-  },
-  dismissNotification (state, { id }) {
-    state.notifications.data = state.notifications.data.filter(n => n.id !== id)
-  },
-  dismissNotifications (state, { finder }) {
-    state.notifications.data = state.notifications.data.filter(n => finder)
-  },
-  updateNotification (state, { id, updater }) {
-    const notification = find(state.notifications.data, n => n.id === id)
-    notification && updater(notification)
-  },
   queueFlush (state, { timeline, id }) {
     state.timelines[timeline].flushMarker = id
   },
@@ -615,20 +518,9 @@ const statuses = {
   actions: {
     addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId, pagination }) {
       commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId, pagination })
-    },
-    addNewNotifications (store, { notifications, older }) {
-      const { commit, dispatch, rootGetters } = store
 
-      const newNotificationSideEffects = (notification) => {
-        maybeShowNotification(store, notification)
-      }
-      commit('addNewNotifications', { dispatch, notifications, older, rootGetters, newNotificationSideEffects })
-    },
-    setNotificationsLoading ({ rootState, commit }, { value }) {
-      commit('setNotificationsLoading', { value })
-    },
-    setNotificationsSilence ({ rootState, commit }, { value }) {
-      commit('setNotificationsSilence', { value })
+      const deletions = statuses.filter(status => status.type === 'deletion')
+      console.log(deletions)
     },
     fetchStatus ({ rootState, dispatch }, id) {
       return rootState.api.backendInteractor.fetchStatus({ id })
@@ -725,35 +617,6 @@ const statuses = {
     queueFlushAll ({ rootState, commit }) {
       commit('queueFlushAll')
     },
-    markNotificationsAsSeen ({ rootState, commit }) {
-      commit('markNotificationsAsSeen')
-      apiService.markNotificationsAsSeen({
-        id: rootState.statuses.notifications.maxId,
-        credentials: rootState.users.currentUser.credentials
-      }).then(() => {
-        closeAllDesktopNotifications(rootState)
-      })
-    },
-    markSingleNotificationAsSeen ({ rootState, commit }, { id }) {
-      commit('markSingleNotificationAsSeen', { id })
-      apiService.markNotificationsAsSeen({
-        single: true,
-        id,
-        credentials: rootState.users.currentUser.credentials
-      }).then(() => {
-        closeDesktopNotification(rootState, id)
-      })
-    },
-    dismissNotificationLocal ({ rootState, commit }, { id }) {
-      commit('dismissNotification', { id })
-    },
-    dismissNotification ({ rootState, commit }, { id }) {
-      commit('dismissNotification', { id })
-      rootState.api.backendInteractor.dismissNotification({ id })
-    },
-    updateNotification ({ rootState, commit }, { id, updater }) {
-      commit('updateNotification', { id, updater })
-    },
     fetchFavsAndRepeats ({ rootState, commit }, id) {
       Promise.all([
         rootState.api.backendInteractor.fetchFavoritedByUsers({ id }),
diff --git a/src/modules/users.js b/src/modules/users.js
index 79268bc3..6669d623 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -498,7 +498,7 @@ const users = {
       store.commit('addNewUsers', users)
       store.commit('addNewUsers', targetUsers)
 
-      const notificationsObject = store.rootState.statuses.notifications.idStore
+      const notificationsObject = store.rootState.notifications.idStore
       const relevantNotifications = Object.entries(notificationsObject)
         .filter(([k, val]) => notificationIds.includes(k))
         .map(([k, val]) => val)
diff --git a/src/services/desktop_notification_utils/desktop_notification_utils.js b/src/services/desktop_notification_utils/desktop_notification_utils.js
index dbca4173..bde9f18c 100644
--- a/src/services/desktop_notification_utils/desktop_notification_utils.js
+++ b/src/services/desktop_notification_utils/desktop_notification_utils.js
@@ -7,7 +7,7 @@ const state = { failCreateNotif: false }
 
 export const showDesktopNotification = (rootState, desktopNotificationOpts) => {
   if (!('Notification' in window && window.Notification.permission === 'granted')) return
-  if (rootState.statuses.notifications.desktopNotificationSilence) { return }
+  if (rootState.notifications.desktopNotificationSilence) { return }
 
   if (isSWSupported()) {
     swDesktopNotification(desktopNotificationOpts)
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 610ba1ab..85da5223 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -439,7 +439,6 @@ export const parseNotification = (data) => {
     output.type = mastoDict[data.type] || data.type
     output.seen = data.pleroma.is_seen
     output.status = isStatusNotification(output.type) ? parseStatus(data.status) : null
-    output.action = output.status // TODO: Refactor, this is unneeded
     output.target = output.type !== 'move'
       ? null
       : parseUser(data.target)
diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
index 01cbd5f1..2c25000e 100644
--- a/src/services/notification_utils/notification_utils.js
+++ b/src/services/notification_utils/notification_utils.js
@@ -6,7 +6,7 @@ import FaviconService from 'src/services/favicon_service/favicon_service.js'
 
 let cachedBadgeUrl = null
 
-export const notificationsFromStore = store => store.state.statuses.notifications.data
+export const notificationsFromStore = store => store.state.notifications.data
 
 export const visibleTypes = store => {
   const rootState = store.rootState || store.state
diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js
index 6c247210..3c280b74 100644
--- a/src/services/notifications_fetcher/notifications_fetcher.service.js
+++ b/src/services/notifications_fetcher/notifications_fetcher.service.js
@@ -21,7 +21,7 @@ const fetchAndUpdate = ({ store, credentials, older = false, since }) => {
   const args = { credentials }
   const { getters } = store
   const rootState = store.rootState || store.state
-  const timelineData = rootState.statuses.notifications
+  const timelineData = rootState.notifications
   const hideMutedPosts = getters.mergedConfig.hideMutedPosts
 
   args.includeTypes = mastoApiNotificationTypes
-- 
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/notification/notification.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 d8827932bc8f329c66bdf23fa0eb7af4ed41179a Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 27 Feb 2024 01:08:04 +0200
Subject: fix collapsed notifications incorrect styles

---
 src/components/notification/notification.vue     |  1 -
 src/components/notifications/notifications.scss  | 14 --------------
 src/components/rich_content/rich_content.jsx     |  8 +++++++-
 src/components/rich_content/rich_content.scss    |  9 +++++++++
 src/components/status_body/status_body.vue       |  2 ++
 src/components/status_content/status_content.vue | 10 ----------
 6 files changed, 18 insertions(+), 26 deletions(-)

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

diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 5c425200..632ae6e9 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -247,7 +247,6 @@
         />
         <template v-else>
           <StatusContent
-            :class="{ faint: !statusExpanded }"
             :compact="!statusExpanded"
             :status="notification.status"
           />
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index 1acccb8d..d749890d 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -60,20 +60,6 @@
     padding: 0.6em;
     min-width: 0;
 
-    .RichContent {
-      a {
-        --link: var(--linkFaint);
-      }
-
-      .greentext {
-        --funtextGreentext: var(--funtextGreentextFaint);
-      }
-
-      .cyantext {
-        --funtextCyantext: var(--funtextCyantextFaint);
-      }
-    }
-
     .avatar-container {
       width: 32px;
       height: 32px;
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index ff14a58a..99d7daca 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -79,6 +79,12 @@ export default {
       required: false,
       type: Boolean,
       default: false
+    },
+    // Faint style (for notifs)
+    faint: {
+      required: false,
+      type: Boolean,
+      default: false
     }
   },
   // NEVER EVER TOUCH DATA INSIDE RENDER
@@ -277,7 +283,7 @@ export default {
     // DO NOT USE SLOTS they cause a re-render feedback loop here.
     // slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
     // at least until vue3?
-    const result = <span class="RichContent">
+    const result = <span class={['RichContent', this.faint ? '-faint' : '']}>
       { pass2 }
     </span>
 
diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss
index 73eb07e9..962fd5bb 100644
--- a/src/components/rich_content/rich_content.scss
+++ b/src/components/rich_content/rich_content.scss
@@ -1,6 +1,15 @@
 .RichContent {
   font-family: var(--font);
 
+  &.-faint {
+    /* stylelint-disable declaration-no-important */
+    --text: var(--textFaint) !important;
+    --link: var(--linkFaint) !important;
+    --funtextGreentext: var(--funtextGreentextFaint) !important;
+    --funtextCyantext: var(--funtextCyantextFaint) !important;
+    /* stylelint-enable declaration-no-important */
+  }
+
   blockquote {
     margin: 0.2em 0 0.2em 0.2em;
     font-style: italic;
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index fb356360..f21bcc80 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -11,6 +11,7 @@
       >
         <RichContent
           class="media-body summary"
+          :faint="compact"
           :html="status.summary_raw_html"
           :emoji="status.emojis"
         />
@@ -48,6 +49,7 @@
           :html="status.raw_html"
           :emoji="status.emojis"
           :handle-links="true"
+          :faint="compact"
           :greentext="mergedConfig.greentext"
           :attentions="status.attentions"
           @parseReady="onParseReady"
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index a9db6b12..e977d489 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -62,15 +62,5 @@
 .StatusContent {
   flex: 1;
   min-width: 0;
-
-  &.faint {
-    .greentext {
-      color: var(--funtextGreentextFaint);
-    }
-
-    .cyantext {
-      color: var(--funtextCyantextFaint);
-    }
-  }
 }
 </style>
-- 
cgit v1.2.3-70-g09d2


From 5c48ea2f1abb21f88990c000e0f158dfa19fa4d7 Mon Sep 17 00:00:00 2001
From: marcin mikołajczak <git@mkljczk.pl>
Date: Sat, 25 May 2024 08:42:32 +0200
Subject: Support `status` notification type
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/status-notification-type.add                  |  1 +
 src/components/interactions/interactions.js               |  1 +
 src/components/notification/notification.vue              |  2 +-
 src/components/notifications/notification_filters.vue     |  9 +++++++++
 src/components/notifications/notifications.js             |  2 +-
 src/components/settings_modal/tabs/notifications_tab.vue  | 15 +++++++++++++++
 src/i18n/en.json                                          |  4 +++-
 src/modules/config.js                                     |  2 ++
 src/services/notification_utils/notification_utils.js     |  6 +++++-
 .../notifications_fetcher.service.js                      |  1 +
 src/sw.js                                                 |  2 ++
 11 files changed, 41 insertions(+), 4 deletions(-)
 create mode 100644 changelog.d/status-notification-type.add

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

diff --git a/changelog.d/status-notification-type.add b/changelog.d/status-notification-type.add
new file mode 100644
index 00000000..0917dad4
--- /dev/null
+++ b/changelog.d/status-notification-type.add
@@ -0,0 +1 @@
+Support `status` notification type
\ No newline at end of file
diff --git a/src/components/interactions/interactions.js b/src/components/interactions/interactions.js
index 1ae1d01c..fc441b90 100644
--- a/src/components/interactions/interactions.js
+++ b/src/components/interactions/interactions.js
@@ -3,6 +3,7 @@ import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
 
 const tabModeDict = {
   mentions: ['mention'],
+  statuses: ['status'],
   'likes+repeats': ['repeat', 'like'],
   follows: ['follow'],
   reactions: ['pleroma:emoji_reaction'],
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 632ae6e9..f84b75de 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -1,6 +1,6 @@
 <template>
   <article
-    v-if="notification.type === 'mention'"
+    v-if="notification.type === 'mention' || notification.type === 'status'"
   >
     <Status
       class="Notification"
diff --git a/src/components/notifications/notification_filters.vue b/src/components/notifications/notification_filters.vue
index cc506e78..497a5156 100644
--- a/src/components/notifications/notification_filters.vue
+++ b/src/components/notifications/notification_filters.vue
@@ -43,6 +43,15 @@
             :class="{ 'menu-checkbox-checked': filters.mentions }"
           />{{ $t('settings.notification_visibility_mentions') }}
         </button>
+        <button
+          class="menu-item dropdown-item"
+          @click="toggleNotificationFilter('statuses')"
+        >
+          <span
+            class="input menu-checkbox"
+            :class="{ 'menu-checkbox-checked': filters.statuses }"
+          />{{ $t('settings.notification_visibility_statuses') }}
+        </button>
         <button
           class="menu-item dropdown-item"
           @click="toggleNotificationFilter('emojiReactions')"
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index a9fa8455..85d3662e 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -33,7 +33,7 @@ const Notifications = {
     // Disables panel styles, unread mark, potentially other notification-related actions
     // meant for "Interactions" timeline
     minimalMode: Boolean,
-    // Custom filter mode, an array of strings, possible values 'mention', 'repeat', 'like', 'follow', used to override global filter for use in "Interactions" timeline
+    // Custom filter mode, an array of strings, possible values 'mention', 'status', 'repeat', 'like', 'follow', used to override global filter for use in "Interactions" timeline
     filterMode: Array,
     // Do not show extra notifications
     noExtra: {
diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue
index ca2cf8ad..10228888 100644
--- a/src/components/settings_modal/tabs/notifications_tab.vue
+++ b/src/components/settings_modal/tabs/notifications_tab.vue
@@ -60,6 +60,21 @@
                 </li>
               </ul>
             </li>
+            <li>
+              <h4> {{ $t('settings.notification_visibility_statuses') }}</h4>
+              <ul class="setting-list">
+                <li>
+                  <BooleanSetting path="notificationVisibility.statuses">
+                    {{ $t('settings.notification_visibility_in_column') }}
+                  </BooleanSetting>
+                </li>
+                <li>
+                  <BooleanSetting path="notificationNative.statuses">
+                    {{ $t('settings.notification_visibility_native_notifications') }}
+                  </BooleanSetting>
+                </li>
+              </ul>
+            </li>
             <li>
               <h4> {{ $t('settings.notification_visibility_likes') }}</h4>
               <ul class="setting-list">
diff --git a/src/i18n/en.json b/src/i18n/en.json
index f626e933..c07f5943 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -212,7 +212,8 @@
     "unread_follow_requests": "{num} new follow request | {num} new follow requests",
     "configuration_tip": "You can customize what to display here in {theSettings}. {dismiss}",
     "configuration_tip_settings": "the settings",
-    "configuration_tip_dismiss": "Do not show again"
+    "configuration_tip_dismiss": "Do not show again",
+    "subscribed_status": "posted"
   },
   "polls": {
     "add_poll": "Add poll",
@@ -588,6 +589,7 @@
     "notification_visibility_moves": "User Migrates",
     "notification_visibility_emoji_reactions": "Reactions",
     "notification_visibility_polls": "Ends of polls you voted in",
+    "notification_visibility_statuses": "Subscriptions",
     "notification_show_extra": "Show extra notifications in the notifications column",
     "notification_extra_chats": "Show unread chats",
     "notification_extra_announcements": "Show unread announcements",
diff --git a/src/modules/config.js b/src/modules/config.js
index 8001b854..aa400f77 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -60,6 +60,7 @@ export const defaultState = {
   notificationVisibility: {
     follows: true,
     mentions: true,
+    statuses: true,
     likes: true,
     repeats: true,
     moves: true,
@@ -72,6 +73,7 @@ export const defaultState = {
   notificationNative: {
     follows: true,
     mentions: true,
+    statuses: true,
     likes: false,
     repeats: false,
     moves: false,
diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
index 7b705e65..831b27f6 100644
--- a/src/services/notification_utils/notification_utils.js
+++ b/src/services/notification_utils/notification_utils.js
@@ -18,6 +18,7 @@ export const visibleTypes = store => {
   return ([
     notificationVisibility.likes && 'like',
     notificationVisibility.mentions && 'mention',
+    notificationVisibility.statuses && 'status',
     notificationVisibility.repeats && 'repeat',
     notificationVisibility.follows && 'follow',
     notificationVisibility.followRequest && 'follow_request',
@@ -28,7 +29,7 @@ export const visibleTypes = store => {
   ].filter(_ => _))
 }
 
-const statusNotifications = new Set(['like', 'mention', 'repeat', 'pleroma:emoji_reaction', 'poll'])
+const statusNotifications = new Set(['like', 'mention', 'status', 'repeat', 'pleroma:emoji_reaction', 'poll'])
 
 export const isStatusNotification = (type) => statusNotifications.has(type)
 
@@ -118,6 +119,9 @@ export const prepareNotificationObject = (notification, i18n) => {
     case 'like':
       i18nString = 'favorited_you'
       break
+    case 'status':
+      i18nString = 'subscribed_status'
+      break
     case 'repeat':
       i18nString = 'repeated_you'
       break
diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js
index 6403693c..32854fed 100644
--- a/src/services/notifications_fetcher/notifications_fetcher.service.js
+++ b/src/services/notifications_fetcher/notifications_fetcher.service.js
@@ -5,6 +5,7 @@ import { promiseInterval } from '../promise_interval/promise_interval.js'
 // Note: chat_mention excluded as pleroma-fe polls them separately
 const mastoApiNotificationTypes = [
   'mention',
+  'status',
   'favourite',
   'reblog',
   'follow',
diff --git a/src/sw.js b/src/sw.js
index 4ee7b9ff..8d790446 100644
--- a/src/sw.js
+++ b/src/sw.js
@@ -38,6 +38,8 @@ const setSettings = async () => {
         switch (k) {
           case 'mentions':
             return 'mention'
+          case 'statuses':
+            return 'status'
           case 'likes':
             return 'like'
           case 'repeats':
-- 
cgit v1.2.3-70-g09d2

