diff --git a/package.json b/package.json index 674828e26..29ef7bf5a 100644 --- a/package.json +++ b/package.json @@ -67,8 +67,6 @@ "@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 04ad81000..5c911d516 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -83,8 +83,9 @@ export default () => { }, { name: 'conversation', - path: '/notice/:id', + path: '/notice/:statusId', component: ConversationPage, + props: true, meta: { dontScroll: true }, }, { diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 7f0686491..919017387 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -1,7 +1,20 @@ -import { get, reduce } from 'lodash-es' -import { mapState } from 'pinia' +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 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' @@ -14,7 +27,10 @@ import { useOAuthStore } from 'src/stores/oauth.js' import { useStatusesStore } from 'src/stores/statuses.js' import { useStreamingStore } from 'src/stores/streaming.js' -import { fetchConversation, fetchStatus } from 'src/api/public.js' +import { + fetchConversation as apiFetchConversation, + fetchStatus as apiFetchStatus, +} from 'src/api/public.js' import { WSConnectionStatus } from 'src/api/websocket.js' import { library } from '@fortawesome/fontawesome-svg-core' @@ -34,372 +50,19 @@ library.add( faTimes, ) -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 = { +export default { 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, @@ -409,142 +72,466 @@ const conversation = { PostStatusForm, RichContent, }, - watch: { - statusId(newVal, oldVal) { - const newConversationId = this.getConversationId(newVal) - const oldConversationId = this.getConversationId(oldVal) - if ( - newConversationId && - oldConversationId && - newConversationId === oldConversationId - ) { - this.setFocused(this.originalStatusId) - } else { - this.fetchConversation() - } - }, - expanded(value) { - if (value) { - this.fetchConversation() - } else { - this.resetDisplayState() - } - }, - 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 { - 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 + setup(props) { + const { statusId } = toRefs(props) - if (!this.streamingEnabled) { + 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) - }, - toggleExpanded() { - this.expanded = !this.expanded - }, - getConversationId(statusId) { - const status = useStatusesStore().allStatuses.get(statusId) + } + 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'), ) - }, - 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) => 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 + } + 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 } - 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, + } + const resetDisplayState = () => { + setFocused(statusId.value) + threadDisplay.value = new Map() + } + watch(statusId, (newVal, oldVal) => { + const newConversationId = getConversationId(newVal) + const oldConversationId = getConversationId(oldVal) + if ( + newConversationId && + oldConversationId && + newConversationId === oldConversationId + ) { + setFocused(newVal) + } else { + resetDisplayState() + 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, ) - }, - // only used when we are not on a page - undive() { - this.inlineDivePosition = null - this.setFocused(this.statusId) - }, - tryScrollTo(id) { + + 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) => { + if (value) { + fetchConversation() + } else { + 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) + } else { + unsuspendibleIds.value.delete(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 } + }) + + // 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(), + ) + + // 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)] + .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) => { if (!id) { return } - if (this.isPage) { - // set statusId - this.$router.push({ name: 'conversation', params: { id } }) - } else { - this.inlineDivePosition = id + if (isPage.value) { + router.push({ name: 'conversation', params: { statusId: id } }) } // Because the conversation can be unmounted when out of sight // and mounted again when it comes into sight, @@ -561,78 +548,69 @@ const conversation = { // different). // Here, let the components be rendered first, in order to trigger // the `focused` watcher. - this.$nextTick(() => { - this.setFocused(id) + nextTick(() => { + setFocused(id) }) - }, - 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, - }) - }) - }, + } + 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, + } }, } - -export default conversation diff --git a/src/components/conversation/conversation.scss b/src/components/conversation/conversation.scss index 99ecb338a..ae06dbfa7 100644 --- a/src/components/conversation/conversation.scss +++ b/src/components/conversation/conversation.scss @@ -6,6 +6,15 @@ 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); @@ -49,26 +58,12 @@ 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 */ - &.status-fadein.-expanded .thread-body { + &.-expanded .thread-body { border-left: 4px solid var(--cRed); border-radius: var(--roundness); border-top-left-radius: 0; @@ -76,7 +71,7 @@ border-bottom: 1px solid var(--border); } - &.-expanded.status-fadein { + &.-expanded:not(.-page) { --___margin: calc(var(--status-margin) / 2); background: var(--background); diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 94c4db188..9b8d342c2 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -1,8 +1,8 @@ @@ -91,36 +91,27 @@ class="thread-ancestors" >
@@ -149,32 +140,13 @@
@@ -183,22 +155,24 @@ v-else-if="isLinearView" class="thread-body" > -
+
+
-
diff --git a/src/components/conversation/useScrollPosition.js b/src/components/conversation/useScrollPosition.js new file mode 100644 index 000000000..7772a35a4 --- /dev/null +++ b/src/components/conversation/useScrollPosition.js @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..803983e9e --- /dev/null +++ b/src/components/conversation/useWindowSize.js @@ -0,0 +1,18 @@ +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 a644eb6d7..6c266fd27 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 762b5be6a..28497f5f5 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: { id: status.id } }) + router.push({ name: 'conversation', params: { statusId: status.id } }) }, }, { @@ -293,8 +293,10 @@ export const BUTTONS = [ navigator.clipboard.writeText( [ useInstanceStore().server, - router.resolve({ name: 'conversation', params: { id: status.id } }) - .href, + router.resolve({ + name: 'conversation', + params: { statusId: 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 ee5f77fd2..5403844d3 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 status-fadein panel-body" + class="conversation-status panel-body" />
diff --git a/src/components/thread_tree/thread_tree.js b/src/components/thread_tree/thread_tree.js index 6bcfa469f..714b2b3f5 100644 --- a/src/components/thread_tree/thread_tree.js +++ b/src/components/thread_tree/thread_tree.js @@ -1,3 +1,5 @@ +import { useMergedConfigStore } from 'src/stores/merged_config.js' + import { library } from '@fortawesome/fontawesome-svg-core' import { faAngleDoubleDown, @@ -10,33 +12,85 @@ const ThreadTree = { components: {}, name: 'ThreadTree', props: { - 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, + depth: Number, }, - emits: ['suspendableStateChange', 'goto', 'dive', 'heightChange'], + emits: [ + 'suspendableStateChange', + 'goto', + 'dive', + 'toggleExpanded', + 'showThreadRecursively', + ], + inject: [ + 'conversation', + 'focused', + 'replies', + 'threadDisplay', + 'threadDisplayDefault', + 'isExpanded', + 'isPage', + ], computed: { currentReplies() { - return this.getReplies(this.statusId).map(({ id }) => id) + return [...this.getReplies(this.statusId)].map(({ id }) => id) + }, + simple() { + return !useMergedConfigStore().mergedConfig.conversationTreeAdvanced }, threadShowing() { - return this.threadDisplayStatus[this.statusId] === 'showing' + 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() }, }, } diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue index be95f8698..8d8f0af33 100644 --- a/src/components/thread_tree/thread_tree.vue +++ b/src/components/thread_tree/thread_tree.vue @@ -2,27 +2,17 @@