diff --git a/components/timeline/TimelineHome.vue b/components/timeline/TimelineHome.vue
index 7a0c77ae..baf4a13b 100644
--- a/components/timeline/TimelineHome.vue
+++ b/components/timeline/TimelineHome.vue
@@ -8,6 +8,6 @@ onBeforeUnmount(() => stream?.then(s => s.disconnect()))
diff --git a/composables/masto.ts b/composables/masto.ts
index 0d0946d2..b79fdb1a 100644
--- a/composables/masto.ts
+++ b/composables/masto.ts
@@ -158,20 +158,3 @@ async function fetchRelationships() {
for (let i = 0; i < requested.length; i++)
requested[i][1].value = relationships[i]
}
-
-const maxDistance = 10
-export function timelineWithReorderedReplies(items: Status[]) {
- const newItems = [...items]
- // TODO: Basic reordering, we should get something more efficient and robust
- for (let i = items.length - 1; i > 0; i--) {
- for (let k = 1; k <= maxDistance && i - k >= 0; k++) {
- const inReplyToId = newItems[i - k].inReplyToId ?? newItems[i - k].reblog?.inReplyToId
- if (inReplyToId && (inReplyToId === newItems[i].reblog?.id || inReplyToId === newItems[i].id)) {
- const item = newItems.splice(i, 1)[0]
- newItems.splice(i - k, 0, item)
- k = 1
- }
- }
- }
- return newItems
-}
diff --git a/composables/timeline.ts b/composables/timeline.ts
new file mode 100644
index 00000000..d9358f25
--- /dev/null
+++ b/composables/timeline.ts
@@ -0,0 +1,50 @@
+import type { Status } from 'masto'
+
+const maxDistance = 10
+const maxSteps = 1000
+
+// Checks if (b) is a reply to (a)
+function areStatusesConsecutive(a: Status, b: Status) {
+ const inReplyToId = b.inReplyToId ?? b.reblog?.inReplyToId
+ return !!inReplyToId && (inReplyToId === a.reblog?.id || inReplyToId === a.id)
+}
+
+export function reorderedTimeline(items: Status[]) {
+ let steps = 0
+ const newItems = [...items]
+ for (let i = items.length - 1; i > 0; i--) {
+ for (let k = 1; k <= maxDistance && i - k >= 0; k++) {
+ // Prevent infinite loops
+ steps++
+ if (steps > maxSteps)
+ return newItems
+
+ // Check if the [i-k] item is a reply to the [i] item
+ // This means that they are in the wrong order
+
+ if (areStatusesConsecutive(newItems[i], newItems[i - k])) {
+ const item = newItems.splice(i, 1)[0]
+ newItems.splice(i - k, 0, item) // insert older item before the newer one
+ k = 0
+ }
+ else if (k > 1) {
+ // Check if the [i] item is a reply to the [i-k] item
+ // This means that they are in the correct order but there are posts between them
+ if (areStatusesConsecutive(newItems[i - k], newItems[i])) {
+ // If the next statuses are already ordered, move them all
+ let j = i
+ for (; j < items.length - 1; j++) {
+ if (!areStatusesConsecutive(newItems[j], newItems[j + 1]))
+ break
+ }
+ const orderedCount = j - i + 1
+ const itemsToMove = newItems.splice(i, orderedCount)
+ // insert older item after the newer one
+ newItems.splice(i - k + 1, 0, ...itemsToMove)
+ k = 0
+ }
+ }
+ }
+ }
+ return newItems
+}
diff --git a/pages/[[server]]/@[account]/index/index.vue b/pages/[[server]]/@[account]/index/index.vue
index e01254f5..b477af42 100644
--- a/pages/[[server]]/@[account]/index/index.vue
+++ b/pages/[[server]]/@[account]/index/index.vue
@@ -23,6 +23,6 @@ if (account) {
diff --git a/pages/[[server]]/@[account]/index/media.vue b/pages/[[server]]/@[account]/index/media.vue
index 16ec2024..4dce4f66 100644
--- a/pages/[[server]]/@[account]/index/media.vue
+++ b/pages/[[server]]/@[account]/index/media.vue
@@ -21,6 +21,6 @@ if (account) {
diff --git a/pages/[[server]]/@[account]/index/with_replies.vue b/pages/[[server]]/@[account]/index/with_replies.vue
index c0f4cac6..b4f48026 100644
--- a/pages/[[server]]/@[account]/index/with_replies.vue
+++ b/pages/[[server]]/@[account]/index/with_replies.vue
@@ -21,6 +21,6 @@ if (account) {
diff --git a/tests/reorder-timeline.test.ts b/tests/reorder-timeline.test.ts
new file mode 100644
index 00000000..747e3800
--- /dev/null
+++ b/tests/reorder-timeline.test.ts
@@ -0,0 +1,65 @@
+/**
+ * @vitest-environment jsdom
+ */
+import type { Status } from 'masto'
+import { describe, expect, it } from 'vitest'
+import { reorderedTimeline } from '~/composables/timeline'
+
+function status(id: string): Status {
+ return { id } as Status
+}
+function reply(id: string, s: Status) {
+ return { id, inReplyToId: s.id } as Status
+}
+function reblog(id: string, s: Status) {
+ return { id, reblog: s } as Status
+}
+
+const p_a1 = status('p_a1')
+const p_b1 = status('p_b1')
+
+const p_a2 = reply('p_a2', p_a1)
+const p_b2 = reply('p_b2', p_b1)
+
+const p_a3 = reply('p_a3', p_a2)
+const p_b3 = reply('p_b3', p_b2)
+
+const r_a1 = reblog('r_a1', p_a1)
+const r_b1 = reblog('r_b1', p_b1)
+
+const r_a2 = reblog('r_a2', p_a2)
+const r_b2 = reblog('r_b2', p_b2)
+
+describe('timeline reordering', () => {
+ it('reorder basic', () => {
+ expect(reorderedTimeline([p_a1, p_a2, p_a3]))
+ .toMatchInlineSnapshot([p_a1, p_a2, p_a3])
+
+ expect(reorderedTimeline([p_a3, p_a2, p_a1]))
+ .toMatchInlineSnapshot([p_a1, p_a2, p_a3])
+
+ expect(reorderedTimeline([p_a2, p_a3, p_a1]))
+ .toMatchInlineSnapshot([p_a1, p_a2, p_a3])
+
+ expect(reorderedTimeline([p_a2, p_b3, p_a3, p_b1, p_a1, p_b2]))
+ .toMatchInlineSnapshot([p_a1, p_a2, p_a3, p_b1, p_b2, p_b3])
+
+ expect(reorderedTimeline([r_a2, p_a1]))
+ .toMatchInlineSnapshot([p_a1, r_a2])
+
+ expect(reorderedTimeline([r_a2, p_b3, p_a3, p_b1, p_a1, r_b2]))
+ .toMatchInlineSnapshot([p_a1, r_a2, p_a3, p_b1, r_b2, p_b3])
+
+ expect(reorderedTimeline([r_a2, p_b3, p_a3, p_b1, p_a1, r_b2]))
+ .toMatchInlineSnapshot([p_a1, r_a2, p_a3, p_b1, r_b2, p_b3])
+
+ expect(reorderedTimeline([p_a1, p_b1, p_a2, p_b2, p_a3, p_b3]))
+ .toMatchInlineSnapshot([p_a1, p_a2, p_a3, p_b1, p_b2, p_b3])
+
+ expect(reorderedTimeline([r_a2, r_a1]))
+ .toMatchInlineSnapshot([r_a1, r_a2])
+
+ expect(reorderedTimeline([p_a3, r_a1, r_a2, r_b2, p_b3, r_b1]))
+ .toMatchInlineSnapshot([r_a1, r_a2, p_a3, r_b1, r_b2, p_b3])
+ })
+})