diff --git a/package.json b/package.json index 29ef7bf5a..674828e26 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,8 @@ "@vue/devtools-api": "8.2.0", "@vue/test-utils": "2.5.0", "autoprefixer": "10.5.4", + "chalk": "6.0.0", + "cross-spawn": "7.0.6", "iso-639-1": "3.1.6", "playwright": "1.61.0", "postcss": "8.5.28", diff --git a/src/boot/routes.js b/src/boot/routes.js index 5c911d516..04ad81000 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -83,9 +83,8 @@ export default () => { }, { name: 'conversation', - path: '/notice/:statusId', + path: '/notice/:id', component: ConversationPage, - props: true, meta: { dontScroll: true }, }, { diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 919017387..7f0686491 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -1,20 +1,7 @@ -import { get } from 'lodash-es' -import { storeToRefs } from 'pinia' -import { - computed, - nextTick, - onMounted, - provide, - ref, - toRefs, - useTemplateRef, - watch, -} from 'vue' -import { useRouter } from 'vue-router' +import { get, reduce } from 'lodash-es' +import { mapState } from 'pinia' import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue' -import { useScrollPosition } from 'src/components/conversation/useScrollPosition.js' -import { useWindowSize } from 'src/components/conversation/useWindowSize.js' import PostStatusForm from 'src/components/post_status_form/post_status_form.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' @@ -27,10 +14,7 @@ import { useOAuthStore } from 'src/stores/oauth.js' import { useStatusesStore } from 'src/stores/statuses.js' import { useStreamingStore } from 'src/stores/streaming.js' -import { - fetchConversation as apiFetchConversation, - fetchStatus as apiFetchStatus, -} from 'src/api/public.js' +import { fetchConversation, fetchStatus } from 'src/api/public.js' import { WSConnectionStatus } from 'src/api/websocket.js' import { library } from '@fortawesome/fontawesome-svg-core' @@ -50,19 +34,372 @@ library.add( faTimes, ) -export default { +const sortById = (a, b) => { + const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id + const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id + const seqA = Number(idA) + const seqB = Number(idB) + const isSeqA = !Number.isNaN(seqA) + const isSeqB = !Number.isNaN(seqB) + if (isSeqA && isSeqB) { + return seqA < seqB ? -1 : 1 + } else if (isSeqA && !isSeqB) { + return -1 + } else if (!isSeqA && isSeqB) { + return 1 + } else { + return idA < idB ? -1 : 1 + } +} + +const conversation = { props: { statusId: { // Main thing type: String, required: true, }, + collapsable: { + // Whether conversation can be collapsed + // i.e. when it's not a page + type: Boolean, + default: false, + }, isPage: { // Whether conversation is rendered as a standalone page // as opposed to embedded into a timeline type: Boolean, default: false, }, + pinnedStatusIdsObject: { + // Used for user profile, map of pinned statuses + type: Object, + default: null, + }, + inProfile: { + // Whether conversation is rendered in a user profile + // used for overriding muted status + type: Boolean, + default: false, + }, + profileUserId: { + // used with inProfile, user id of the profile + type: String, + default: null, + }, + virtualHidden: { + // Whether conversation is suspended. Controls rendering of statuses + type: Boolean, + default: false, + }, + }, + emits: ['update:virtualHeight'], + data() { + return { + focused: null, + expanded: false, + threadDisplayStatusObject: {}, // id => 'showing' | 'hidden' + inlineDivePosition: null, + loadStatusError: null, + unsuspendibleIds: new Set(), + virtualHeight: 120, + } + }, + created() { + if (this.isPage) { + this.fetchConversation() + } + }, + mounted() { + this.updateVirtualHeight() + }, + computed: { + status() { + return useStatusesStore().allStatuses.get(this.statusId) + }, + maxDepthToShowByDefault() { + // maxDepthInThread = max number of depths that is *visible* + // since our depth starts with 0 and "showing" means "showing children" + // there is a -2 here + const maxDepth = this.mergedConfig.maxDepthInThread - 2 + return maxDepth >= 1 ? maxDepth : 1 + }, + streamingEnabled() { + return ( + this.mergedConfig.useStreamingApi && + this.mastoUserSocketStatus === WSConnectionStatus.JOINED + ) + }, + displayStyle() { + return this.mergedConfig.conversationDisplay + }, + treeViewIsSimple() { + return !this.mergedConfig.conversationTreeAdvanced + }, + isTreeView() { + return this.displayStyle === 'tree' + }, + isLinearView() { + return this.displayStyle !== 'tree' + }, + shouldFadeAncestors() { + return this.mergedConfig.conversationTreeFadeAncestors + }, + otherRepliesButtonPosition() { + return this.mergedConfig.conversationOtherRepliesButton + }, + showOtherRepliesButtonBelowStatus() { + return this.otherRepliesButtonPosition === 'below' + }, + showOtherRepliesButtonInsideStatus() { + return this.otherRepliesButtonPosition === 'inside' + }, + suspendable() { + return this.unsuspendibleIds.size === 0 + }, + hide() { + return this.virtualHidden && this.suspendable + }, + originalStatusId() { + if (this.status.retweeted_status) { + return this.status.retweeted_status.id + } else { + return this.statusId + } + }, + conversationId() { + return this.getConversationId(this.statusId) + }, + conversation() { + if (!this.status) { + return [] + } + + if (!this.isExpanded) { + return [this.status] + } + + const conversation = useStatusesStore().conversations.get( + this.conversationId, + ) + + return [...conversation.keys()] + .map((k) => useStatusesStore().allStatuses.get(k)) + .filter((status) => status.type != 'repeat') // Old backend behavior? + .toSorted(sortById) + }, + statusMap() { + return this.conversation.reduce((res, s) => { + res[s.id] = s + return res + }, {}) + }, + threadTree() { + const reverseLookupTable = this.conversation.reduce( + (table, status, index) => { + table[status.id] = index + return table + }, + {}, + ) + + const threads = this.conversation.reduce( + (a, cur) => { + const id = cur.id + a.forest[id] = this.getReplies(id).map((s) => s.id) + + return a + }, + { + forest: {}, + }, + ) + + const walk = (forest, topLevel, depth = 0, processed = {}) => + topLevel + .map((id) => { + if (processed[id]) { + return [] + } + + processed[id] = true + return [ + { + status: this.conversation[reverseLookupTable[id]], + id, + depth, + }, + walk(forest, forest[id], depth + 1, processed), + ].flat() + }) + .flat() + + const linearized = walk( + threads.forest, + this.topLevel.map((k) => k.id), + ) + + return linearized + }, + replyIds() { + return this.conversation + .map((k) => k.id) + .reduce((res, id) => { + res[id] = (this.replies[id] || []).map((k) => k.id) + return res + }, {}) + }, + totalReplyCount() { + const sizes = {} + const subTreeSizeFor = (id) => { + if (sizes[id]) { + return sizes[id] + } + sizes[id] = + 1 + + this.replyIds[id] + .map((cid) => subTreeSizeFor(cid)) + .reduce((a, b) => a + b, 0) + return sizes[id] + } + this.conversation.map((k) => k.id).map(subTreeSizeFor) + return Object.keys(sizes).reduce((res, id) => { + res[id] = sizes[id] - 1 // exclude itself + return res + }, {}) + }, + totalReplyDepth() { + const depths = {} + const subTreeDepthFor = (id) => { + if (depths[id]) { + return depths[id] + } + depths[id] = + 1 + + this.replyIds[id] + .map((cid) => subTreeDepthFor(cid)) + .reduce((a, b) => (a > b ? a : b), 0) + return depths[id] + } + this.conversation.map((k) => k.id).map(subTreeDepthFor) + return Object.keys(depths).reduce((res, id) => { + res[id] = depths[id] - 1 // exclude itself + return res + }, {}) + }, + depths() { + return this.threadTree.reduce((a, k) => { + a[k.id] = k.depth + return a + }, {}) + }, + topLevel() { + const topLevel = this.conversation.reduce( + (tl, cur) => + tl.filter( + (k) => + !this.getReplies(cur.id) + .map((v) => v.id) + .includes(k.id), + ), + this.conversation, + ) + return topLevel + }, + otherTopLevelCount() { + return this.topLevel.length - 1 + }, + showingTopLevel() { + if (this.canDive && this.diveRoot) { + return [this.statusMap[this.diveRoot]] + } + return this.topLevel + }, + diveRoot() { + const statusId = this.inlineDivePosition || this.statusId + const isTopLevel = !this.parentOf(statusId) + return isTopLevel ? null : statusId + }, + diveDepth() { + return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0 + }, + diveMode() { + return this.canDive && !!this.diveRoot + }, + shouldShowAllConversationButton() { + // The "show all conversation" button tells the user that there exist + // other toplevel statuses, so do not show it if there is only a single root + return ( + this.isTreeView && + this.isExpanded && + this.diveMode && + this.topLevel.length > 1 + ) + }, + shouldShowAncestors() { + return ( + this.isTreeView && + this.isExpanded && + this.ancestorsOf(this.diveRoot).length + ) + }, + replies() { + let i = 1 + + return reduce( + this.conversation, + (result, { id, in_reply_to_status_id: irid }) => { + if (irid) { + result[irid] = result[irid] || [] + result[irid].push({ + name: `#${i}`, + id, + }) + } + i++ + return result + }, + {}, + ) + }, + isExpanded() { + return !!(this.expanded || this.isPage) + }, + hiddenStyle() { + return { height: this.virtualHeight + 'px' } + }, + threadDisplayStatus() { + return this.conversation.reduce((a, k) => { + const id = k.id + const depth = this.depths[id] + const status = (() => { + if (this.threadDisplayStatusObject[id]) { + return this.threadDisplayStatusObject[id] + } + if (depth - this.diveDepth <= this.maxDepthToShowByDefault) { + return 'showing' + } else { + return 'hidden' + } + })() + + a[id] = status + return a + }, {}) + }, + canDive() { + return this.isTreeView && this.isExpanded + }, + maybeFocused() { + return this.isExpanded ? this.focused : null + }, + ...mapState(useMergedConfigStore, ['mergedConfig']), + ...mapState(useStreamingStore, { + mastoUserSocketStatus: (state) => state.state, + }), + ...mapState(useInterfaceStore, { + mobileLayout: (store) => store.layoutType === 'mobile', + }), }, components: { ThreadTree, @@ -72,466 +409,142 @@ export default { PostStatusForm, RichContent, }, - setup(props) { - const { statusId } = toRefs(props) - - const router = useRouter() - - // # Main Configuration - const { mergedConfig } = storeToRefs(useMergedConfigStore()) - const { mastoUserSocketStatus } = storeToRefs(useStreamingStore()) - const displayStyle = computed(() => mergedConfig.value.conversationDisplay) - const streamingEnabled = computed( - () => - mergedConfig.value.useStreamingApi && - mastoUserSocketStatus === WSConnectionStatus.JOINED, - ) - - // # Misc - const loadStatusError = ref(null) - const { layoutType } = storeToRefs(useInterfaceStore()) - const mobileLayout = computed(() => layoutType.value === 'mobile') - - // # Focus - const focusedId = ref(statusId.value) - const focused = computed(() => (isExpanded.value ? focusedId.value : null)) - const setFocused = (id) => { - if (!id) return - focusedId.value = id - - if (!streamingEnabled.value) { - useStatusesStore().fetchStatus(id) - } - - useStatusesStore().fetchFavsAndRepeats(id) - useStatusesStore().fetchEmojiReactions(id) - } - provide('focused', focused) - - // # Main things - const getStatusObject = (id) => useStatusesStore().allStatuses.get(id) - const getConversationId = (statusId) => { - const status = getStatusObject(statusId) - return get( - status, - 'retweeted_status.statusnet_conversation_id', - get(status, 'statusnet_conversation_id'), - ) - } - const status = computed(() => getStatusObject(focusedId.value)) - - const fetchConversation = async () => { - if (status.value) { - const { - data: { ancestors, descendants }, - timestamp, - } = await apiFetchConversation({ - id: statusId.value, - credentials: useOAuthStore().token, - }) - - useStatusesStore().addNewStatuses({ statuses: ancestors, timestamp }) - useStatusesStore().addNewStatuses({ - statuses: descendants, - timestamp, - }) - } else { - try { - loadStatusError.value = null - - const { data: status } = await apiFetchStatus({ - id: statusId.value, - credentials: useOAuthStore().token, - }) - - useStatusesStore().addNewStatuses({ statuses: [status] }) - fetchConversation() - } catch (error) { - console.error(error) - loadStatusError.value = error - } - } - } - const resetDisplayState = () => { - setFocused(statusId.value) - threadDisplay.value = new Map() - } - watch(statusId, (newVal, oldVal) => { - const newConversationId = getConversationId(newVal) - const oldConversationId = getConversationId(oldVal) + watch: { + statusId(newVal, oldVal) { + const newConversationId = this.getConversationId(newVal) + const oldConversationId = this.getConversationId(oldVal) if ( newConversationId && oldConversationId && newConversationId === oldConversationId ) { - setFocused(newVal) + this.setFocused(this.originalStatusId) } else { - resetDisplayState() - fetchConversation() + this.fetchConversation() } - }) - - const sortById = (a, b) => { - const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id - const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id - const seqA = Number(idA) - const seqB = Number(idB) - const isSeqA = !Number.isNaN(seqA) - const isSeqB = !Number.isNaN(seqB) - if (isSeqA && isSeqB) { - return seqA < seqB ? -1 : 1 - } else if (isSeqA && !isSeqB) { - return -1 - } else if (!isSeqA && isSeqB) { - return 1 - } else { - return idA < idB ? -1 : 1 - } - } - const conversationId = computed(() => getConversationId(statusId.value)) - const conversation = computed(() => { - if (!status.value) { - return [] - } - - if (!isExpanded.value) { - return [status.value] - } - - const conversation = useStatusesStore().conversations.get( - conversationId.value, - ) - - return [...conversation.keys()] - .map((k) => useStatusesStore().allStatuses.get(k)) - .filter((status) => status.type != 'repeat') // Old backend behavior? - .toSorted(sortById) - }) - const replies = computed(() => - conversation.value.reduce( - (result, { id, in_reply_to_status_id: irid }, index) => { - if (irid) { - if (!result.has(irid)) { - result.set(irid, new Set()) - } - result.get(irid).add({ - name: `#${index}`, - id, - }) - } - return result - }, - new Map(), - ), - ) - const getReplies = (id) => replies.value.get(id) ?? new Set() - const statusReplies = computed(() => { - return getReplies(status.value.id) - }) - - provide('conversation', conversation) - provide('replies', replies) - - // # Conversation Expansion - const expanded = ref(false) - const { isPage } = toRefs(props) - const isExpanded = computed(() => !!(expanded.value || isPage.value)) - const toggleExpanded = () => { - expanded.value = !expanded.value - } - watch(expanded, (value) => { + }, + expanded(value) { if (value) { - fetchConversation() + this.fetchConversation() } else { - resetDisplayState() + this.resetDisplayState() } - }) - provide('isExpanded', isExpanded) - provide('isPage', isPage) - - // Component created - if (isPage.value) { - fetchConversation() - } - - // # Virtual scrolling stuff - const fontSizeSetting = computed(() => mergedConfig.value.textSize) - const fontSize = computed(() => { - // reading fontSizeSetting to make computed react to it - fontSizeSetting.value - const string = window - .getComputedStyle(document.body) - .getPropertyValue('font-size') - return Number.parseInt(string.slice(0, -2), 10) // remove the 'px' - }) - const mutedStatusHeight = computed(() => { - return fontSize.value * 1.5 - }) - const normalStatusHeight = computed(() => { - return fontSize.value * 10 - }) - const heights = ref(new Map()) - const totalHeight = computed(() => - conversation.value.reduce((acc, item) => { - if (heights.value.has(item.id)) { - return acc + heights.value.get(item.id) - } else if (item.muted) { - return acc + mutedStatusHeight.value - } else { - return acc + normalStatusHeight.value - } - }, 0), - ) - - const body = useTemplateRef('body') - const updateVirtualHeight = ({ id, height }) => { - heights.value.set(id, height) - } - - const { y: topScrollBoundary } = useScrollPosition() - const { height: windowHeight } = useWindowSize() - - const realTopScrollBoundary = ref(0) - const realBottomScrollBoundary = ref(0) - const updateBoundaries = () => { - if (!body.value) return // Not mounted yet - - const { top } = body.value.getBoundingClientRect() - - const distanceItemTopToWindowTop = 0 - top - const distanceItemTopToWindowBottom = windowHeight.value - top - - realTopScrollBoundary.value = distanceItemTopToWindowTop - realBottomScrollBoundary.value = distanceItemTopToWindowBottom - } - - watch(topScrollBoundary, updateBoundaries) - watch(totalHeight, updateBoundaries) - onMounted(updateBoundaries) - - const buffer = normalStatusHeight.value * 2 - - const unsuspendibleIds = ref(new Set()) - const onStatusSuspendStateChange = ({ id, suspend }) => { - if (!suspend) { - unsuspendibleIds.value.add(id) + }, + virtualHidden() { + this.updateVirtualHeight() + }, + }, + methods: { + fetchConversation() { + if (this.status) { + fetchConversation({ + id: this.statusId, + credentials: useOAuthStore().token, + }).then(({ data: { ancestors, descendants }, timestamp }) => { + useStatusesStore().addNewStatuses({ statuses: ancestors, timestamp }) + useStatusesStore().addNewStatuses({ + statuses: descendants, + timestamp, + }) + this.setFocused(this.originalStatusId) + }) } else { - unsuspendibleIds.value.delete(id) + this.loadStatusError = null + fetchStatus({ + id: this.statusId, + credentials: useOAuthStore().token, + }) + .then(({ data: status }) => { + useStatusesStore().addNewStatuses({ statuses: [status] }) + this.fetchConversation() + }) + .catch((error) => { + console.error(error) + this.loadStatusError = error + }) } - } + }, + getReplies(id) { + return this.replies[id] || [] + }, + setFocused(id) { + if (!id) return + this.focused = id - const heightChartLinear = computed(() => { - // Map every height and suspendable state - const chart = conversation.value.map(({ id }) => { - const status = getStatusObject(id) - const height = - (() => { - if (heights.value.has(id)) { - return heights.value.get(id) - } else if (status?.muted) { - return mutedStatusHeight.value - } else { - return normalStatusHeight.value - } - })() + 1 //including border - const suspendable = !unsuspendibleIds.value.has(id) - return { id, height, suspendable, status } - }) + if (!this.streamingEnabled) { + useStatusesStore().fetchStatus(id) + } - // Walk over the list to set top offsets - chart.reduce((sum, item) => { - item.top = sum - return sum + item.height - }, 0) - - // Determine visibility state - chart.forEach((heightChartItem) => { - const itemBottomBoundary = heightChartItem.top + heightChartItem.height - const itemTopBoundary = heightChartItem.top - - const finalTopScrollBoundary = realTopScrollBoundary.value - buffer - const finalBottomScrollBoundary = - realBottomScrollBoundary.value + buffer - - // console.log( - // 'TOP SCROLL', - // itemBottomBoundary > finalTopScrollBoundary, - // itemBottomBoundary, finalTopScrollBoundary, - // ) - // console.log( - // 'BOTTOM SCROLL', - // itemTopBoundary < finalBottomScrollBoundary, - // itemTopBoundary, finalBottomScrollBoundary, - // ) - - // To be visible, item's bottom boundary shoud be below top scroll boundary) - const belowTopBoundary = itemBottomBoundary > finalTopScrollBoundary - // To be visible, item's top boundary shoud be above bottom scroll boundary) - const aboveBottomBoundary = itemTopBoundary < finalBottomScrollBoundary - // This accounts for the case where item's boundaries exceed scroll boundary - - heightChartItem.visible = belowTopBoundary && aboveBottomBoundary - }) - - // Group invisible statuses into spacers - return chart.reduce((acc, heightChartItem) => { - const { suspendable, visible, height, top, bottom, id, status } = - heightChartItem - const present = visible || !suspendable - if (present) { - return [...acc, { type: 'status', height, top, bottom, id, status }] - } else { - const previousItem = acc[acc.length - 1] - const spacer = - previousItem?.type === 'spacer' - ? previousItem - : { - type: 'spacer', - top: Number.POSITIVE_INFINITY, - bottom: Number.POSITIVE_INFINITY, - height: 0, - ids: new Set(), - } - - spacer.ids.add(id) - spacer.id = [...spacer.ids].join() - spacer.height += height - if (top < spacer.top) spacer.top = top - if (bottom < spacer.bottom) spacer.bottom = bottom - - if (previousItem?.type === 'spacer') { - return acc - } else { - return [...acc, spacer] - } - } - }, []) - }) - - // # Misc UI things - const firstStatus = computed(() => conversation.value[0]) - const lastStatus = computed( - () => conversation.value[conversation.value.legnth - 1], - ) - const getStatusClasses = (status, active) => ({ - '-first': status.id === firstStatus.value?.id, - '-last': status.id === lastStatus.value?.id, - }) - - // # Linear style stuff - const isLinearView = computed(() => displayStyle.value !== 'tree') - - // # Tree style stuff - const isTreeView = computed(() => displayStyle.value === 'tree') - - // ## Tree state - // ### Topology - const ancestors = computed(() => { - // First we fill map with empty sets and add given id's parent - // as set's only element (if any) - const parentMap = conversation.value.reduce( - (result, { id, in_reply_to_status_id: irid }) => { - if (!result.has(id)) { - result.set(id, new Set()) - } - if (irid) { - // Setting parent for current item - result.get(id).add(irid) - } - return result - }, - new Map(), + useStatusesStore().fetchFavsAndRepeats(id) + useStatusesStore().fetchEmojiReactions(id) + }, + toggleExpanded() { + this.expanded = !this.expanded + }, + getConversationId(statusId) { + const status = useStatusesStore().allStatuses.get(statusId) + return get( + status, + 'retweeted_status.statusnet_conversation_id', + get(status, 'statusnet_conversation_id'), ) - - // Next we iterate over each entry and fill the whole ancestry chain - parentMap.entries().forEach(([originId, originSet]) => { - let current = originSet.values().next().value - while (current) { - originSet.add(current) - - const parent = parentMap.get(current) ?? new Set() - - current = parent.values().next().value - } - }) - return parentMap - }) - const topLevel = computed(() => - [...ancestors.value.entries()] - .filter(([id, ancestors]) => ancestors.size === 0) - .map(([id]) => getStatusObject(id)), - ) - const getAncestorIds = (id) => ancestors.value.get(id) ?? new Set() - const getAncestors = (id) => - [...getAncestorIds(id)].map(getStatusObject).filter(Boolean) - const currentAncestors = computed(() => - getAncestors(focusedId.value).reverse(), - ) - const currentDepth = computed(() => currentAncestors.value.length) - - // ### Thread Display - const threadDisplay = ref(new Map()) // id => 'showing' | 'hidden' - const threadDisplayDefault = computed(() => { - return conversation.value.reduce((map, status) => { - const { id } = status - const depth = ancestors.value.get(id).size - - const state = (() => { - if (depth - currentDepth.value <= maxDepthToShowByDefault.value) { - return 'showing' - } else { - return 'hidden' - } - })() - - map.set(id, state) - return map - }, new Map()) - }) - provide('threadDisplay', threadDisplay) - provide('threadDisplayDefault', threadDisplayDefault) - - const setThreadDisplayRecursively = (id, value) => { - threadDisplay.value.set(id, value) - ;[...getReplies(id)] + }, + setThreadDisplay(id, nextStatus) { + this.threadDisplayStatusObject = { + ...this.threadDisplayStatusObject, + [id]: nextStatus, + } + }, + toggleThreadDisplay(id) { + const curStatus = this.threadDisplayStatus[id] + const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing' + this.setThreadDisplay(id, nextStatus) + }, + setThreadDisplayRecursively(id, nextStatus) { + this.setThreadDisplay(id, nextStatus) + this.getReplies(id) .map((k) => k.id) - .map((id) => setThreadDisplayRecursively(id, value)) - } - const showThreadRecursively = (id) => { - setThreadDisplayRecursively(id, 'showing') - } - - // ## Derived values and config - const treeViewIsSimple = computed( - () => !mergedConfig.value.conversationTreeAdvanced, - ) - const maxDepthToShowByDefault = computed(() => { - // maxDepthInThread = max number of depths that is *visible* - // since our depth starts with 0 and "showing" means "showing children" - // there is a -2 here - const maxDepth = mergedConfig.value.maxDepthInThread - 2 - return Math.min(1, maxDepth) - }) - const shouldShowAllConversationButton = computed( - () => currentAncestors.value.length > 0 && topLevel.value.length > 1, - ) - const shouldShowAncestors = computed( - () => isExpanded.value && ancestors.value.get(focusedId.value) != null, - ) - const shouldFadeAncestors = computed( - () => mergedConfig.value.conversationTreeFadeAncestors, - ) - const shouldShowOtherRepliesButton = computed( - () => mergedConfig.value.conversationOtherRepliesButton === 'below', - ) - - // # Scrolling - const tryScrollTo = (id) => { + .map((id) => this.setThreadDisplayRecursively(id, nextStatus)) + }, + showThreadRecursively(id) { + this.setThreadDisplayRecursively(id, 'showing') + }, + leastVisibleAncestor(id) { + let cur = id + let parent = this.parentOf(cur) + while (cur) { + // if the parent is showing it means cur is visible + if (this.threadDisplayStatus[parent] === 'showing') { + return cur + } + parent = this.parentOf(parent) + cur = this.parentOf(cur) + } + // nothing found, fall back to toplevel + return this.topLevel[0] ? this.topLevel[0].id : undefined + }, + diveIntoStatus(id) { + this.tryScrollTo(id) + }, + diveToTopLevel() { + this.tryScrollTo( + this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id, + ) + }, + // only used when we are not on a page + undive() { + this.inlineDivePosition = null + this.setFocused(this.statusId) + }, + tryScrollTo(id) { if (!id) { return } - if (isPage.value) { - router.push({ name: 'conversation', params: { statusId: id } }) + if (this.isPage) { + // set statusId + this.$router.push({ name: 'conversation', params: { id } }) + } else { + this.inlineDivePosition = id } // Because the conversation can be unmounted when out of sight // and mounted again when it comes into sight, @@ -548,69 +561,78 @@ export default { // different). // Here, let the components be rendered first, in order to trigger // the `focused` watcher. - nextTick(() => { - setFocused(id) + this.$nextTick(() => { + this.setFocused(id) }) - } - const diveIntoStatus = (id) => { - tryScrollTo(id) - } - const diveToTopLevel = () => { - tryScrollTo(currentAncestors.value[0].id) - } - - return { - // # Misc - loadStatusError, - mobileLayout, - - // # Focus - focused, - setFocused, - - // # Main things - status, - statusReplies, - getReplies, - conversation, - - // # Conversation Expansion - isPage, - isExpanded, - toggleExpanded, - - // # Virtual scrolling stuff - onStatusSuspendStateChange, - updateVirtualHeight, - - // # Misc UI things - getStatusClasses, - - // # Linear style stuff - isLinearView, - heightChartLinear, - - // # Tree style stuff - isTreeView, - - // ## Tree state - // ### Topology - topLevel, - currentAncestors, - - // ### Thread Display - showThreadRecursively, - - // ## Derived values and config - treeViewIsSimple, - shouldShowAllConversationButton, - shouldShowAncestors, - shouldFadeAncestors, - shouldShowOtherRepliesButton, - - // # Scrolling - diveToTopLevel, - diveIntoStatus, - } + }, + goToCurrent() { + this.tryScrollTo(this.diveRoot || this.topLevel[0].id) + }, + statusById(id) { + return this.statusMap[id] + }, + parentOf(id) { + const status = this.statusById(id) + if (!status) { + return undefined + } + const { in_reply_to_status_id: parentId } = status + if (!this.statusMap[parentId]) { + return undefined + } + return parentId + }, + parentOrSelf(id) { + return this.parentOf(id) || id + }, + // Ancestors of some status, from top to bottom + ancestorsOf(id) { + const ancestors = [] + let cur = this.parentOf(id) + while (cur) { + ancestors.unshift(this.statusMap[cur]) + cur = this.parentOf(cur) + } + return ancestors + }, + topLevelAncestorOrSelfId(id) { + let cur = id + let parent = this.parentOf(id) + while (parent) { + cur = this.parentOf(cur) + parent = this.parentOf(parent) + } + return cur + }, + resetDisplayState() { + this.undive() + this.threadDisplayStatusObject = {} + }, + onStatusSuspendStateChange({ id, suspend }) { + if (!suspend) { + this.unsuspendibleIds.add(id) + } else { + this.unsuspendibleIds.delete(id) + } + }, + onPosted(data) { + if (this.isPage) { + this.$router.push({ name: 'conversation', params: { id: data.id } }) + } + }, + updateVirtualHeight() { + if (this.hide) return // no updates when not rendering + if (!this.status) return // not loaded yet + this.$nextTick(() => { + this.virtualHeight = this.$refs.body.getBoundingClientRect().height + this.$emit('update:virtualHeight', { + id: this.status.id, + height: this.virtualHeight, + top: this.$el.clientTop, + }) + }) + }, }, } + +export default conversation diff --git a/src/components/conversation/conversation.scss b/src/components/conversation/conversation.scss index ae06dbfa7..99ecb338a 100644 --- a/src/components/conversation/conversation.scss +++ b/src/components/conversation/conversation.scss @@ -6,15 +6,6 @@ backdrop-filter: var(--__panel-backdrop-filter); } - .conversation-status:not(.-last) { - border-bottom: 1px solid var(--border); - } - - .conversation-status:not(.-last) - .conversation-status:not(.-first) { - border-radius: 0; - } - .conversation-dive-to-top-level-box { padding: var(--status-margin); border-bottom: 1px solid var(--border); @@ -58,12 +49,26 @@ padding: var(--status-margin); } + .conversation-status { + border-bottom: 1px solid var(--border); + border-radius: 0; + } + + .thread-ancestor-has-other-replies .conversation-status, + &:last-child:not(.-expanded) .conversation-status, + &.-expanded .conversation-status:last-child, + .thread-ancestor:last-child .conversation-status, + .thread-ancestor:last-child .thread-ancestor-dive-box, + &.-expanded .thread-tree .conversation-status { + border-bottom: none; + } + .thread-ancestors + .thread-tree > .conversation-status { border-top: 1px solid var(--border); } /* expanded conversation in timeline */ - &.-expanded .thread-body { + &.status-fadein.-expanded .thread-body { border-left: 4px solid var(--cRed); border-radius: var(--roundness); border-top-left-radius: 0; @@ -71,7 +76,7 @@ border-bottom: 1px solid var(--border); } - &.-expanded:not(.-page) { + &.-expanded.status-fadein { --___margin: calc(var(--status-margin) / 2); background: var(--background); diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 9b8d342c2..94c4db188 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -1,8 +1,8 @@ @@ -91,27 +91,36 @@ class="thread-ancestors" >
@@ -140,13 +149,32 @@
@@ -155,24 +183,22 @@ v-else-if="isLinearView" class="thread-body" > -
-
+
+
diff --git a/src/components/conversation/useScrollPosition.js b/src/components/conversation/useScrollPosition.js deleted file mode 100644 index 7772a35a4..000000000 --- a/src/components/conversation/useScrollPosition.js +++ /dev/null @@ -1,21 +0,0 @@ -import { onMounted, onUnmounted, ref } from 'vue' - -export function useScrollPosition() { - const x = ref(0) - const y = ref(0) - - const update = (e) => { - x.value = window.scrollX - y.value = window.scrollY - } - - onMounted(() => { - window.addEventListener('scroll', update) - update() - }) - onUnmounted(() => { - window.removeEventListener('scroll', update) - }) - - return { x, y } -} diff --git a/src/components/conversation/useWindowSize.js b/src/components/conversation/useWindowSize.js deleted file mode 100644 index 803983e9e..000000000 --- a/src/components/conversation/useWindowSize.js +++ /dev/null @@ -1,18 +0,0 @@ -import { onMounted, onUnmounted, ref } from 'vue' - -export function useWindowSize() { - const height = ref(0) - const width = ref(0) - - const update = () => { - height.value = window.innerHeight - width.value = window.innerWidth - } - - onMounted(() => window.addEventListener('resize', update)) - onUnmounted(() => window.removeEventListener('resize', update)) - - update() - - return { height, width } -} diff --git a/src/components/draft/draft.vue b/src/components/draft/draft.vue index 6c266fd27..a644eb6d7 100644 --- a/src/components/draft/draft.vue +++ b/src/components/draft/draft.vue @@ -13,7 +13,7 @@ diff --git a/src/components/status_action_buttons/buttons_definitions.js b/src/components/status_action_buttons/buttons_definitions.js index 28497f5f5..762b5be6a 100644 --- a/src/components/status_action_buttons/buttons_definitions.js +++ b/src/components/status_action_buttons/buttons_definitions.js @@ -234,7 +234,7 @@ export const BUTTONS = [ return chatView }, action({ router, status }) { - router.push({ name: 'conversation', params: { statusId: status.id } }) + router.push({ name: 'conversation', params: { id: status.id } }) }, }, { @@ -293,10 +293,8 @@ export const BUTTONS = [ navigator.clipboard.writeText( [ useInstanceStore().server, - router.resolve({ - name: 'conversation', - params: { statusId: status.id }, - }).href, + router.resolve({ name: 'conversation', params: { id: status.id } }) + .href, ].join(''), ) return Promise.resolve() diff --git a/src/components/status_history_modal/status_history_modal.vue b/src/components/status_history_modal/status_history_modal.vue index 5403844d3..ee5f77fd2 100644 --- a/src/components/status_history_modal/status_history_modal.vue +++ b/src/components/status_history_modal/status_history_modal.vue @@ -20,7 +20,7 @@ :key="status.id" :statusoid="status" :is-preview="true" - class="conversation-status panel-body" + class="conversation-status status-fadein panel-body" />
diff --git a/src/components/thread_tree/thread_tree.js b/src/components/thread_tree/thread_tree.js index 714b2b3f5..6bcfa469f 100644 --- a/src/components/thread_tree/thread_tree.js +++ b/src/components/thread_tree/thread_tree.js @@ -1,5 +1,3 @@ -import { useMergedConfigStore } from 'src/stores/merged_config.js' - import { library } from '@fortawesome/fontawesome-svg-core' import { faAngleDoubleDown, @@ -12,85 +10,33 @@ const ThreadTree = { components: {}, name: 'ThreadTree', props: { - statusId: String, depth: Number, + statusId: String, + inProfile: Boolean, + conversation: Array, + collapsable: Boolean, + isExpanded: Boolean, + pinnedStatusIdsObject: Object, + profileUserId: String, + + focused: String, + getReplies: Function, + toggleExpanded: Function, + + simple: Boolean, + canDive: Boolean, + threadDisplayStatus: Object, + showThreadRecursively: Function, + totalReplyCount: Object, + totalReplyDepth: Object, }, - emits: [ - 'suspendableStateChange', - 'goto', - 'dive', - 'toggleExpanded', - 'showThreadRecursively', - ], - inject: [ - 'conversation', - 'focused', - 'replies', - 'threadDisplay', - 'threadDisplayDefault', - 'isExpanded', - 'isPage', - ], + emits: ['suspendableStateChange', 'goto', 'dive', 'heightChange'], computed: { currentReplies() { - return [...this.getReplies(this.statusId)].map(({ id }) => id) - }, - simple() { - return !useMergedConfigStore().mergedConfig.conversationTreeAdvanced + return this.getReplies(this.statusId).map(({ id }) => id) }, threadShowing() { - const result = - this.threadDisplay.get(this.statusId) ?? - this.threadDisplayDefault.get(this.statusId) - return result === 'showing' - }, - canDive() { - return this.isExpanded - }, - totalReplyCount() { - const sizes = {} - const subTreeSizeFor = (id) => { - if (sizes[id]) { - return sizes[id] - } - sizes[id] = - 1 + - [...this.getReplies(id)] - .map(({ id }) => id) - .map((cid) => subTreeSizeFor(cid)) - .reduce((a, b) => a + b, 0) - return sizes[id] - } - this.conversation.map((k) => k.id).forEach(subTreeSizeFor) - return Object.keys(sizes).reduce((res, id) => { - res[id] = sizes[id] - 1 // exclude itself - return res - }, {}) - }, - totalReplyDepth() { - const depths = {} - const subTreeDepthFor = (id) => { - if (depths[id]) { - return depths[id] - } - depths[id] = - 1 + - [...this.getReplies(id)] - .map(({ id }) => id) - .map((cid) => subTreeDepthFor(cid)) - .reduce((a, b) => (a > b ? a : b), 0) - return depths[id] - } - this.conversation.map((k) => k.id).forEach(subTreeDepthFor) - return Object.keys(depths).reduce((res, id) => { - res[id] = depths[id] - 1 // exclude itself - return res - }, {}) - }, - }, - methods: { - getReplies(id) { - return this.replies.get(id) ?? new Set() + return this.threadDisplayStatus[this.statusId] === 'showing' }, }, } diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue index 8d8f0af33..be95f8698 100644 --- a/src/components/thread_tree/thread_tree.vue +++ b/src/components/thread_tree/thread_tree.vue @@ -2,17 +2,27 @@