Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
94 KB
Referenced Files
None
Subscribers
None
diff --git a/src/App.scss b/src/App.scss
index 0a6fa90bcd..6d937129a6 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -1,1120 +1,1122 @@
@use "panel";
@import '@fortawesome/fontawesome-svg-core/styles.css';
@import '@kazvmoe-infra/pinch-zoom-element/dist/pinch-zoom.css';
// stylelint-disable rscss/class-format
/* stylelint-disable no-descending-specificity */
:root {
--status-margin: 0.75em;
--post-line-height: 1.4;
// Z-Index stuff
--ZI_media_modal: 9000;
--ZI_modals_popovers: 8500;
--ZI_modals: 8000;
--ZI_navbar_popovers: 7500;
--ZI_navbar: 7000;
--ZI_popovers: 6000;
// Fallback for when stuff is loading
--background: var(--bg);
}
html {
font-size: var(--textSize, 1rem);
--navbar-height: var(--navbarSize, 3.5rem);
--emoji-size: var(--emojiSize, 32px);
--panel-header-height: var(--panelHeaderSize, 3.2rem);
// overflow-x: clip causes my browser's tab to crash with SIGILL lul
}
body {
font-family: sans-serif;
font-family: var(--font);
margin: 0;
padding: 0;
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overscroll-behavior-y: none;
overflow: clip scroll;
&.hidden {
display: none;
}
}
// ## Custom scrollbars
// Only show custom scrollbars on devices which
// have a cursor/pointer to operate them
@media (any-pointer: fine) {
* {
scrollbar-color: var(--icon) transparent;
&::-webkit-scrollbar {
background: transparent;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-resizer {
/* stylelint-disable-next-line declaration-no-important */
background-color: transparent !important;
background-image:
linear-gradient(
135deg,
transparent calc(50% - 1px),
var(--textFaint) 50%,
transparent calc(50% + 1px),
transparent calc(75% - 1px),
var(--textFaint) 75%,
transparent calc(75% + 1px),
);
}
&::-webkit-scrollbar-button,
&::-webkit-scrollbar-thumb {
box-shadow: var(--shadow);
border-radius: var(--roundness);
}
// horizontal/vertical/increment/decrement are webkit-specific stuff
// that indicates whether we're affecting vertical scrollbar, increase button etc
// stylelint-disable selector-pseudo-class-no-unknown
&::-webkit-scrollbar-button {
--___bgPadding: 2px;
color: var(--text);
background-repeat: no-repeat, no-repeat;
&:horizontal {
background-size: 50% calc(50% - var(--___bgPadding)), 50% calc(50% - var(--___bgPadding));
&:increment {
background-image:
linear-gradient(45deg, var(--text) 50%, transparent 51%),
linear-gradient(-45deg, transparent 50%, var(--text) 51%);
background-position: top var(--___bgPadding) left 50%, right 50% bottom var(--___bgPadding);
}
&:decrement {
background-image:
linear-gradient(45deg, transparent 50%, var(--text) calc(50% + 1px)),
linear-gradient(-45deg, var(--text) 50%, transparent 51%);
background-position: bottom var(--___bgPadding) right 50%, left 50% top var(--___bgPadding);
}
}
&:vertical {
background-size: calc(50% - var(--___bgPadding)) 50%, calc(50% - var(--___bgPadding)) 50%;
&:increment {
background-image:
linear-gradient(-45deg, transparent 50%, var(--text) 51%),
linear-gradient(45deg, transparent 50%, var(--text) 51%);
background-position: right var(--___bgPadding) top 50%, left var(--___bgPadding) top 50%;
}
&:decrement {
background-image:
linear-gradient(-45deg, var(--text) 50%, transparent 51%),
linear-gradient(45deg, var(--text) 50%, transparent 51%);
background-position: left var(--___bgPadding) top 50%, right var(--___bgPadding) top 50%;
}
}
}
// stylelint-enable selector-pseudo-class-no-unknown
}
// Body should have background to scrollbar otherwise it will use white (body color?)
html {
scrollbar-color: var(--icon) var(--wallpaper);
background: var(--wallpaper);
}
}
a {
text-decoration: none;
color: var(--link);
&:focus,
&:hover {
text-decoration: underline;
}
&:not(.button-unstyled):focus-visible {
border-radius: var(--roundness);
outline: 2px solid var(--link);
}
}
.button-unstyled {
+ text-decoration: underline dotted var(--icon);
+
+ &:hover,
+ &:focus {
+ text-decoration: underline solid;
+ }
+
&:focus-visible {
border-radius: var(--roundness);
outline: 2px solid var(--text);
}
}
.underline-links a {
text-decoration: underline;
}
h4 {
margin: 0;
}
code {
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--roundness);
padding: 0 0.2em;
& pre,
pre & {
display: block;
overflow-x: auto;
padding: 0.2em;
}
&.pre {
white-space: pre;
display: block;
}
}
.iconLetter {
display: inline-block;
text-align: center;
font-weight: 1000;
}
i[class*="icon-"],
.svg-inline--fa,
.iconLetter {
color: var(--icon);
}
header {
z-index: var(--ZI_navbar);
box-shadow: var(--shadow);
box-sizing: border-box;
height: var(--navbar-height);
font-size: calc(var(--navbar-height) / 3.5);
position: fixed;
}
#sidebar {
grid-area: sidebar;
}
#modal {
position: absolute;
z-index: var(--ZI_modals);
}
.column.-scrollable {
top: var(--navbar-height);
position: sticky;
}
#main-scroller {
grid-area: content;
position: relative;
}
#notifs-column {
grid-area: notifs;
}
.app-bg-wrapper {
position: fixed;
height: 100%;
top: var(--navbar-height);
z-index: -1000;
left: 0;
right: -20px;
background-size: cover;
background-repeat: no-repeat;
background-color: var(--wallpaper);
background-image: var(--body-background-image);
background-position: 50%;
transition: background-image 1s;
}
.underlay {
grid-column: 1 / span 3;
grid-row: 1 / 1;
pointer-events: none;
background-color: var(--underlay);
z-index: -1000;
}
.app-layout {
--miniColumn: 25rem;
--maxiColumn: 45rem;
--columnGap: 1rem;
--effectiveSidebarColumnWidth: minmax(var(--miniColumn), var(--sidebarColumnWidth, var(--miniColumn)));
--effectiveNotifsColumnWidth: minmax(var(--miniColumn), var(--notifsColumnWidth, var(--miniColumn)));
--effectiveContentColumnWidth: minmax(var(--miniColumn), var(--contentColumnWidth, var(--maxiColumn)));
position: relative;
display: grid;
grid-template-columns:
var(--effectiveSidebarColumnWidth)
var(--effectiveContentColumnWidth);
grid-template-areas: "sidebar content";
grid-template-rows: 1fr;
box-sizing: border-box;
margin: 0 auto;
place-content: flex-start center;
flex-wrap: wrap;
min-height: 100vh;
overflow-x: clip;
.column {
--___columnMargin: var(--columnGap);
display: grid;
grid-template-columns: 100%;
box-sizing: border-box;
grid-row: 1 / 1;
margin: 0 calc(var(--___columnMargin) / 2);
padding: calc(var(--___columnMargin)) 0;
row-gap: var(--___columnMargin);
align-content: start;
&:not(.-scrollable) {
margin-top: var(--navbar-height);
}
&:hover {
z-index: 2;
}
&.-full-height {
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
}
&.-scrollable {
--___paddingIncrease: calc(var(--columnGap) / 2);
position: sticky;
top: var(--navbar-height);
max-height: calc(100vh - var(--navbar-height));
overflow: hidden auto;
margin-left: calc(var(--___paddingIncrease) * -1);
padding-left: calc(var(--___paddingIncrease) + var(--___columnMargin) / 2);
// On browsers that don't support hiding scrollbars we enforce "show scrolbars" mode
// might implement old style of hiding scrollbars later if there's demand
@supports (scrollbar-width: none) or (-webkit-text-fill-color: initial) {
&:not(.-show-scrollbar) {
scrollbar-width: none;
margin-right: calc(var(--___paddingIncrease) * -1);
padding-right: calc(var(--___paddingIncrease) + var(--___columnMargin) / 2);
&::-webkit-scrollbar {
display: block;
width: 0;
}
}
}
.panel-heading.-sticky {
top: calc(var(--columnGap) / -1);
}
}
}
&.-has-new-post-button {
.column {
padding-bottom: 10rem;
}
}
&.-no-sticky-headers {
.column {
.panel-heading.-sticky {
position: relative;
top: 0;
}
}
}
.column-inner {
display: grid;
grid-template-columns: 100%;
box-sizing: border-box;
row-gap: 1em;
align-content: start;
}
&.-reverse:not(.-wide, .-mobile) {
grid-template-columns:
var(--effectiveContentColumnWidth)
var(--effectiveSidebarColumnWidth);
grid-template-areas: "content sidebar";
}
&.-wide {
grid-template-columns:
var(--effectiveSidebarColumnWidth)
var(--effectiveContentColumnWidth)
var(--effectiveNotifsColumnWidth);
grid-template-areas: "sidebar content notifs";
&.-reverse {
grid-template-columns:
var(--effectiveNotifsColumnWidth)
var(--effectiveContentColumnWidth)
var(--effectiveSidebarColumnWidth);
grid-template-areas: "notifs content sidebar";
}
}
&.-mobile {
grid-template-columns: 100vw;
grid-template-areas: "content";
padding: 0;
.column {
padding-top: 0;
margin: var(--navbar-height) 0 0 0;
}
.panel-heading,
.panel-heading::after,
.panel-heading::before,
.panel,
.panel::after {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
#sidebar,
#notifs-column {
display: none;
}
}
&.-normal {
#notifs-column {
display: none;
}
}
}
.text-center {
text-align: center;
}
.button-default {
user-select: none;
color: var(--text);
border: none;
cursor: pointer;
background-color: var(--background);
box-shadow: var(--shadow);
font-size: 1em;
font-family: sans-serif;
font-family: var(--font);
&.-transparent {
backdrop-filter: blur(0.125em) contrast(60%);
}
&::-moz-focus-inner {
border: none;
}
&:disabled {
cursor: not-allowed;
}
&:active {
transform: translate(1px, 1px);
}
}
.menu-item {
line-height: var(--__line-height);
font-family: inherit;
font-weight: 400;
font-size: 100%;
cursor: pointer;
a,
button:not(.button-default) {
color: var(--text);
font-size: 100%;
text-align: initial;
padding: 0;
background: none;
border: none;
outline: none;
display: inline;
font-family: inherit;
line-height: unset;
}
&.disabled {
cursor: not-allowed;
}
}
.menu-item,
.list-item {
display: block;
box-sizing: border-box;
border: none;
outline: none;
text-align: initial;
color: inherit;
clear: both;
position: relative;
white-space: nowrap;
width: 100%;
padding: var(--__vertical-gap) var(--__horizontal-gap);
background: transparent;
--__line-height: 1.5em;
--__horizontal-gap: 0.75em;
--__vertical-gap: 0.5em;
}
.button-unstyled {
border: none;
outline: none;
display: inline;
text-align: initial;
font-size: 100%;
font-family: inherit;
box-shadow: var(--shadow);
background-color: transparent;
padding: 0;
line-height: unset;
cursor: pointer;
box-sizing: content-box;
color: inherit;
-
- &.-link {
- /* stylelint-disable-next-line declaration-no-important */
- color: var(--link) !important;
- }
}
label {
&.-disabled {
color: var(--textFaint);
}
}
input,
textarea {
border: none;
display: inline-block;
outline: none;
}
.input {
&.unstyled {
border-radius: 0;
/* stylelint-disable-next-line declaration-no-important */
background: none !important;
box-shadow: none;
height: unset;
}
&::placeholder {
color: var(--textFaint)
}
--_padding: 0.5em;
border: none;
background-color: var(--background);
color: var(--text);
box-shadow: var(--shadow);
font-family: var(--font);
font-size: 1em;
margin: 0;
box-sizing: border-box;
display: inline-block;
position: relative;
line-height: 2;
hyphens: none;
padding: 0 var(--_padding);
&:disabled,
&[disabled="disabled"],
&.disabled {
cursor: not-allowed;
color: var(--textFaint);
/* stylelint-disable-next-line declaration-no-important */
background-color: transparent !important;
}
&[type="range"] {
background: none;
border: none;
margin: 0;
box-shadow: none;
flex: 1;
}
&[type="radio"] {
display: none;
&:checked + label::before {
box-shadow: var(--shadow);
background-color: var(--background);
color: var(--text);
}
&:disabled {
&,
& + label,
& + label::before {
opacity: 0.5;
}
background-color: var(--background);
}
+ label::before {
flex-shrink: 0;
display: inline-block;
content: "•";
transition: box-shadow 200ms;
width: 1.1em;
height: 1.1em;
border-radius: 100%; // Radio buttons should always be circle
background-color: var(--background);
box-shadow: var(--shadow);
margin-right: 0.5em;
vertical-align: top;
text-align: center;
line-height: 1.1;
font-size: 1.1em;
box-sizing: border-box;
color: transparent;
overflow: hidden;
}
}
&[type="checkbox"] {
&:checked + label::before {
color: var(--text);
background-color: var(--background);
box-shadow: var(--shadow);
}
&:disabled {
&,
& + label,
& + label::before {
opacity: 0.5;
}
}
+ label::before {
flex-shrink: 0;
display: inline-block;
content: "✓";
transition: color 200ms;
width: 1.1em;
height: 1.1em;
border-radius: var(--roundness);
box-shadow: var(--shadow);
margin-right: 0.5em;
vertical-align: top;
text-align: center;
line-height: 1.1;
font-size: 1.1em;
box-sizing: border-box;
color: transparent;
overflow: hidden;
}
}
&.resize-height {
resize: vertical;
}
}
.input,
.button-default {
--_roundness-left: var(--roundness);
--_roundness-right: var(--roundness);
border-top-left-radius: var(--_roundness-left);
border-bottom-left-radius: var(--_roundness-left);
border-top-right-radius: var(--_roundness-right);
border-bottom-right-radius: var(--_roundness-right);
}
// Textareas should have stock line-height + vertical padding instead of huge line-height
textarea.input {
padding: var(--_padding);
line-height: var(--post-line-height);
}
option {
color: var(--text);
background-color: var(--background);
}
.hide-number-spinner {
appearance: textfield;
&[type="number"]::-webkit-inner-spin-button,
&[type="number"]::-webkit-outer-spin-button {
opacity: 0;
display: none;
}
}
.cards-list {
list-style: none;
display: grid;
grid-auto-flow: row dense;
grid-template-columns: repeat(auto-fit, minmax(20em, 1fr));
gap: 0.5em;
li {
border: 1px solid var(--border);
border-radius: var(--roundness);
padding: 0.5em;
margin: 0.25em;
}
}
.btn-group {
position: relative;
display: inline-flex;
vertical-align: middle;
> .button-default {
--_roundness-left: 0;
--_roundness-right: 0;
position: relative;
&:first-child {
--_roundness-left: var(--roundness);
}
&:last-child {
--_roundness-right: var(--roundness);
}
}
}
.fa {
color: grey;
}
.mobile-shown {
display: none;
}
.badge {
box-sizing: border-box;
display: inline-block;
border-radius: 99px;
max-width: 10em;
min-width: 1.7em;
height: 1.3em;
padding: 0.15em;
vertical-align: middle;
font-weight: normal;
font-style: normal;
font-size: 0.9em;
line-height: 1;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.-dot,
&.-counter {
margin: 0;
position: absolute;
}
&.-dot {
min-height: 0.6em;
max-height: 0.6em;
min-width: 0.6em;
max-width: 0.6em;
left: calc(50% + 0.5em);
top: calc(50% - 1em);
line-height: 0;
padding: 0;
margin: 0;
}
&.-counter {
border-radius: var(--roundness);
font-size: 0.75em;
line-height: 1;
text-align: right;
padding: 0.2em;
min-width: 0;
left: calc(50% - 0.5em);
top: calc(50% - 0.4em);
margin-left: 0.7em;
margin-top: -1em;
}
}
.alert {
margin: 0 0.35em;
padding: 0 0.25em;
border-radius: var(--roundness);
border: 1px solid var(--border);
&.-dismissible {
display: flex;
padding-left: 0.5em;
margin: 0;
align-items: baseline;
line-height: 2;
span {
display: block;
flex: 1 0 auto;
}
}
}
.faint {
--text: var(--textFaint);
--link: var(--linkFaint);
color: var(--text);
}
.notice-dismissible {
display: flex;
padding: 0.75em 1em;
align-items: baseline;
line-height: 1.5;
p,
span {
display: block;
flex: 1 1 auto;
margin: 0;
}
.dismiss {
color: inherit;
}
}
.fa-scale-110 {
&.svg-inline--fa,
&.iconLetter {
font-size: 1.1em;
}
&.svg-inline--fa {
vertical-align: -0.15em;
}
}
.fa-old-padding {
&.iconLetter,
&.svg-inline--fa,
&-layer {
padding: 0 0.3em;
}
}
.veryfaint {
opacity: 0.25;
}
.timeago {
--link: var(--text);
--linkFaint: var(--textFaint);
}
.login-hint {
text-align: center;
@media all and (width >= 801px) {
display: none;
}
a {
display: inline-block;
padding: 1em 0;
width: 100%;
}
}
.btn.button-default {
min-height: 2em;
}
.new-status-notification {
position: relative;
font-size: 1.1em;
z-index: 1;
flex: 1;
}
@media all and (width <= 800px) {
.mobile-hidden {
display: none;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(359deg);
}
}
@keyframes shakeError {
0% {
transform: translateX(0);
}
15% {
transform: translateX(0.375rem);
}
30% {
transform: translateX(-0.375rem);
}
45% {
transform: translateX(0.375rem);
}
60% {
transform: translateX(-0.375rem);
}
75% {
transform: translateX(0.375rem);
}
90% {
transform: translateX(-0.375rem);
}
100% {
transform: translateX(0);
}
}
// Vue transitions
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-active {
opacity: 0;
}
/* stylelint-enable no-descending-specificity */
.visible-for-screenreader-only {
display: block;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
visibility: visible;
/* stylelint-disable property-no-deprecated */
clip: rect(0 0 0 0);
/* stylelint-enable property-no-deprecated */
padding: 0;
position: absolute;
}
*::selection {
color: var(--selectionText);
background-color: var(--selectionBackground);
}
#splash {
pointer-events: none;
#status {
&.css-ok {
&::before {
display: inline-block;
content: "CSS OK";
}
}
.initial-text {
display: none;
}
}
#throbber {
animation-duration: 3s;
animation-name: bounce;
animation-iteration-count: infinite;
animation-direction: normal;
transform-origin: bottom center;
&.dead {
animation-name: dead;
animation-duration: 0.5s;
animation-iteration-count: 1;
transform: rotateX(90deg) rotateY(0) rotateZ(-45deg);
}
@keyframes dead {
0% {
transform: rotateX(0) rotateY(0) rotateZ(0);
}
5% {
transform: rotateX(0) rotateY(0) rotateZ(1deg);
}
10% {
transform: rotateX(0) rotateY(0) rotateZ(-2deg);
}
15% {
transform: rotateX(0) rotateY(0) rotateZ(3deg);
}
20% {
transform: rotateX(0) rotateY(0) rotateZ(0);
}
25% {
transform: rotateX(0) rotateY(0) rotateZ(0);
}
30% {
transform: rotateX(10deg) rotateY(0) rotateZ(0);
}
35% {
transform: rotateX(-10deg) rotateY(0) rotateZ(0);
}
40% {
transform: rotateX(10deg) rotateY(0) rotateZ(0);
}
45% {
transform: rotateX(-10deg) rotateY(0) rotateZ(0);
}
50% {
transform: rotateX(10deg) rotateY(0) rotateZ(0);
}
100% {
transform: rotateX(90deg) rotateY(0) rotateZ(-45deg);
transition-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06); /* easeInQuint */
}
}
@keyframes bounce {
0% {
scale: 1 1;
translate: 0 0;
animation-timing-function: ease-out;
}
10% {
scale: 1.2 0.8;
translate: 0 0;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-out;
}
30% {
scale: 0.9 1.1;
translate: 0 -40%;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-in;
}
40% {
scale: 1.1 0.9;
translate: 0 -50%;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-in;
}
45% {
scale: 0.9 1.1;
translate: 0 -45%;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-in;
}
50% {
scale: 1.05 0.95;
translate: 0 -40%;
animation-timing-function: ease-in;
}
55% {
scale: 0.985 1.025;
translate: 0 -35%;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-in;
}
60% {
scale: 1.0125 0.9985;
translate: 0 -30%;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-in;
}
80% {
scale: 1.0063 0.9938;
translate: 0 -10%;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-in-out;
}
90% {
scale: 1.2 0.8;
translate: 0 0;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-out;
}
100% {
scale: 1 1;
translate: 0 0;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-out;
}
}
}
}
@property --shadow {
syntax: "*";
inherits: false;
}
diff --git a/src/components/activity_header/activity_header.js b/src/components/activity_header/activity_header.js
index ae62c0c29b..e60584ffc3 100644
--- a/src/components/activity_header/activity_header.js
+++ b/src/components/activity_header/activity_header.js
@@ -1,126 +1,126 @@
import Timeago from 'src/components/timeago/timeago.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useUsersStore } from 'src/stores/users.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleRight,
faChevronDown,
faChevronUp,
faEllipsisH,
faEnvelope,
faEye,
faEyeSlash,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faPlay,
faPlusSquare,
faReply,
faRetweet,
faSmileBeam,
faStar,
faThumbtack,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEnvelope,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faStar,
faSmileBeam,
faEllipsisH,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight,
faPlay,
)
const ActivityHeader = {
name: 'ActivityHeader',
components: {
Timeago,
UserLink,
},
props: {
statusId: String,
statusoid: Object,
userId: String,
userObject: Object,
replies: Set,
expandable: Boolean,
expanded: Boolean,
time: Date,
threadDisplayState: String,
unmuted: Boolean,
multiline: Boolean,
// for notifications
nonMention: Boolean,
},
- emits: ['dive', 'toggleExpanded', 'toggleThreadDisplay', 'toggleMuted'],
+ emits: ['requestFocus', 'toggleExpanded', 'toggleThreadDisplay', 'toggleMuted'],
computed: {
// Whatever we're given to work with
status() {
return this.statusoid ?? useStatusesStore().allStatuses.get(this.statusId)
},
user() {
return this.userObject ?? useUsersStore().findUser(this.userId)
},
// Config
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
isTreeView() {
return this.mergedConfig.conversationDisplay === 'tree'
},
simpleTree() {
return !this.mergedConfig.conversationTreeAdvanced
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
// Threading
inThreadForest() {
return !!this.threadDisplayState
},
threadShowing() {
return this.threadDisplayState === 'showing'
},
// localization
visibilityLocalized() {
return this.$i18n.t(
'general.scope_in_timeline.' + this.status?.visibility,
)
},
visibilityIcon() {
switch (this.status?.visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
},
}
export default ActivityHeader
diff --git a/src/components/activity_header/activity_header.vue b/src/components/activity_header/activity_header.vue
index 082ecc8236..3b81971fc5 100644
--- a/src/components/activity_header/activity_header.vue
+++ b/src/components/activity_header/activity_header.vue
@@ -1,149 +1,149 @@
<template>
<div
class="ActivityHeader"
:class="{ '-multiline': multiline }"
>
<template
v-if="user"
>
<RichContent
v-if="user.name_html"
class="user-title"
:html="user.name"
:emoji="user.emoji"
:allow-non-square-emoji="allowNonSquareEmoji"
:is-local="user.is_local"
:title="user.name"
/>
<span
v-else
class="status-username"
:title="user.name"
>
{{ user.name }}
</span>
{{ ' ' }}
<span class="user-handle">
<UserLink
:title="user.screen_name_ui"
:user="user"
:at="false"
/>
<img
v-if="!!(user && user.favicon)"
class="instance-favicon"
:title="$t('status.favicon')"
:alt="$t('status.favicon')"
:src="user.favicon"
>
</span>
</template>
<span class="misc" :class="{ '-has-actions': true }">
<span
v-if="!nonMention && status?.pinned"
class="pin"
>
<FAIcon
icon="thumbtack"
class="faint"
/>
<span class="faint">{{ $t('status.pinned') }}</span>
</span>
<router-link
v-if="status"
class="timeago faint"
:to="{ name: 'conversation', params: { statusId: status.id } }"
:aria-label="$t('status.open_in_thread_view')"
>
<Timeago
:time="time"
:auto-update="60"
/>
</router-link>
<span
v-else
class="timeago faint"
>
<Timeago
:time="time"
:auto-update="60"
/>
</span>
<span
v-if="!nonMention && status.visibility"
class="visibility-icon"
:title="visibilityLocalized"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="visibilityIcon"
/>
</span>
<button
v-if="!nonMention && expandable && !expanded"
class="button-unstyled"
:title="$t('status.expand')"
@click.prevent="$emit('toggleExpanded')"
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="plus-square"
/>
</button>
<button
v-if="unmuted"
class="button-unstyled"
@click.prevent="$emit('toggleMuted')"
>
<FAIcon
fixed-width
icon="eye-slash"
class="fa-scale-110"
/>
</button>
<button
v-if="status && inThreadForest && replies?.size && !simpleTree"
class="button-unstyled"
:title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')"
:aria-expanded="threadShowing ? 'true' : 'false'"
@click.prevent="$emit('toggleThreadDisplay')"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="threadShowing ? 'chevron-up' : 'chevron-down'"
/>
</button>
<button
v-if="!nonMention && expanded && isTreeView && !simpleTree"
class="button-unstyled"
:title="$t('status.show_only_conversation_under_this')"
- @click.prevent="$emit('dive')"
+ @click.prevent="$emit('requestFocus')"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="'angle-double-right'"
/>
</button>
<button
v-if="nonMention && status"
class="button-unstyled expand-icon"
:title="$t('tool_tip.toggle_expand')"
:aria-expanded="expanded"
@click.prevent="$emit('toggleExpanded')"
>
<FAIcon
class="fa-scale-110"
fixed-width
:icon="expanded ? 'compress-alt' : 'expand-alt'"
/>
</button>
</span>
</div>
</template>
<script src="./activity_header.js"></script>
<style src="./activity_header.scss"></style>
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 18a061ee2b..eb8133d733 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,380 +1,378 @@
import { last } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
inject,
nextTick,
provide,
ref,
toRefs,
useTemplateRef,
watch,
} from 'vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import ThreadTree from 'src/components/thread_tree/thread_tree.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useClientRectSize } from 'src/composables/useClientRectSize.js'
import { useConversation } from 'src/composables/useConversation.js'
import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
import { useTreeConversationTopology } from 'src/composables/useTreeConversationTopology.js'
import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
)
export default {
props: {
statusId: {
// Main thing
type: String,
required: true,
},
isPage: {
// Whether conversation is rendered as a standalone page
// as opposed to embedded into a timeline
type: Boolean,
default: false,
},
},
components: {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
RichContent,
},
emits: ['heightChange', 'suspendableStateChange', 'expanded', 'collapsed'],
setup(props, { emit }) {
const scroller = inject('bodyScrollPosition')
const { statusId } = toRefs(props)
// # Main Configuration / global state
const { mergedConfig } = storeToRefs(useMergedConfigStore())
const displayStyle = computed(() => mergedConfig.value.conversationDisplay)
const { layoutType } = storeToRefs(useInterfaceStore())
const mobileLayout = computed(() => layoutType.value === 'mobile')
// # Conversation Expansion
const expanded = ref(false)
const { isPage } = toRefs(props)
const isExpanded = computed(() => !!(expanded.value || isPage.value))
const toggleExpanded = async () => {
const newVal = !expanded.value
if (newVal) {
virtualScrollingEnabled.value = newVal
await nextTick()
expanded.value = newVal
} else {
expanded.value = newVal
await nextTick()
virtualScrollingEnabled.value = newVal
}
}
provide('isExpanded', isExpanded)
provide('isPage', isPage)
provide('expandable', true)
watch(expanded, (val) => (val ? emit('expanded') : emit('collapsed')), {
flush: 'post',
})
// # Main things
const {
focusedId,
conversationId,
setFocused,
currentStatus,
conversation,
replies,
getReplies,
fetchConversation,
loadError,
} = useConversation(statusId, isExpanded)
const conversationLite = computed(() =>
conversation.value.map(({ id }) => ({ id })),
)
provide('focusedId', focusedId)
provide('conversation', conversation)
provide('replies', replies)
// Component created
if (isPage.value) {
fetchConversation()
}
// # Misc UI things
const firstStatus = computed(() => conversation.value[0])
const lastStatus = computed(() => last(conversation.value))
const getStatusClasses = (statusId, ancestor) => {
const result = {
'-first': statusId === firstStatus.value?.id,
'-last': statusId === lastStatus.value?.id,
}
if (ancestor) {
result['-ancestor'] = true
result['-fade'] = mergedConfig.value.conversationTreeFadeAncestors
}
return result
}
// # External virtual scrolling
const unsuspendableIds = ref(new Set())
const suspendable = computed(
() => !isExpanded.value && unsuspendableIds.value.size === 0,
)
const rootElement = useTemplateRef('root')
const rootElementMargin = ref(null)
const { height } = useClientRectSize(rootElement)
watch(height, (value) => {
const rootCss = window.getComputedStyle(rootElement.value)
const rootMarginString = rootCss.getPropertyValue('margin-top')
const rootMargin = Number.parseInt(rootMarginString.slice(0, -2), 10)
rootElementMargin.value = rootMargin
emit('heightChange', {
id: statusId.value,
height: value,
element: rootElement,
})
})
watch(suspendable, (value) =>
emit('suspendableStateChange', {
suspendable: value,
id: statusId.value,
}),
)
// # Internal virtual scrolling
const virtualScrollingEnabled = ref(isExpanded.value)
// Placeholder heights.
const { fontSize, navbarSize, panelHeaderSize } = useInterfaceSizes()
const normalStatusHeight = computed(() => fontSize.value * 10)
const getPlaceholderHeight = (id) => normalStatusHeight
const offset = computed(() => {
// The fontsize after navbar is the little gap between navbar and content
if (isPage.value) {
return navbarSize.value + fontSize.value + panelHeaderSize.value
} else if (expanded.value) {
return (
navbarSize.value +
fontSize.value +
panelHeaderSize.value * 2 +
rootElementMargin.value
)
} else {
return navbarSize.value + fontSize.value
}
})
// # Linear style stuff
const isLinearView = computed(() => displayStyle.value !== 'tree')
const linearElement = useTemplateRef('linear')
const linearScrollCompensation = computed(
() => virtualScrollingEnabled.value && isLinearView.value,
)
const {
heightChart: heightChartLinear,
changeSuspendState: changeSuspendStateLinear,
updateVirtualHeight: updateVirtualHeightLinear,
reset: resetLinearScrollVirtualization,
scrollTo: linearScrollTo,
} = useVirtualScrolling({
name: 'Linear',
enabled: linearScrollCompensation,
list: conversationLite,
body: linearElement,
offset,
scrollPositionInstance: scroller,
scrollCompensation: linearScrollCompensation,
getPlaceholderHeight,
})
const changeSuspendStateLinearLocal = (e) => {
changeSuspendStateLinear(e)
const { id, suspendable } = e
if (suspendable) {
unsuspendableIds.value.delete(id)
} else {
unsuspendableIds.value.add(id)
}
}
// # Tree style stuff
const isTreeView = computed(() => displayStyle.value === 'tree')
const {
topLevel,
currentAncestors,
threadDisplay,
setThreadDisplayRecursively,
showThreadRecursively,
resetThreadDisplay,
totalReplyCount,
totalReplyDepth,
} = useTreeConversationTopology(conversation, replies, focusedId)
provide('threadDisplay', threadDisplay)
provide('totalReplyCount', totalReplyCount)
provide('totalReplyDepth', totalReplyDepth)
watch(
isExpanded,
(value) => {
if (!value) resetThreadDisplay()
},
{ flush: 'post' },
)
const toggleThreadDisplay = (id) => {
const current = threadDisplay.value.get(id)
const next = current === 'hidden' ? 'showing' : 'hidden'
setThreadDisplayRecursively(id, next)
}
const currentAncestorsLite = computed(() =>
currentAncestors.value.map(({ id }) => ({ id })),
)
const ancestorsElement = useTemplateRef('ancestors')
const treeScrollCompensation = computed(
() => virtualScrollingEnabled.value && isTreeView.value,
)
const {
heightChart: heightChartAncestors,
changeSuspendState: changeSuspendStateAncestors,
updateVirtualHeight: updateVirtualHeightAncestors,
reset: resetTreeScrollVirtualization,
} = useVirtualScrolling({
name: 'Ancestors',
enabled: treeScrollCompensation,
list: currentAncestorsLite,
body: ancestorsElement,
offset,
scrollPositionInstance: scroller,
scrollCompensation: treeScrollCompensation,
collapseMode: 'height',
getPlaceholderHeight,
})
const changeSuspendStateAncestorsLocal = (e) => {
changeSuspendStateAncestors(e)
const { id, suspendable } = e
if (suspendable) {
unsuspendableIds.value.delete(id)
} else {
unsuspendableIds.value.add(id)
}
}
const changeSuspendStateCurrentLevelLocal = (e) => {
const { id, suspendable } = e
if (suspendable) {
unsuspendableIds.value.delete(id)
} else {
unsuspendableIds.value.add(id)
}
}
watch(conversationId, (neu, old) => {
resetLinearScrollVirtualization()
resetTreeScrollVirtualization()
})
const treeViewIsSimple = computed(
() => !mergedConfig.value.conversationTreeAdvanced,
)
const shouldShowAllConversationButton = computed(
() => currentAncestors.value.length > 0 && topLevel.value.length > 1,
)
const shouldShowAncestors = computed(
() => isExpanded.value && heightChartAncestors.value.length > 0,
)
// # Scrolling
const scrollTo = (ids) => {
if (isLinearView.value) {
return linearScrollTo(ids)
}
}
- const diveIntoStatus = (id) => setFocused(id)
- const diveToTopLevel = () => setFocused(currentAncestors.value[0].id)
+ const focusTopLevel = () => setFocused(currentAncestors.value[0].id)
watch(focusedId, async (neu, old) => {
// Ignoring initial update (null -> id) since that is handled by scroll compensation
if (old && neu) scrollTo(new Set([neu]))
})
return {
// # Misc
loadError,
mobileLayout,
// # Conversation Expansion
isPage,
isExpanded,
toggleExpanded,
// # Focus
focusedId,
setFocused,
// # Main things
conversation,
currentStatus,
getReplies,
// # Misc UI things
getStatusClasses,
// # Linear style stuff
isLinearView,
// ## Linear virtual scrolling
heightChartLinear,
changeSuspendStateLinearLocal,
updateVirtualHeightLinear,
// # Tree style stuff
isTreeView,
// ## Tree virtual scrolling
heightChartAncestors,
changeSuspendStateAncestorsLocal,
updateVirtualHeightAncestors,
changeSuspendStateCurrentLevelLocal,
// ## Tree state
// ### Topology
topLevel,
currentAncestors,
// ### Thread Display
showThreadRecursively,
toggleThreadDisplay,
// ### Derived values and config
treeViewIsSimple,
shouldShowAllConversationButton,
shouldShowAncestors,
// # Scrolling
- diveToTopLevel,
- diveIntoStatus,
+ focusTopLevel,
unsuspendableIds,
}
},
}
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 8260635c11..af4709dc44 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,184 +1,182 @@
<template>
<article
ref="root"
class="Conversation"
:class="{ '-expanded' : isExpanded, '-page': isPage, 'panel' : isExpanded }"
:aria-label="conversation[0]?.summary || $t('timeline.unsubjected_conversation')"
>
<div
v-if="isExpanded"
ref="panelHeader"
class="panel-heading conversation-heading -sticky"
>
<component :is="isPage ? 'h1' : 'h2'" class="title">
<RichContent
v-if="conversation[0]?.summary_raw_html"
:html="conversation[0].summary_raw_html"
:emoji="conversation[0].emojis"
/>
<template v-else>
{{ $t('timeline.conversation') }}
</template>
</component>
<button
v-if="!isPage"
- class="button-unstyled -link"
+ class="button-unstyled"
@click.prevent="toggleExpanded"
>
{{ $t('timeline.collapse') }}
</button>
<QuickFilterSettings
v-if="isPage && mobileLayout"
:conversation="true"
class="rightside-button"
/>
<QuickViewSettings
v-if="isPage"
:conversation="true"
class="rightside-button"
/>
</div>
<div
v-if="isPage && !currentStatus"
class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }"
>
<p v-if="!loadError">
<FAIcon
spin
icon="circle-notch"
/>
{{ $t('status.loading') }}
</p>
<p v-else>
{{ $t('status.load_error', { error: loadError }) }}
</p>
</div>
<div
v-else
class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }"
>
<div
v-if="isTreeView"
class="thread-body"
>
<div
v-if="shouldShowAllConversationButton"
class="conversation-dive-to-top-level-box"
>
<i18n-t
keypath="currentStatus.show_all_conversation_with_icon"
tag="button"
- class="button-unstyled -link"
+ class="button-unstyled"
scope="global"
- @click.prevent="diveToTopLevel"
+ @click.prevent="focusTopLevel"
>
<template #icon>
<FAIcon
icon="angle-double-left"
/>
</template>
<template #text>
<span>
{{ $t('status.show_all_conversation', { numStatus: topLevel.length - 1 }, topLevel.length - 1) }}
</span>
</template>
</i18n-t>
</div>
<div
v-if="shouldShowAncestors"
ref="ancestors"
class="thread-ancestors"
>
<template
v-for="element in heightChartAncestors"
:key="element.id"
>
<Status
v-if="element.type === 'item'"
class="conversation-status panel-body"
:class="getStatusClasses(element.id, true)"
:status-id="element.id"
:replies="getReplies(element.id)"
:focused="focusedId === element.id"
conversation-rank="ancestor"
:data-status-id="element.id"
:data-vs-height="element.height"
:data-vs-top="element.top"
:data-vs-suspend="element.suspendable"
- @goto="setFocused"
- @dive="diveIntoStatus(element.id)"
+ @request-focus="setFocused"
@toggle-thread-display="toggleThreadDisplay"
@suspendable-state-change="changeSuspendStateAncestorsLocal"
@height-change="updateVirtualHeightAncestors"
/>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
role="presentation"
:style="{ height: element.height + 'px' }"
aria-hidden="true"
/>
</template>
</div>
<ThreadTree
:status-id="focusedId ?? currentStatus.id"
:depth="0"
- @goto="setFocused"
- @dive="diveIntoStatus"
+ @request-focus="setFocused"
@toggle-expanded="toggleExpanded"
@toggle-thread-display="toggleThreadDisplay"
@show-thread-recursively="showThreadRecursively"
@suspendable-state-change="changeSuspendStateCurrentLevelLocal"
/>
</div>
<div
v-else-if="isLinearView"
ref="linear"
class="thread-body linear-view"
>
<template
v-for="element in heightChartLinear"
:key="element.id"
>
<Status
v-if="element.type === 'item'"
class="panel-body conversation-status"
:class="getStatusClasses(element.id)"
:status-id="element.id"
:replies="getReplies(element.id)"
:focused="focusedId === element.id"
:data-status-id="element.id"
:data-vs-height="element.height"
:data-vs-top="element.top"
- @goto="setFocused"
+ @request-focus="setFocused"
@toggle-expanded="toggleExpanded"
@toggle-thread-display="toggleThreadDisplay"
@suspendable-state-change="changeSuspendStateLinearLocal"
@height-change="updateVirtualHeightLinear"
/>
<div
v-if="element.type === 'spacer'"
:data-vs-id="element.id"
aria-hidden="true"
class="panel-body virtual-spacer"
:style="{ height: element.height + 'px' }"
/>
</template>
</div>
</div>
</article>
</template>
<script src="./conversation.js"></script>
<style src="./conversation.scss" />
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 159f74f6b4..0059bab3df 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,630 +1,630 @@
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import ActivityHeader from 'src/components/activity_header/activity_header.vue'
import AvatarList from 'src/components/avatar_list/avatar_list.vue'
import EmojiReactions from 'src/components/emoji_reactions/emoji_reactions.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import StatusPopover from 'src/components/status_popover/status_popover.vue'
import Timeago from 'src/components/timeago/timeago.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import UserListPopover from 'src/components/user_list_popover/user_list_popover.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { muteFilterHits } from '../../services/status_parser/status_parser.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useScrobblesStore } from 'src/stores/scrobbles.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { useUsersStore } from 'src/stores/users.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleRight,
faChevronDown,
faChevronUp,
faEllipsisH,
faEnvelope,
faEye,
faEyeSlash,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faPlay,
faPlusSquare,
faReply,
faRetweet,
faSmileBeam,
faStar,
faThumbtack,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEnvelope,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faStar,
faSmileBeam,
faEllipsisH,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight,
faPlay,
)
const Status = {
name: 'Status',
components: {
PostStatusForm,
UserAvatar,
AvatarList,
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent,
ActivityHeader,
MentionLink,
MentionsLine,
UserPopover,
UserLink,
Timeago,
Quote: defineAsyncComponent(() => import('src/components/quote/quote.vue')),
StatusActionButtons,
},
props: {
statusId: String,
statusoid: Object,
replies: Set,
focused: Boolean,
compact: Boolean,
isPreview: Boolean,
noHeading: Boolean,
inQuote: Boolean,
ignoreMute: Boolean,
widthEfficient: Boolean,
threadDisplayState: String,
conversationRank: {
type: String,
default: 'linear',
},
},
emits: [
- 'dive',
+ 'requestFocus',
'toggleExpanded',
'toggleThreadDisplay',
'suspendableStateChange',
'heightChange',
],
inject: {
profileUserId: {
default: null,
},
isPage: {
default: false,
},
isExpanded: {
default: false,
},
expandable: {
default: false,
},
},
provide: {
expandable: false, // for quotes
},
data() {
return {
resizeObserver: new ResizeObserver(this.updateVirtualHeight),
replying: false,
unmuted: false,
mediaPlaying: new Set(),
error: null,
headTailLinks: null,
}
},
created() {
useScrobblesStore().getLatestScrobble(this.status.user.id)
},
computed: {
shouldBeWidthEfficient() {
return (
this.widthEfficient ||
this.layoutType === 'mobile' ||
this.mergedConfig.widthEfficientPostLayout
)
},
headerLevel() {
return [this.isPage ? 0 : 1, this.isExpanded ? 1 : 0].reduce(
(acc, item) => acc + item,
1,
)
},
rootClasses() {
return [
{
'-focused': this.focused,
'-conversation': !this.isPage && this.isExpanded,
},
`-conversation-rank-${this.conversationRank}`,
]
},
// Whatever we're given to work with
status() {
return this.statusoid ?? useStatusesStore().allStatuses.get(this.statusId)
},
inConversation() {
return this.isExpanded
},
inProfile() {
return this.profileUserId != null
},
// Status repeated
repeatedStatus() {
if (this.status.retweeted_status === undefined) return undefined
return useStatusesStore().allStatuses.get(this.status.retweeted_status.id)
},
// THE repeat
repeatStatus() {
if (this.isRepeat) {
return this.status
} else {
return null
}
},
mainStatus() {
if (this.isRepeat) {
return this.repeatedStatus
} else {
return this.status
}
},
repeater() {
return useUsersStore().findUser(this.status.user.id)
},
user() {
return useUsersStore().findUser(this.mainStatus.user.id)
},
isTreeView() {
return this.mergedConfig.conversationDisplay === 'tree'
},
showOtherRepliesInside() {
return this.mergedConfig.conversationOtherRepliesButton === 'inside'
},
showOtherRepliesBelow() {
return this.mergedConfig.conversationOtherRepliesButton === 'below'
},
showReasonMutedThread() {
return (
(this.mainStatus.thread_muted || this.repeatStatus?.thread_muted) &&
!this.inConversation
)
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
repeaterClass() {
return highlightClass(this.repeater)
},
userClass() {
return highlightClass(this.user)
},
isDeleted() {
return this.status.deleted
},
repeaterStyle() {
return highlightStyle(
useUserHighlightStore().get(this.repeater.screen_name),
)
},
favoritedBy() {
return useStatusesStore().favs.get(this.mainStatus.id) ?? new Set()
},
repeatedBy() {
return useStatusesStore().repeats.get(this.mainStatus.id) ?? new Set()
},
userStyle() {
if (this.noHeading) return
return highlightStyle(useUserHighlightStore().get(this.user.screen_name))
},
replyProfileLink() {
if (this.isReply) {
const user = useUsersStore().findUser(
this.mainStatus.in_reply_to_user_id,
)
// User referenced in post might not be yet present in store
// since their data is not included in status data, just the id
return user?.statusnet_profile_url
}
},
isRepeat() {
return !!this.repeatedStatus
},
repeaterName() {
return this.status.user.name || this.status.user.screen_name_ui
},
repeaterHtml() {
return this.status.user.name
},
repeaterProfileLink() {
return this.generateUserProfileLink(
this.repeater.id,
this.repeater.screen_name,
)
},
loggedIn() {
return !!this.currentUser
},
muteFilterHits() {
return muteFilterHits(
Object.values(
useSyncConfigStore().prefsStorage.simple.muteFilters || {},
),
this.status,
)
},
botStatus() {
return this.status.user.actor_type === 'Service'
},
sensitiveStatus() {
return this.status.nsfw
},
mentionsLine() {
if (!this.headTailLinks) return []
const writtenSet = new Set(
this.headTailLinks.writtenMentions.map((_) => _.url),
)
return this.mainStatus.attentions
.filter((attn) => {
// no reply user
return (
attn.id !== this.mainStatus.in_reply_to_user_id &&
// no self-replies
attn.statusnet_profile_url !==
this.mainStatus.user.statusnet_profile_url &&
// don't include if mentions is written
!writtenSet.has(attn.statusnet_profile_url)
)
})
.map((attn) => ({
url: attn.statusnet_profile_url,
content: attn.screen_name,
userId: attn.id,
}))
},
hasMentionsLine() {
return this.mentionsLine.length > 0
},
// TODO move muting logic into some store
muteReasons() {
return [
this.userIsMuted ? 'user' : null,
this.mainStatus.thread_muted ? 'thread' : null,
this.muteFilterHits.length > 0 ? 'filtered' : null,
this.muteBotStatuses && this.botStatus ? 'bot' : null,
this.muteSensitiveStatuses && this.sensitiveStatus ? 'nsfw' : null,
].filter(Boolean)
},
muteLocalized() {
if (this.muteReasons.length === 0) return null
const mainReason = () => {
switch (this.muteReasons[0]) {
case 'user':
return this.$t('status.muted_user')
case 'thread':
return this.$t('status.thread_muted')
case 'filtered':
return this.$t(
'status.muted_filters',
{
name: this.muteFilterHits[0].name,
filterMore: this.muteFilterHits.length - 1,
},
this.muteFilterHits.length,
)
case 'bot':
return this.$t('status.bot_muted')
case 'nsfw':
return this.$t('status.sensitive_muted')
}
}
if (this.muteReasons.length > 1) {
return this.$t(
'status.multi_reason_mute',
{
main: mainReason(),
numReasonsMore: this.muteReasons.length - 1,
},
this.muteReasons.length - 1,
)
} else {
return mainReason()
}
},
muted() {
if (this.ignoreMute) return false
if (this.status.user.id === this.currentUser?.id) return false
return !this.unmuted && !this.shouldNotMute && this.muteReasons.length > 0
},
userIsMuted() {
if (!this.currentUser) return false
if (this.user === this.currentUser) return false
if (this.repeater === this.currentUser) return false
const relationship = useUsersStore().relationship(this.user.id)
const relationshipRepeat = useUsersStore().relationship(this.repeater?.id)
return (
(this.status.muted && !this.status.thread_muted) ||
// Reprööt of a muted post according to BE
(this.repeatedStatus?.muted && !this.repeatedStatus.thread_muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
relationshipRepeat?.muting
)
},
shouldNotMute() {
if (this.ignoreMute) return true
if (this.focused) return true
const { reblog } = this.mainStatus
return (
((this.inProfile &&
// Don't mute user's posts on user timeline (except reblogs)
((!reblog && this.mainStatus.user.id === this.profileUserId) ||
// Same as above but also allow self-reblogs
reblog?.user.id === this.profileUserId)) ||
// Don't mute statuses in muted conversation when said conversation is opened
(this.inConversation && this.mainStatus.thread_muted)) &&
// No excuses if post has muted words
!this.muteFilterHits.length > 0
)
},
hideMutedUsers() {
return this.mergedConfig.hideMutedPosts
},
hideMutedThreads() {
return this.mergedConfig.hideMutedThreads
},
hideFilteredStatuses() {
return this.mergedConfig.hideFilteredStatuses
},
hideWordFilteredPosts() {
return this.mergedConfig.hideWordFilteredPosts
},
hideStatus() {
return (
!this.shouldNotMute &&
((this.muted && this.hideFilteredStatuses) ||
(this.userIsMuted && this.hideMutedUsers) ||
(this.status.thread_muted && this.hideMutedThreads) ||
(this.muteFilterHits.length > 0 && this.hideWordFilteredPosts) ||
this.muteFilterHits.some((x) => x.hide))
)
},
isReply() {
return !!(
this.mainStatus.in_reply_to_status_id &&
this.mainStatus.in_reply_to_user_id
)
},
replyToName() {
if (this.mainStatus.in_reply_to_screen_name) {
return this.mainStatus.in_reply_to_screen_name
} else {
const user = useUsersStore().findUser(
this.mainStatus.in_reply_to_user_id,
)
return user?.screen_name_ui
}
},
combinedFavsAndRepeatsUsers() {
return new Set([...this.favoritedBy, ...this.repeatedBy])
},
tags() {
return [...this.status.tags]
.filter((tagObj) => Object.hasOwn(tagObj, 'name'))
.map((tagObj) => tagObj.name)
.join(' ')
},
hidePostStats() {
return this.mergedConfig.hidePostStats
},
shouldDisplayFavsAndRepeats() {
return (
!this.hidePostStats &&
this.focused &&
(this.combinedFavsAndRepeatsUsers.size > 0 ||
this.mainStatus.quotes_count)
)
},
muteBotStatuses() {
return this.mergedConfig.muteBotStatuses
},
muteSensitiveStatuses() {
return this.mergedConfig.muteSensitiveStatuses
},
hideBotIndication() {
return this.mergedConfig.hideBotIndication
},
currentUser() {
return useUsersStore().currentUser
},
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
isSuspendable() {
return !this.replying && this.mediaPlaying.size === 0
},
visibilityLocalized() {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
},
isEdited() {
return this.mainStatus.edited_at !== null
},
editingAvailable() {
return useInstanceCapabilitiesStore().editingAvailable
},
quoteId() {
return this.mainStatus.quote_id
},
quoteUrl() {
return this.mainStatus.quote_url
},
quoteVisible() {
return this.mainStatus.quote_visible
},
quoteExpanded() {
return !this.inQuote
},
scrobblePresent() {
if (this.mergedConfig.hideScrobbles) return false
if (!this.status.user?.latestScrobble) return false
const value = this.mergedConfig.hideScrobblesAfter.match(/\d+/gs)[0]
const unit = this.mergedConfig.hideScrobblesAfter.match(/\D+/gs)[0]
let multiplier = 60 * 1000 // minutes is smallest unit
switch (unit) {
case 'm':
break
case 'h':
multiplier *= 60 // hour
break
case 'd':
multiplier *= 60 // hour
multiplier *= 24 // day
break
}
const maxAge = Number(value) * multiplier
const createdAt = Date.parse(this.status.user.latestScrobble.created_at)
const age = Date.now() - createdAt
if (age > maxAge) return false
return this.status.user.latestScrobble.artist
},
scrobble() {
return this.status.user?.latestScrobble
},
...mapState(useInterfaceStore, ['layoutType']),
},
methods: {
showError(error) {
this.error = error
},
clearError() {
this.error = undefined
},
toggleReplyForm() {
if (this.replying) {
// This emits 'close-accepted' if successful
// which in turn callse closeReply()
this.$refs.postStatusForm.requestClose()
} else {
this.replying = true
}
},
closeReplyForm() {
this.replying = false
},
toggleMute() {
this.unmuted = !this.unmuted
},
generateUserProfileLink(id, name) {
return generateProfileLink(
id,
name,
useInstanceStore().restrictedNicknames,
)
},
addMediaPlaying(id) {
this.mediaPlaying.add(id)
},
removeMediaPlaying(id) {
this.mediaPlaying.delete(id)
},
setHeadTailLinks(headTailLinks) {
this.headTailLinks = headTailLinks
},
updateVirtualHeight(e) {
const [entry] = e
this.$emit('heightChange', {
id: this.status.id,
height: entry.contentRect.height + 1,
element: this.$el,
})
},
},
mounted() {
if (this.$refs.root) {
this.resizeObserver.observe(this.$refs.root)
this.updateVirtualHeight([
{
contentRect: this.$refs.root.getBoundingClientRect(),
},
])
}
},
unmounted() {
this.resizeObserver.disconnect()
},
watch: {
hideStatus: {
handler() {
if (this.$refs.root) {
this.resizeObserver.observe(this.$refs.root)
} else {
this.resizeObserver.disconnect()
}
},
flush: 'post',
},
'mainStatus.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (this.focused && this.repeatedBy.size !== num) {
useStatusesStore().fetchRepeats(this.mainStatus.id)
}
},
'mainStatus.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (this.focused && this.favoritedBy.size !== num) {
useStatusesStore().fetchFavs(this.mainStatus.id)
}
},
isSuspendable: function (suspendable) {
this.$emit('suspendableStateChange', { id: this.status.id, suspendable })
},
},
}
export default Status
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 2a5a8a3115..5e7c03d4aa 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -1,252 +1,257 @@
.Status {
min-width: 0;
white-space: normal;
overflow-wrap: break-word;
text-wrap: pretty;
display: flex;
flex-direction: column;
&:hover {
--_still-image-img-visibility: visible;
--_still-image-canvas-visibility: hidden;
--_still-image-label-visibility: hidden;
}
.gravestone {
padding: var(--status-margin);
display: flex;
.deleted-text {
margin: 0.5em 0;
align-items: center;
}
}
.pin {
display: flex;
align-items: center;
justify-content: flex-end;
margin-right: 0.5em;
}
._misclick-prevention & {
pointer-events: none;
.attachments {
pointer-events: initial;
cursor: initial;
}
}
.usercard {
margin-bottom: var(--status-margin);
}
.glued-label {
display: inline-flex;
white-space: nowrap;
}
& .heading-reply-row,
& .heading-edited-row {
position: relative;
align-content: baseline;
font-size: 0.85em;
line-height: 130%;
max-width: 100%;
align-items: stretch;
}
& .reply-to-popover,
& .reply-to-no-popover,
& .mentions {
min-width: 0;
margin-right: 0.4em;
flex-shrink: 0;
}
.reply-glued-label {
margin-right: 0.5em;
}
.reply-to-popover {
.faint-link:hover {
// override default
text-decoration: none;
}
&.-strikethrough {
.reply-to::after {
content: "";
display: block;
position: absolute;
top: 50%;
width: 100%;
border-bottom: 1px solid var(--faint);
pointer-events: none;
}
}
}
& .mentions,
& .reply-to {
white-space: nowrap;
position: relative;
}
& .mentions-text,
& .reply-to-text {
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mentions-line {
display: inline;
}
.replies {
margin-top: 0.25em;
line-height: 1.3;
font-size: 0.85em;
- display: flex;
- flex-wrap: wrap;
- & > * {
- margin-right: 0.4em;
+ .replies-list {
+ list-style: none;
+ display: inline;
+ margin: 0;
+ padding: 0;
+
+ li {
+ display: inline;
+ }
}
- }
- .reply-link {
- height: 17px;
+ .reply-link {
+ height: 1.2em;
+ }
}
.repeat-info {
line-height: 1;
padding: 0.4em var(--status-margin);
--emoji-size: 1em;
.repeater-avatar {
border-radius: var(--roundness);
margin-left: 2em; // 3.6 (poster avatar size) - 1.6 (repeater avatar size)
width: 1.6em;
height: 1.6em;
margin-right: var(--status-margin);
vertical-align: middle;
}
.repeater-name {
a {
font-weight: bold;
color: var(--text);
}
}
.repeat-label {
white-space: nowrap;
.repeat-icon {
vertical-align: middle;
color: var(--cGreen);
}
}
}
.muted {
padding: 0.25em 0.6em;
height: 1.2em;
line-height: 1.2em;
text-overflow: ellipsis;
overflow: hidden;
display: flex;
flex-wrap: nowrap;
gap: 1ex;
& .status-username,
& .mute-reason {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.status-username {
font-weight: normal;
flex: 0 1 auto;
margin-right: 0.2em;
font-size: smaller;
display: flex;
}
.unmute {
flex: 0 0 auto;
margin-left: auto;
display: block;
}
}
.reply-form {
padding-top: 0;
padding-bottom: 0;
}
.reply-body {
flex: 1;
}
.favs-repeated-users {
margin-top: var(--status-margin);
}
.stats {
width: 100%;
display: flex;
}
.avatar-row {
flex: 1;
position: relative;
display: flex;
align-items: center;
overflow: hidden;
&::before {
content: "";
position: absolute;
height: 100%;
width: 1px;
left: 0;
background-color: var(--textFaint);
}
}
.stat-count {
margin-right: var(--status-margin);
user-select: none;
.stat-title {
color: var(--textFaint);
font-size: 0.85em;
text-transform: uppercase;
position: relative;
}
.stat-number {
font-weight: bolder;
font-size: 1.1em;
line-height: 1em;
color: var(--text);
}
&:hover .stat-title {
text-decoration: underline;
}
}
.thread-tree-show-replies-button {
display: block;
padding: var(--status-margin);
padding-left: var(--status-margin);
}
}
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 7c73053723..ccbace51bf 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,468 +1,473 @@
<template>
<article
v-if="!hideStatus"
ref="root"
class="Status"
:class="rootClasses"
:aria-label="mainStatus.summary || $t('status.unsubjected')"
>
<div
v-if="error"
class="alert error"
>
{{ error }}
<button
class="fa-scale-110 fa-old-padding"
type="button"
@click="clearError"
>
<FAIcon icon="times" />
</button>
</div>
<template v-if="muted && !isPreview">
<div class="muted">
<small class="status-username">
<FAIcon
v-if="muted && isRepeat"
class="fa-scale-110 fa-old-padding repeat-icon"
icon="retweet"
/>
<UserLink
:user="repeater"
:at="false"
/>
</small>
<small class="mute-reason">
{{ muteLocalized }}
</small>
<button
class="unmute button-unstyled"
@click.prevent="toggleMute"
:title="$t('status.show_muted_status')"
>
<FAIcon
icon="eye-slash"
class="fa-scale-110 fa-old-padding"
/>
</button>
</div>
</template>
<template v-else>
<div
v-if="isRepeat && !inConversation"
:class="[repeaterClass, { highlighted: repeaterStyle }]"
:style="[repeaterStyle]"
class="repeat-info"
>
<UserAvatar
class="repeater-avatar"
:user-id="repeater.id"
/>
<bdi
class="status-username repeater-name"
:title="repeaterName"
>
<router-link
+ class="button-unstyled"
v-if="repeaterHtml"
:to="repeaterProfileLink"
>
<RichContent
:html="repeaterHtml"
:emoji="repeater.emoji"
:allow-non-square-emoji="allowNonSquareEmoji"
:pause-mfm="pauseMfm"
:scale-mfm="scaleMfm"
:is-local="repeater.is_local"
/>
</router-link>
<router-link
v-else
:to="repeaterProfileLink"
>{{ repeaterName }}</router-link>
</bdi>
{{ ' ' }}
<span class="repeat-label faint">
<FAIcon
icon="retweet"
class="repeat-icon"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
</span>
</div>
<div
v-if="!isDeleted"
:class="[userClass, { highlighted: userStyle, '-repeat': isRepeat && !inConversation, '-width-efficient': shouldBeWidthEfficient, '-compact': compact }]"
:style="[ userStyle ]"
class="activity-layout"
:data-tags="tags"
>
<UserPopover
v-if="user.name"
class="activity-avatar"
:user-id="user.id"
overlay-centers
link
>
<UserAvatar
class="post-avatar"
:compact="compact"
:user-id="user.id"
/>
</UserPopover>
<UserAvatar
v-else
:user-id="user.id"
class="activity-avatar"
:compact="compact"
:title="$t('status.unknown_user_info')"
/>
<ActivityHeader
class="activity-header"
:status-id="mainStatus.id"
:user-id="user.id"
:unmuted="unmuted"
:time="mainStatus.created_at"
:replies="replies"
:multiline="shouldBeWidthEfficient"
:compact="compact"
:expandable="expandable"
:expanded="isExpanded"
:thread-display-state="threadDisplayState"
+ @request-focus="$emit('requestFocus', mainStatus.id)"
@toggle-muted="toggleMute"
@toggle-expanded="$emit('toggleExpanded')"
@toggle-thread-display="$emit('toggleThreadDisplay')"
- @dive="$emit('dive')"
/>
<div class="activity-subheader">
<div
v-if="scrobblePresent"
class="status-rich-presence"
>
<a
v-if="scrobble.externalLink"
:href="scrobble.externalLink"
target="_blank"
>
{{ 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
v-if="isReply || hasMentionsLine"
class="heading-reply-row"
>
<span
v-if="isReply"
class="glued-label reply-glued-label"
>
<i18n-t
keypath="status.reply_to_with_arg"
scope="global"
>
<template #replyToWithIcon>
<StatusPopover
v-if="!isPreview"
- :status-id="mainStatus.parent_visible && mainStatus.in_reply_to_status_id"
class="reply-to-popover"
- style="min-width: 0;"
:class="{ '-strikethrough': !mainStatus.parent_visible }"
+ :status-id="mainStatus.parent_visible && mainStatus.in_reply_to_status_id"
+ :link="isPage"
:aria-label="$t('tool_tip.reply')"
- :link="isExpanded"
+ @navigate="$emit('requestFocus', mainStatus.parent_visible && mainStatus.in_reply_to_status_id)"
>
<i18n-t
keypath="status.reply_to_with_icon"
scope="global"
>
<template #icon>
<FAIcon
class="fa-scale-110"
icon="reply"
flip="horizontal"
/>
</template>
<template #replyTo>
<span
class="reply-to-text"
>
{{ $t('status.reply_to') }}
</span>
</template>
</i18n-t>
</StatusPopover>
<span
v-else
class="reply-to-no-popover"
>
<span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span>
</template>
<template #user>
<MentionLink
:content="replyToName"
:url="replyProfileLink"
:user-id="status.in_reply_to_user_id"
:user-screen-name="status.in_reply_to_screen_name"
/>
</template>
</i18n-t>
</span>
<!-- This little wrapper is made for sole purpose of "gluing" -->
<!-- "Mentions" label to the first mention -->
<span
v-if="hasMentionsLine"
class="glued-label"
>
<span
class="mentions"
:aria-label="$t('tool_tip.mentions')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<span
class="mentions-text"
>
{{ $t('status.mentions') }}
</span>
</span>
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(0, 1)"
class="mentions-line-first"
/>
</span>
{{ ' ' }}
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(1)"
class="mentions-line"
/>
</div>
<div
v-if="isEdited && editingAvailable && !isPreview"
class="heading-edited-row"
>
<i18n-t
scope="global"
keypath="status.edited_at"
tag="span"
>
<template #time>
<Timeago
template-key="time.in_past"
:time="mainStatus.edited_at"
:auto-update="60"
:long-format="true"
/>
</template>
</i18n-t>
</div>
</div>
<div
class="activity-content"
>
<StatusContent
ref="content"
:status="mainStatus"
:focused="focused"
:in-conversation="inConversation"
@mediaplay="addMediaPlaying($event)"
@mediapause="removeMediaPlaying($event)"
@parse-ready="setHeadTailLinks"
/>
<Quote
:status-id="quoteId"
:status-url="quoteUrl"
:status-visible="quoteVisible"
:initially-expanded="quoteExpanded"
/>
<div
v-if="inConversation && !isPreview && replies?.size"
class="replies"
>
<button
v-if="showOtherRepliesInside && replies.size > 1"
class="button-unstyled -link"
:title="$t('status.ancestor_follow', { numReplies: replies.size - 1 }, replies.size - 1)"
- @click.prevent="$emit('dive')"
+ @click.prevent="$emit('requestFocus', mainStatus.id)"
>
{{ $t('status.replies_list_with_others', { numReplies: replies.size - 1 }, replies.size - 1) }}
</button>
<span
v-else
role="header"
:aria-level="headerLevel"
class="faint"
>
{{ $t('status.replies_list') }}
</span>
- <span role="list" :aria-label="$t('status.replies_list')">
- <span
+ {{ ' ' }}
+ <ol
+ class="replies-list"
+ :aria-label="$t('status.replies_list')"
+ >
+ <li
v-for="reply in replies.values()"
:key="reply.id"
- role="listitem"
:aria-label="reply.name"
>
<StatusPopover
:status-id="reply.id"
+ :link="isPage"
class="reply-link"
- link
+ @navigate="$emit('requestFocus', reply.id)"
>
{{ reply.name }}
</StatusPopover>
{{ ' ' }}
- </span>
- </span>
+ </li>
+ </ol>
</div>
<Transition name="fade">
<div
v-if="shouldDisplayFavsAndRepeats"
class="favs-repeated-users"
>
<div class="stats">
<UserListPopover
v-if="repeatedBy.size > 0"
:user-ids="repeatedBy"
>
<div class="stat-count">
<a class="stat-title">{{ $t('status.repeats') }}</a>
<div class="stat-number">
{{ repeatedBy.size }}
</div>
</div>
</UserListPopover>
<UserListPopover
v-if="favoritedBy.size > 0"
:user-ids="favoritedBy"
>
<div
class="stat-count"
>
<a class="stat-title">{{ $t('status.favorites') }}</a>
<div class="stat-number">
{{ favoritedBy.size }}
</div>
</div>
</UserListPopover>
<router-link
v-if="mainStatus.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">
{{ mainStatus.quotes_count }}
</div>
</div>
</router-link>
<div class="avatar-row">
<AvatarList :user-ids="combinedFavsAndRepeatsUsers" />
</div>
</div>
</div>
</Transition>
<EmojiReactions
v-if="(mergedConfig.emojiReactionsOnTimeline || focused) && (!isPreview)"
:status="mainStatus"
/>
</div>
<StatusActionButtons
v-if="!isPreview"
class="activity-actions"
:status="mainStatus"
:replying="replying"
@toggle-replying="toggleReplyForm"
/>
</div>
<div
v-else
class="gravestone"
>
<UserAvatar
class="post-avatar"
:compact="compact"
/>
<div class="deleted-text">
{{ $t('status.status_deleted') }}
</div>
</div>
<div
v-if="replying"
class="reply-form"
>
<PostStatusForm
ref="postStatusForm"
class="reply-body"
:closeable="true"
:replied-status="mainStatus"
@posted="closeReplyForm"
@draft-done="closeReplyForm"
@close-accepted="closeReplyForm"
/>
</div>
<i18n-t
v-if="inConversation && conversationRank === 'ancestor' && !isPreview && showOtherRepliesBelow && replies?.size > 1"
tag="button"
scope="global"
keypath="status.ancestor_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="$emit('dive')"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
{{ $t('status.ancestor_follow', { numReplies: replies.size - 1 }) }}
</span>
</template>
</i18n-t>
</template>
</article>
</template>
<script src="./status.js"></script>
<style src="./status.scss" lang="scss"></style>
<style src="./activity-layout.scss" lang="scss"></style>
diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js
index 4c9fd4e13c..12272322ec 100644
--- a/src/components/status_popover/status_popover.js
+++ b/src/components/status_popover/status_popover.js
@@ -1,65 +1,70 @@
import Popover from 'src/components/popover/popover.vue'
import { useStatusesStore } from 'src/stores/statuses.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
const StatusPopover = {
name: 'StatusPopover',
props: {
statusId: String,
link: Boolean,
},
+ emits: ['navigate'],
data() {
return {
error: false,
}
},
computed: {
status() {
return useStatusesStore().allStatuses.get(this.statusId)
},
statusLink() {
return this.$router.resolve({
name: 'conversation',
params: { statusId: this.statusId },
}).href
},
},
components: {
Popover,
},
methods: {
enter() {
if (!this.status) {
if (!this.statusId) {
this.error = true
return
}
useStatusesStore()
.fetchStatus(this.statusId)
.then(() => (this.error = false))
.catch(() => (this.error = true))
}
},
navigate() {
- if (!this.link) return
- this.$router.push({
- name: 'conversation',
- params: { statusId: this.statusId },
- })
+ if (this.link) {
+ this.$router.push({
+ name: 'conversation',
+ params: { statusId: this.statusId },
+ })
+ } else {
+ console.log(this.statusId)
+ this.$emit('navigate', this.statusId)
+ }
},
},
watch: {
status(newStatus, oldStatus) {
if (newStatus !== oldStatus) {
this.$nextTick(() => this.$refs.popover.updateStyles())
}
},
},
}
export default StatusPopover
diff --git a/src/components/thread_tree/thread_tree.js b/src/components/thread_tree/thread_tree.js
index 6bea1279ac..d6fb9a5493 100644
--- a/src/components/thread_tree/thread_tree.js
+++ b/src/components/thread_tree/thread_tree.js
@@ -1,65 +1,61 @@
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleRight,
} from '@fortawesome/free-solid-svg-icons'
library.add(faAngleDoubleDown, faAngleDoubleRight)
const ThreadTree = {
components: {},
name: 'ThreadTree',
props: {
statusId: String,
depth: Number,
},
emits: [
'suspendableStateChange',
- 'goto',
- 'dive',
+ 'requestFocus',
'toggleExpanded',
'toggleThreadDisplay',
'showThreadRecursively',
],
inject: [
'conversation',
'focusedId',
'replies',
'threadDisplay',
'isExpanded',
'isPage',
'totalReplyCount',
'totalReplyDepth',
],
computed: {
status() {
const status = useStatusesStore().allStatuses.get(this.statusId)
if (status.retweeted_status) {
return useStatusesStore().allStatuses.get(status.retweeted_status.id)
}
return status
},
currentReplies() {
return [...this.getReplies(this.status.id)].map(({ id }) => id)
},
simple() {
return !useMergedConfigStore().mergedConfig.conversationTreeAdvanced
},
threadShowing() {
return this.threadDisplay.get(this.status.id) === 'showing'
},
- canDive() {
- return this.isExpanded
- },
},
methods: {
getReplies(id) {
return this.replies.get(id) ?? new Set()
},
},
}
export default ThreadTree
diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue
index 6fe9c955bb..84d3a3127f 100644
--- a/src/components/thread_tree/thread_tree.vue
+++ b/src/components/thread_tree/thread_tree.vue
@@ -1,103 +1,101 @@
<template>
<div
ref="root"
class="thread-tree"
>
<Status
:key="statusId"
class="conversation-status conversation-status-treeview panel-body"
:status-id="statusId"
:replies="getReplies(statusId)"
:focused="focusedId === status.id"
:data-status-id="statusId"
:conversation-rank="depth === 0 ? 'current' : 'child'"
:thread-display-state="threadDisplay.get(statusId)"
- @dive="$emit('dive', statusId)"
- @goto="$emit('goto', statusId)"
+ @request-focus="(e) => $emit('requestFocus', e)"
@toggle-expanded="$emit('toggleExpanded', statusId)"
@toggle-thread-display="$emit('toggleThreadDisplay', statusId)"
@suspendable-state-change="(e) => $emit('suspendableStateChange', e)"
/>
<div
v-if="currentReplies.length > 0 && threadShowing"
class="thread-tree-replies"
>
<ThreadTree
v-for="replyStatusId in currentReplies"
:key="replyStatusId"
:depth="depth + 1"
:status-id="replyStatusId"
+ @request-focus="(e) => $emit('requestFocus', e)"
@show-thread-recursively="(e) => $emit('showThreadRecursively', e)"
- @goto="(e) => $emit('goto', e)"
- @dive="(e) => $emit('dive', e)"
@toggle-thread-display="e => $emit('toggleThreadDisplay', e)"
@suspendable-state-change="e => $emit('suspendableStateChange', e)"
@toggle-expanded="(e) => $emit('toggleExpanded', e)"
/>
</div>
<div
v-if="currentReplies.length && !threadShowing"
class="thread-tree-replies thread-tree-replies-hidden"
>
<i18n-t
v-if="simple"
scope="global"
tag="button"
keypath="status.thread_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="$emit('dive', statusId)"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
{{ $t('status.thread_follow', { numStatus: totalReplyCount.get(status.id) }, totalReplyCount.get(status.id)) }}
</span>
</template>
</i18n-t>
<i18n-t
v-else
scope="global"
tag="button"
keypath="status.thread_show_full_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="$emit('showThreadRecursively', statusId)"
>
<template #icon>
<FAIcon
icon="angle-double-down"
/>
</template>
<template #text>
<span>
{{ $t('status.thread_show_full', { numStatus: totalReplyCount.get(status.id), depth: totalReplyDepth.get(status.id) }, totalReplyCount.get(status.id)) }}
</span>
</template>
</i18n-t>
</div>
</div>
</template>
<script src="./thread_tree.js"></script>
<style lang="scss">
.thread-tree-replies {
margin-left: var(--status-margin);
border-left: 2px solid var(--border);
}
.thread-tree-replies-hidden {
padding: var(--status-margin);
/* Make the button stretch along the whole row */
display: flex;
align-items: stretch;
flex-direction: column;
}
</style>

File Metadata

Mime Type
text/x-diff
Expires
Sat, Oct 31, 1:39 PM (13 h, 3 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1801456
Default Alt Text
(94 KB)

Event Timeline