File a comment under the content item it answers rather than the room it carries
This commit is contained in:
parent
c170058508
commit
334e4f8353
5 changed files with 105 additions and 42 deletions
|
|
@ -1729,6 +1729,18 @@ Acceptance:
|
||||||
- A thread bob starts in the same room raises none, however much of it reaches
|
- A thread bob starts in the same room raises none, however much of it reaches
|
||||||
her client.
|
her client.
|
||||||
|
|
||||||
|
### US-137 — Reach a thread from the inbox
|
||||||
|
|
||||||
|
As alice, I want an unread reply to a thread to lead me to the thread, so that
|
||||||
|
the inbox never drops me in a room chat to go looking for it.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- A comment bob writes on a thread in a room alice belongs to leaves the room's
|
||||||
|
inbox row showing the room's own last message.
|
||||||
|
- The same comment counts toward the space's unread thread activity, and the
|
||||||
|
thread it answers carries the unread dot on the threads board.
|
||||||
|
|
||||||
### US-105 — Land on the home page
|
### US-105 — Land on the home page
|
||||||
|
|
||||||
As a new user, I want the home page to route me somewhere useful, so that I'm
|
As a new user, I want the home page to route me somewhere useful, so that I'm
|
||||||
|
|
|
||||||
|
|
@ -702,6 +702,66 @@ test("US-112 see which threads are unread", async ({seed, as}) => {
|
||||||
await expect(unreadDot(threadsNav)).toHaveCount(0)
|
await expect(unreadDot(threadsNav)).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("US-137 reach a thread from the inbox activity its comment raised", async ({seed, as}) => {
|
||||||
|
const scenario = await seed(({relay, user, at}) => {
|
||||||
|
const space = relay("space")
|
||||||
|
|
||||||
|
space.room("general", {name: "General"})
|
||||||
|
space.join(user.alice, "general")
|
||||||
|
space.join(user.bob, "general")
|
||||||
|
space.message(user.bob, "general", "the server is on fire", at(3, HOUR))
|
||||||
|
|
||||||
|
const topic = seedThread(space, user.bob, "where is the sextant", at(2, HOUR))
|
||||||
|
|
||||||
|
space.event(
|
||||||
|
user.bob,
|
||||||
|
() =>
|
||||||
|
space
|
||||||
|
.kind(Comment)
|
||||||
|
.writer()
|
||||||
|
// publishComment carries the root's room forward, so a comment on a thread is tagged h too.
|
||||||
|
.setRoom(space.url, "general")
|
||||||
|
.setRootFromEvent(topic.event)
|
||||||
|
.setParentFromEvent(topic.event)
|
||||||
|
.setContent("starboard locker, under the charts")
|
||||||
|
.renderTemplate(),
|
||||||
|
at(1, HOUR),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const space = scenario.space("space")
|
||||||
|
const alice = await as(users.alice, "/home")
|
||||||
|
|
||||||
|
// The room's row is its own chat, whatever the newest thing tagged for the room happens to be
|
||||||
|
const conversation = alice.getByRole("link").filter({hasText: "the server is on fire"})
|
||||||
|
|
||||||
|
await expect(conversation).toBeVisible()
|
||||||
|
await expect(conversation).toContainText("General")
|
||||||
|
await expect(alice.getByRole("link").filter({hasText: "starboard locker"})).toHaveCount(0)
|
||||||
|
|
||||||
|
// The comment is activity on the thread, so the space's thread count is what it raises
|
||||||
|
await expect(alice.getByRole("link").filter({hasText: "1 thread"})).toBeVisible()
|
||||||
|
|
||||||
|
await conversation.click()
|
||||||
|
|
||||||
|
await expect(alice).toHaveURL(pathPattern(roomPath(space.url, "general")))
|
||||||
|
|
||||||
|
await contentNavItem(alice, "Threads").click()
|
||||||
|
|
||||||
|
const topic = alice.getByRole("row").filter({hasText: "where is the sextant"})
|
||||||
|
|
||||||
|
await expect(topic).toBeVisible()
|
||||||
|
|
||||||
|
// syncChecked marks the page read 300ms later and latestActivityByPath is throttled to a second.
|
||||||
|
await alice.waitForTimeout(1500)
|
||||||
|
|
||||||
|
await expect(unreadDot(topic)).toBeVisible()
|
||||||
|
|
||||||
|
await topic.click()
|
||||||
|
|
||||||
|
await expect(alice.getByText("starboard locker, under the charts")).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
const seedPoll = (space: SeededSpace, user: TestUser, title: string, createdAt: number) =>
|
const seedPoll = (space: SeededSpace, user: TestUser, title: string, createdAt: number) =>
|
||||||
space.event(
|
space.event(
|
||||||
user,
|
user,
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,21 @@ export const makeDeleteFilter = (kinds: number[], extra: Filter = {}) => ({
|
||||||
...extra,
|
...extra,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// What an event is activity on: the root a comment sits under, or the event itself.
|
||||||
|
export const getActivityTarget = (event: TrustedEvent) => {
|
||||||
|
if (event.kind === COMMENT) {
|
||||||
|
const {kind, id, address} = reader(Comment)(event).root()
|
||||||
|
const rootKind = parseInt(kind || "")
|
||||||
|
const idOrAddress = address || id
|
||||||
|
|
||||||
|
if (rootKind && idOrAddress) {
|
||||||
|
return {kind: rootKind, idOrAddress}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {kind: event.kind, idOrAddress: getIdOrAddress(event)}
|
||||||
|
}
|
||||||
|
|
||||||
// An event is active as of its own creation or the newest comment on it, keyed by address when addressable.
|
// An event is active as of its own creation or the newest comment on it, keyed by address when addressable.
|
||||||
export const partitionByActivity = (kind: number, events: TrustedEvent[]) => {
|
export const partitionByActivity = (kind: number, events: TrustedEvent[]) => {
|
||||||
const [items, comments] = partition(spec({kind}), events)
|
const [items, comments] = partition(spec({kind}), events)
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,18 @@ import {
|
||||||
sortBy,
|
sortBy,
|
||||||
throttle,
|
throttle,
|
||||||
parseJson,
|
parseJson,
|
||||||
|
spec,
|
||||||
gt,
|
gt,
|
||||||
int,
|
int,
|
||||||
HOUR,
|
HOUR,
|
||||||
} from "@welshman/lib"
|
} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getIdOrAddress, sortEventsDesc, tagSpec, tagValue, COMMENT, MESSAGE} from "@welshman/util"
|
import {sortEventsDesc, tagSpec, tagValue, MESSAGE} from "@welshman/util"
|
||||||
import {Comment} from "@welshman/domain"
|
|
||||||
import {synced, throttled, withGetter} from "@welshman/store"
|
import {synced, throttled, withGetter} from "@welshman/store"
|
||||||
import {Events, Relays} from "@welshman/app"
|
import {Events, Relays} from "@welshman/app"
|
||||||
import {app, fromApp, reader} from "@app/core"
|
import {app, fromApp} from "@app/core"
|
||||||
import {makeRoomPath, makeSpaceChatPath, makeChatPath, makeContentPath} from "@app/routes"
|
import {makeRoomPath, makeSpaceChatPath, makeChatPath, makeContentPath} from "@app/routes"
|
||||||
import {CONTENT_KINDS, makeCommentFilter} from "@app/content"
|
import {CONTENT_KINDS, getActivityTarget, makeCommentFilter} from "@app/content"
|
||||||
import {getIsMuted, userSettingsValues} from "@app/settings"
|
import {getIsMuted, userSettingsValues} from "@app/settings"
|
||||||
import {Chats} from "@app/chats"
|
import {Chats} from "@app/chats"
|
||||||
import {dufflepud, DUFFLEPUD_URL} from "@app/env"
|
import {dufflepud, DUFFLEPUD_URL} from "@app/env"
|
||||||
|
|
@ -159,20 +159,12 @@ export const syncCheckedRemote = () => {
|
||||||
|
|
||||||
// Derived notifications state
|
// Derived notifications state
|
||||||
|
|
||||||
// The content item an event belongs to - either the content event itself, or the root of a comment
|
// The content item an event belongs to, when it belongs to one at all.
|
||||||
const getContentTarget = (event: TrustedEvent) => {
|
const getContentTarget = (event: TrustedEvent) => {
|
||||||
if (CONTENT_KINDS.includes(event.kind)) {
|
const target = getActivityTarget(event)
|
||||||
return {kind: event.kind, idOrAddress: getIdOrAddress(event)}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.kind === COMMENT) {
|
if (CONTENT_KINDS.includes(target.kind)) {
|
||||||
const root = reader(Comment)(event).root()
|
return target
|
||||||
const kind = parseInt(root.kind || "")
|
|
||||||
const idOrAddress = root.address || root.id
|
|
||||||
|
|
||||||
if (CONTENT_KINDS.includes(kind) && idOrAddress) {
|
|
||||||
return {kind, idOrAddress}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,9 +231,11 @@ export const latestActivityByPath = derived(
|
||||||
|
|
||||||
for (const url of $activeSpaceUrls) {
|
for (const url of $activeSpaceUrls) {
|
||||||
const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values())
|
const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values())
|
||||||
|
// A chat is its own messages; content and its comments carry the room h but belong to the item.
|
||||||
|
const messages = events.filter(spec({kind: MESSAGE}))
|
||||||
|
|
||||||
if (spaceSupportsRooms(url)) {
|
if (spaceSupportsRooms(url)) {
|
||||||
for (const [h, [event]] of groupBy(e => tagValue(tagSpec("h"), e.tags), events)) {
|
for (const [h, [event]] of groupBy(e => tagValue(tagSpec("h"), e.tags), messages)) {
|
||||||
// A muted room is left out entirely, so it can't light up its own badge or the space's
|
// A muted room is left out entirely, so it can't light up its own badge or the space's
|
||||||
if (h && !getIsMuted($settings, url, h)) {
|
if (h && !getIsMuted($settings, url, h)) {
|
||||||
const path = makeRoomPath(url, h)
|
const path = makeRoomPath(url, h)
|
||||||
|
|
@ -250,7 +244,7 @@ export const latestActivityByPath = derived(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const event = first(events)
|
const event = first(messages)
|
||||||
|
|
||||||
if (event) {
|
if (event) {
|
||||||
const path = makeSpaceChatPath(url)
|
const path = makeSpaceChatPath(url)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import type {Maybe} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
CLASSIFIED,
|
CLASSIFIED,
|
||||||
COMMENT,
|
|
||||||
EVENT_TIME,
|
EVENT_TIME,
|
||||||
LONG_FORM,
|
LONG_FORM,
|
||||||
MESSAGE,
|
MESSAGE,
|
||||||
|
|
@ -15,18 +14,16 @@ import {
|
||||||
POLL,
|
POLL,
|
||||||
THREAD,
|
THREAD,
|
||||||
ZAP_GOAL,
|
ZAP_GOAL,
|
||||||
getIdOrAddress,
|
|
||||||
hexTags,
|
hexTags,
|
||||||
tagSpec,
|
tagSpec,
|
||||||
tagValue,
|
tagValue,
|
||||||
tagValues,
|
tagValues,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import {Comment} from "@welshman/domain"
|
import {app, messagingRelayLists, user} from "@app/core"
|
||||||
import {app, messagingRelayLists, reader, user} from "@app/core"
|
|
||||||
import {makeChatId} from "@app/chats"
|
import {makeChatId} from "@app/chats"
|
||||||
import {entityLink, PLATFORM_URL, PLATFORM_RELAYS} from "@app/env"
|
import {entityLink, PLATFORM_URL, PLATFORM_RELAYS} from "@app/env"
|
||||||
import {decodeRelay, encodeRelay} from "@app/relays"
|
import {decodeRelay, encodeRelay} from "@app/relays"
|
||||||
import {DM_KINDS} from "@app/content"
|
import {DM_KINDS, getActivityTarget} from "@app/content"
|
||||||
import {navigate, pushModal} from "@app/modal"
|
import {navigate, pushModal} from "@app/modal"
|
||||||
import type {NavigateOptions} from "@app/modal"
|
import type {NavigateOptions} from "@app/modal"
|
||||||
import type {NotePointer} from "@app/social"
|
import type {NotePointer} from "@app/social"
|
||||||
|
|
@ -212,33 +209,18 @@ export const makeEventPath = (event: TrustedEvent, urls: string[]): Maybe<string
|
||||||
|
|
||||||
if (urls.length > 0) {
|
if (urls.length > 0) {
|
||||||
const url = urls[0]
|
const url = urls[0]
|
||||||
|
const {kind, idOrAddress} = getActivityTarget(event)
|
||||||
|
|
||||||
if (event.kind === MESSAGE) {
|
// A comment inherits the h of the message it answers, so both anchor the same transcript.
|
||||||
|
if (kind === MESSAGE) {
|
||||||
return makeMessagePath(url, event)
|
return makeMessagePath(url, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = makeContentPath(url, event.kind, getIdOrAddress(event))
|
const path = makeContentPath(url, kind, idOrAddress)
|
||||||
|
|
||||||
if (path) {
|
if (path) {
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.kind === COMMENT) {
|
|
||||||
const root = reader(Comment)(event).root()
|
|
||||||
const rootIdOrAddress = root.address ?? root.id
|
|
||||||
|
|
||||||
if (root.kind && rootIdOrAddress) {
|
|
||||||
if (parseInt(root.kind) === MESSAGE) {
|
|
||||||
return makeMessagePath(url, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootPath = makeContentPath(url, parseInt(root.kind), rootIdOrAddress)
|
|
||||||
|
|
||||||
if (rootPath) {
|
|
||||||
return rootPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A note belongs to no space, so its path carries the relays it was found on.
|
// A note belongs to no space, so its path carries the relays it was found on.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue