Show which item raised an unread dot on every content board (#419)

Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
This commit is contained in:
Coracle-Bot 2026-09-03 20:13:21 +00:00 committed by hodlbod
parent 4d7b052724
commit bec000b05c
25 changed files with 142 additions and 89 deletions

View file

@ -1577,6 +1577,18 @@ Acceptance:
- The thread list below the md breakpoint is a list of links rather than a - The thread list below the md breakpoint is a list of links rather than a
table, and alice's thread carries a dot there; bob's own does not. table, and alice's thread carries a dot there; bob's own does not.
### US-114 — See which listings are unread
As bob, I want the same dot on a board whose items are cards rather than rows,
so that every content section answers "which one is new" the same way.
Acceptance:
- A listing alice posted raises an unread dot on the Classifieds nav item; one
bob posted himself does not.
- Opening the list keeps the dot on the corner of alice's card, and bob's own
card carries none.
## Out of scope ## Out of scope
Features the e2e suite cannot exercise, and what stops it. Features the e2e suite cannot exercise, and what stops it.

View file

@ -1,7 +1,7 @@
import {neventEncode, npubEncode} from "nostr-tools/nip19" import {neventEncode, npubEncode} from "nostr-tools/nip19"
import {HOUR, MINUTE} from "@welshman/lib" import {HOUR, MINUTE} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {MessagingRelayList, RelayList, Thread} from "@welshman/domain" import {Classified, MessagingRelayList, RelayList, Thread} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test" import type {Locator, Page} from "@playwright/test"
import {expect, roomPath, spacePath, test, users} from "../harness" import {expect, roomPath, spacePath, test, users} from "../harness"
import type {SeededSpace, TestUser} from "../harness" import type {SeededSpace, TestUser} from "../harness"
@ -481,6 +481,22 @@ test("US-110 see another space's unread activity from a phone", async ({seed, as
const contentNavItem = (page: Page, name: string) => const contentNavItem = (page: Page, name: string) =>
page.locator(".secondary-nav").getByRole("link", {name}) page.locator(".secondary-nav").getByRole("link", {name})
const seedClassified = (space: SeededSpace, user: TestUser, title: string, createdAt: number) =>
space.event(
user,
() =>
space
.kind(Classified)
.writer()
.setRoom(space.url, "general")
.setIdentifier()
.setTitle(title)
.setPrice(100)
.setContent("in good condition")
.renderTemplate(),
createdAt,
)
const seedThread = (space: SeededSpace, user: TestUser, title: string, createdAt: number) => const seedThread = (space: SeededSpace, user: TestUser, title: string, createdAt: number) =>
space.event( space.event(
user, user,
@ -579,3 +595,39 @@ test("US-113 see which threads are unread on a phone", async ({seed, as}) => {
await expect(unreadDot(hers)).toBeVisible() await expect(unreadDot(hers)).toBeVisible()
await expect(unreadDot(his)).toHaveCount(0) await expect(unreadDot(his)).toHaveCount(0)
}) })
// Classifieds stands in for the five boards whose items are cards rather than rows — they all
// render the same UnreadDot off the same content path, and only the corner it sits in differs.
test("US-114 see which listings are unread", 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")
seedClassified(space, user.bob, "vintage sextant", at(3, HOUR))
seedClassified(space, user.alice, "brass astrolabe", at(2, HOUR))
})
const space = scenario.space("space")
const bob = await as(users.bob, roomPath(space.url, "general"))
const classifiedsNav = contentNavItem(bob, "Classifieds")
await expect(classifiedsNav).toBeVisible()
await expect(unreadDot(classifiedsNav)).toBeVisible()
await classifiedsNav.click()
const hers = bob.getByRole("link").filter({hasText: "brass astrolabe"})
const his = bob.getByRole("link").filter({hasText: "vintage sextant"})
await expect(his).toBeVisible()
// Same tick and throttle as US-112.
await bob.waitForTimeout(1500)
await expect(unreadDot(hers)).toBeVisible()
await expect(unreadDot(his)).toHaveCount(0)
})

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import {uniq} from "@welshman/lib" import {uniq} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {getAddress} from "@welshman/util"
import {Article} from "@welshman/domain" import {Article} from "@welshman/domain"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
@ -14,7 +13,7 @@
import EventActionBar from "@app/components/EventActionBar.svelte" import EventActionBar from "@app/components/EventActionBar.svelte"
import {reader} from "@app/core" import {reader} from "@app/core"
import {deriveIsDeleted} from "@app/repository" import {deriveIsDeleted} from "@app/repository"
import {makeArticlePath, makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
type Props = { type Props = {
url: string url: string
@ -31,7 +30,6 @@
const article = $derived(reader(Article)(event)) const article = $derived(reader(Article)(event))
const h = $derived(article.room()) const h = $derived(article.room())
const topics = $derived(article.topics()) const topics = $derived(article.topics())
const path = $derived(makeArticlePath(url, getAddress(event)))
const deleted = $derived(deriveIsDeleted(event)) const deleted = $derived(deriveIsDeleted(event))
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h}) const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
@ -54,7 +52,7 @@
<EventActionBar {url} {event} noun="Article"> <EventActionBar {url} {event} noun="Article">
{#snippet leading()} {#snippet leading()}
{#if showActivity} {#if showActivity}
<EventActivity {path} {event} {context} size="sm" hideLastActive /> <EventActivity {event} {context} size="sm" hideLastActive />
{/if} {/if}
{/snippet} {/snippet}
</EventActionBar> </EventActionBar>
@ -84,7 +82,7 @@
{createReaction} {createReaction}
reactionClass="tip-left" /> reactionClass="tip-left" />
{#if showActivity} {#if showActivity}
<EventActivity {path} {event} {context} /> <EventActivity {event} {context} />
{/if} {/if}
</ThunkStatusOrDeleted> </ThunkStatusOrDeleted>
</div> </div>

View file

@ -7,6 +7,7 @@
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import ArticleActions from "@app/components/ArticleActions.svelte" import ArticleActions from "@app/components/ArticleActions.svelte"
import UnreadDot from "@app/components/UnreadDot.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import {reader} from "@app/core" import {reader} from "@app/core"
import {makeArticlePath} from "@app/routes" import {makeArticlePath} from "@app/routes"
@ -19,6 +20,7 @@
const {url, event, context}: Props = $props() const {url, event, context}: Props = $props()
const path = $derived(makeArticlePath(url, getAddress(event)))
const article = $derived(reader(Article)(event)) const article = $derived(reader(Article)(event))
const title = $derived(article.title()) const title = $derived(article.title())
const summary = $derived(article.summary()) const summary = $derived(article.summary())
@ -28,10 +30,8 @@
<div data-component="ArticleItem" class="cv relative w-full card card-interactive"> <div data-component="ArticleItem" class="cv relative w-full card card-interactive">
<!-- An overlay rather than a wrapper: the card carries a profile button and the room and action <!-- An overlay rather than a wrapper: the card carries a profile button and the room and action
links, and none of those may sit inside an anchor. --> links, and none of those may sit inside an anchor. -->
<Link <Link class="absolute inset-0 rounded-2xl" href={path} aria-label={title || "Untitled"} />
class="absolute inset-0 rounded-2xl" <UnreadDot {path} class="absolute right-3 top-3" />
href={makeArticlePath(url, getAddress(event))}
aria-label={title || "Untitled"} />
<div class="pointer-events-none relative flex flex-col gap-2"> <div class="pointer-events-none relative flex flex-col gap-2">
{#if image} {#if image}
<img src={image} alt="" class="h-40 w-full rounded-2xl object-cover" /> <img src={image} alt="" class="h-40 w-full rounded-2xl object-cover" />

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {getAddress, tagSpec, tagValue} from "@welshman/util" import {tagSpec, tagValue} from "@welshman/util"
import Pen2 from "@assets/icons/pen-2.svg?dataurl" import Pen2 from "@assets/icons/pen-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@ -14,7 +14,7 @@
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte" import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import {user} from "@app/core" import {user} from "@app/core"
import {makeCalendarPath, makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
type Props = { type Props = {
@ -28,7 +28,6 @@
const {url, event, showRoom, showActivity, context}: Props = $props() const {url, event, showRoom, showActivity, context}: Props = $props()
const h = $derived(tagValue(tagSpec("h"), event.tags)) const h = $derived(tagValue(tagSpec("h"), event.tags))
const path = $derived(makeCalendarPath(url, getAddress(event)))
const editEvent = () => pushModal(CalendarEventEdit, {url, event}) const editEvent = () => pushModal(CalendarEventEdit, {url, event})
@ -52,7 +51,7 @@
{createReaction} {createReaction}
reactionClass="tip tip-left" /> reactionClass="tip tip-left" />
{#if showActivity} {#if showActivity}
<EventActivity {path} {event} {context} /> <EventActivity {event} {context} />
{/if} {/if}
<EventActions {url} {event} noun="Event"> <EventActions {url} {event} noun="Event">
{#snippet customActions()} {#snippet customActions()}

View file

@ -7,6 +7,7 @@
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte" import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import RoomLink from "@app/components/RoomLink.svelte" import RoomLink from "@app/components/RoomLink.svelte"
import UnreadDot from "@app/components/UnreadDot.svelte"
import {makeCalendarPath} from "@app/routes" import {makeCalendarPath} from "@app/routes"
type Props = { type Props = {
@ -17,12 +18,12 @@
const {url, event, context}: Props = $props() const {url, event, context}: Props = $props()
const path = $derived(makeCalendarPath(url, getAddress(event)))
const h = $derived(tagValue(tagSpec("h"), event.tags)) const h = $derived(tagValue(tagSpec("h"), event.tags))
</script> </script>
<Link <Link class="cv relative flex flex-col gap-3 card card-interactive w-full" href={path}>
class="cv flex flex-col gap-3 card card-interactive w-full" <UnreadDot {path} class="absolute right-3 top-3" />
href={makeCalendarPath(url, getAddress(event))}>
<CalendarEventHeader {event} /> <CalendarEventHeader {event} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row"> <div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75"> <span class="whitespace-nowrap py-1 text-sm opacity-75">

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import {uniq} from "@welshman/lib" import {uniq} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {getAddress} from "@welshman/util"
import {Classified} from "@welshman/domain" import {Classified} from "@welshman/domain"
import Pen2 from "@assets/icons/pen-2.svg?dataurl" import Pen2 from "@assets/icons/pen-2.svg?dataurl"
import {normalizeTopic} from "@lib/util" import {normalizeTopic} from "@lib/util"
@ -18,7 +17,7 @@
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte" import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
import {reader, user} from "@app/core" import {reader, user} from "@app/core"
import {makeClassifiedPath, makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
type Props = { type Props = {
@ -36,7 +35,6 @@
const classified = $derived(reader(Classified)(event)) const classified = $derived(reader(Classified)(event))
const h = $derived(classified.room()) const h = $derived(classified.room())
const topics = $derived(classified.topics() ?? []) const topics = $derived(classified.topics() ?? [])
const path = $derived(makeClassifiedPath(url, getAddress(event)))
const editClassified = () => pushModal(ClassifiedEdit, {url, event}) const editClassified = () => pushModal(ClassifiedEdit, {url, event})
@ -70,7 +68,7 @@
{createReaction} {createReaction}
reactionClass="tip-left" /> reactionClass="tip-left" />
{#if showActivity} {#if showActivity}
<EventActivity {path} {event} {context} /> <EventActivity {event} {context} />
{/if} {/if}
<EventActions {url} {event} noun="Listing"> <EventActions {url} {event} noun="Listing">
{#snippet customActions()} {#snippet customActions()}

View file

@ -12,6 +12,7 @@
import ClassifiedActions from "@app/components/ClassifiedActions.svelte" import ClassifiedActions from "@app/components/ClassifiedActions.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import RoomLink from "@app/components/RoomLink.svelte" import RoomLink from "@app/components/RoomLink.svelte"
import UnreadDot from "@app/components/UnreadDot.svelte"
import {makeClassifiedPath} from "@app/routes" import {makeClassifiedPath} from "@app/routes"
type Props = { type Props = {
@ -22,6 +23,7 @@
const {url, event, context}: Props = $props() const {url, event, context}: Props = $props()
const path = $derived(makeClassifiedPath(url, getAddress(event)))
const classified = $derived(reader(Classified)(event)) const classified = $derived(reader(Classified)(event))
const title = $derived(classified.title()) const title = $derived(classified.title())
const h = $derived(classified.room()) const h = $derived(classified.room())
@ -29,9 +31,8 @@
const price = $derived(classified.price()) const price = $derived(classified.price())
</script> </script>
<Link <Link class="cv relative flex flex-col gap-2 card card-interactive w-full" href={path}>
class="cv flex flex-col gap-2 card card-interactive w-full" <UnreadDot {path} class="absolute right-3 top-3" />
href={makeClassifiedPath(url, getAddress(event))}>
{#if title} {#if title}
<div class="flex w-full items-center justify-between gap-2"> <div class="flex w-full items-center justify-between gap-2">
<p class="text-xl"> <p class="text-xl">

View file

@ -7,22 +7,18 @@
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import {makeSpacePath} from "@app/routes"
type Props = { type Props = {
url: string url: string
event: TrustedEvent event: TrustedEvent
segment?: string
showActivity?: boolean showActivity?: boolean
context: FeedContext context: FeedContext
} }
const {url, event, segment, showActivity = false, context}: Props = $props() const {url, event, showActivity = false, context}: Props = $props()
const h = tagValue(tagSpec("h"), event.tags) const h = tagValue(tagSpec("h"), event.tags)
const path = segment && makeSpacePath(url, segment, event.id)
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h}) const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h}) const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
@ -38,8 +34,8 @@
{deleteReaction} {deleteReaction}
{createReaction} {createReaction}
reactionClass="tip-left" /> reactionClass="tip-left" />
{#if showActivity && path} {#if showActivity}
<EventActivity {path} {event} {context} /> <EventActivity {event} {context} />
{/if} {/if}
<EventActions {url} {event} noun="Comment" /> <EventActions {url} {event} noun="Comment" />
</ThunkStatusOrDeleted> </ThunkStatusOrDeleted>

View file

@ -1,22 +1,19 @@
<script lang="ts"> <script lang="ts">
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {filter, max, gt, spec, formatTimestampRelative} from "@welshman/lib" import {filter, max, spec, formatTimestampRelative} from "@welshman/lib"
import {COMMENT} from "@welshman/util" import {COMMENT} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import Reply from "@assets/icons/reply-2.svg?dataurl" import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import {deriveChecked} from "@app/notifications"
// hideLastActive: redundant next to a detail page's own comments below. // hideLastActive: redundant next to a detail page's own comments below.
const { const {
path,
event, event,
context, context,
size = "xs", size = "xs",
hideLastActive, hideLastActive,
}: { }: {
path: string
event: TrustedEvent event: TrustedEvent
context: FeedContext context: FeedContext
size?: "xs" | "sm" size?: "xs" | "sm"
@ -25,7 +22,6 @@
const buttonClass = `button button-neutral button-${size} rounded-full` const buttonClass = `button button-neutral button-${size} rounded-full`
const checked = deriveChecked(path)
const related = context.related(event) const related = context.related(event)
const replies = derived(related, $related => filter(spec({kind: COMMENT}), $related)) const replies = derived(related, $related => filter(spec({kind: COMMENT}), $related))
const lastActive = $derived(max([...$replies, event].map(e => e.created_at))) const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
@ -36,10 +32,7 @@
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span> <span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
</div> </div>
{#if !hideLastActive} {#if !hideLastActive}
<div class="{buttonClass} relative"> <div class={buttonClass}>
{#if gt(lastActive, $checked)}
<div class="h-2 w-2 rounded-full bg-primary text-primary-content"></div>
{/if}
Active {formatTimestampRelative(lastActive)} Active {formatTimestampRelative(lastActive)}
</div> </div>
{/if} {/if}

View file

@ -10,7 +10,7 @@
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import {makeGoalPath, makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
interface Props { interface Props {
url: string url: string
@ -22,7 +22,6 @@
const {url, event, showRoom, showActivity, context}: Props = $props() const {url, event, showRoom, showActivity, context}: Props = $props()
const path = makeGoalPath(url, event.id)
const goal = reader(ZapGoal)(event) const goal = reader(ZapGoal)(event)
const h = goal.room() const h = goal.room()
@ -47,7 +46,7 @@
{createReaction} {createReaction}
reactionClass="tip-left" /> reactionClass="tip-left" />
{#if showActivity} {#if showActivity}
<EventActivity {path} {event} {context} /> <EventActivity {event} {context} />
{/if} {/if}
<EventActions {url} {event} hideZap noun="Goal" /> <EventActions {url} {event} hideZap noun="Goal" />
</ThunkStatusOrDeleted> </ThunkStatusOrDeleted>

View file

@ -9,6 +9,7 @@
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import GoalSummary from "@app/components/GoalSummary.svelte" import GoalSummary from "@app/components/GoalSummary.svelte"
import RoomLink from "@app/components/RoomLink.svelte" import RoomLink from "@app/components/RoomLink.svelte"
import UnreadDot from "@app/components/UnreadDot.svelte"
import {makeGoalPath} from "@app/routes" import {makeGoalPath} from "@app/routes"
type Props = { type Props = {
@ -21,14 +22,14 @@
const goal = reader(ZapGoal)(event) const goal = reader(ZapGoal)(event)
const path = makeGoalPath(url, event.id)
const title = goal.title() const title = goal.title()
const summary = goal.summary() const summary = goal.summary()
const h = goal.room() const h = goal.room()
</script> </script>
<Link <Link class="cv relative flex flex-col gap-2 card card-interactive w-full" href={path}>
class="cv flex flex-col gap-2 card card-interactive w-full" <UnreadDot {path} class="absolute right-3 top-3" />
href={makeGoalPath(url, event.id)}>
<p class="text-2xl">{title}</p> <p class="text-2xl">{title}</p>
<Content <Content
event={{content: summary, tags: event.tags}} event={{content: summary, tags: event.tags}}

View file

@ -4,6 +4,8 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import MenuButton from "@lib/components/MenuButton.svelte" import MenuButton from "@lib/components/MenuButton.svelte"
import BoardMenu from "@app/components/BoardMenu.svelte" import BoardMenu from "@app/components/BoardMenu.svelte"
import UnreadDot from "@app/components/UnreadDot.svelte"
import {makeLibraryPath} from "@app/routes"
type Props = { type Props = {
url: string url: string
@ -13,6 +15,8 @@
} }
const {url, board, selected, onclick}: Props = $props() const {url, board, selected, onclick}: Props = $props()
const path = $derived(makeLibraryPath(url, board.address()))
</script> </script>
<div class="relative w-56 shrink-0"> <div class="relative w-56 shrink-0">
@ -22,7 +26,8 @@
class={cx("card card-sm card-interactive flex h-full w-full flex-col items-start gap-1 pr-10", { class={cx("card card-sm card-interactive flex h-full w-full flex-col items-start gap-1 pr-10", {
"card-primary": selected, "card-primary": selected,
})}> })}>
<strong class="truncate w-full text-left">{board.title() || "Untitled shelf"}</strong> <strong class="truncate w-full text-left"
><UnreadDot {path} class="mr-1" />{board.title() || "Untitled shelf"}</strong>
{#if board.description()} {#if board.description()}
<span class="line-clamp-2 text-left text-sm opacity-70">{board.description()}</span> <span class="line-clamp-2 text-left text-sm opacity-70">{board.description()}</span>
{/if} {/if}

View file

@ -9,7 +9,7 @@
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import {makePollPath, makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
type Props = { type Props = {
url: string url: string
@ -22,7 +22,6 @@
const {url, event, showRoom, showActivity, context}: Props = $props() const {url, event, showRoom, showActivity, context}: Props = $props()
const h = tagValue(tagSpec("h"), event.tags) const h = tagValue(tagSpec("h"), event.tags)
const path = makePollPath(url, event.id)
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h}) const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
@ -44,7 +43,7 @@
{createReaction} {createReaction}
reactionClass="tip-left" /> reactionClass="tip-left" />
{#if showActivity} {#if showActivity}
<EventActivity {path} {event} {context} /> <EventActivity {event} {context} />
{/if} {/if}
<EventActions {url} {event} noun="Poll" /> <EventActions {url} {event} noun="Poll" />
</ThunkStatusOrDeleted> </ThunkStatusOrDeleted>

View file

@ -7,6 +7,7 @@
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import RoomLink from "@app/components/RoomLink.svelte" import RoomLink from "@app/components/RoomLink.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import UnreadDot from "@app/components/UnreadDot.svelte"
import {makePollPath} from "@app/routes" import {makePollPath} from "@app/routes"
type Props = { type Props = {
@ -17,12 +18,12 @@
const {url, event, context}: Props = $props() const {url, event, context}: Props = $props()
const path = makePollPath(url, event.id)
const h = tagValue(tagSpec("h"), event.tags) const h = tagValue(tagSpec("h"), event.tags)
</script> </script>
<Link <Link class="cv relative flex flex-col gap-2 card card-interactive w-full" href={path}>
class="cv flex flex-col gap-2 card card-interactive w-full" <UnreadDot {path} class="absolute right-3 top-3" />
href={makePollPath(url, event.id)}>
<NoteContent {event} {url} /> <NoteContent {event} {url} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row"> <div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75"> <span class="whitespace-nowrap py-1 text-sm opacity-75">

View file

@ -9,7 +9,7 @@
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import {makeThreadPath, makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
interface Props { interface Props {
url: string url: string
@ -22,7 +22,6 @@
const {url, event, showRoom, showActivity, context}: Props = $props() const {url, event, showRoom, showActivity, context}: Props = $props()
const h = tagValue(tagSpec("h"), event.tags) const h = tagValue(tagSpec("h"), event.tags)
const path = makeThreadPath(url, event.id)
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url}) const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url})
const createReaction = (values: EventContent) => publishReaction(event, values, {url}) const createReaction = (values: EventContent) => publishReaction(event, values, {url})
@ -43,7 +42,7 @@
{createReaction} {createReaction}
reactionClass="tip-left" /> reactionClass="tip-left" />
{#if showActivity} {#if showActivity}
<EventActivity {path} {event} {context} /> <EventActivity {event} {context} />
{/if} {/if}
<EventActions {url} {event} noun="Thread" /> <EventActions {url} {event} noun="Thread" />
</ThunkStatusOrDeleted> </ThunkStatusOrDeleted>

View file

@ -4,12 +4,11 @@
import {filter, formatTimestamp, max, spec} from "@welshman/lib" import {filter, formatTimestamp, max, spec} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {COMMENT, tagSpec, tagValue} from "@welshman/util" import {COMMENT, tagSpec, tagValue} from "@welshman/util"
import {fade} from "@lib/transition"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
import UnreadDot from "@app/components/UnreadDot.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import {notifications} from "@app/notifications"
import {makeThreadPath} from "@app/routes" import {makeThreadPath} from "@app/routes"
type Props = { type Props = {
@ -30,17 +29,13 @@
const onClick = () => goto(path) const onClick = () => goto(path)
</script> </script>
{#snippet unread()}
{#if $notifications.has(path)}
<div class="mr-1 inline-block h-2 w-2 rounded-full bg-primary" transition:fade></div>
{/if}
{/snippet}
{#if mobile} {#if mobile}
<Link <Link
href={path} href={path}
class="cv hover:bg-surface-less flex w-full flex-col gap-2 border-b border-solid border-line px-4 py-3 text-left text-sm transition-colors"> class="cv hover:bg-surface-less flex w-full flex-col gap-2 border-b border-solid border-line px-4 py-3 text-left text-sm transition-colors">
<p class="truncate font-medium">{@render unread()}{title || "Untitled thread"}</p> <p class="truncate font-medium">
<UnreadDot {path} class="mr-1" />{title || "Untitled thread"}
</p>
<div class="text-content-muted flex flex-wrap items-center gap-x-3 gap-y-1 text-xs"> <div class="text-content-muted flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span class="flex min-w-0 items-center gap-1.5"> <span class="flex min-w-0 items-center gap-1.5">
<ProfileCircle pubkey={event.pubkey} {url} size={4} /> <ProfileCircle pubkey={event.pubkey} {url} size={4} />
@ -57,7 +52,7 @@
onclick={onClick} onclick={onClick}
class="hover:bg-surface-less cursor-pointer border-b border-solid border-line text-sm transition-colors"> class="hover:bg-surface-less cursor-pointer border-b border-solid border-line text-sm transition-colors">
<td class="max-w-0 truncate px-4 py-3 align-top"> <td class="max-w-0 truncate px-4 py-3 align-top">
{@render unread()}{title || "Untitled thread"} <UnreadDot {path} class="mr-1" />{title || "Untitled thread"}
</td> </td>
<td class="w-32 px-4 py-3 align-middle"> <td class="w-32 px-4 py-3 align-middle">
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-2">

View file

@ -8,6 +8,7 @@
import ThreadActions from "@app/components/ThreadActions.svelte" import ThreadActions from "@app/components/ThreadActions.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import RoomLink from "@app/components/RoomLink.svelte" import RoomLink from "@app/components/RoomLink.svelte"
import UnreadDot from "@app/components/UnreadDot.svelte"
import {makeThreadPath} from "@app/routes" import {makeThreadPath} from "@app/routes"
type Props = { type Props = {
@ -18,13 +19,13 @@
const {url, event, context}: Props = $props() const {url, event, context}: Props = $props()
const path = makeThreadPath(url, event.id)
const title = tagValue(tagSpec("title"), event.tags) const title = tagValue(tagSpec("title"), event.tags)
const h = tagValue(tagSpec("h"), event.tags) const h = tagValue(tagSpec("h"), event.tags)
</script> </script>
<Link <Link class="cv relative flex flex-col gap-2 card card-interactive w-full" href={path}>
class="cv flex flex-col gap-2 card card-interactive w-full" <UnreadDot {path} class="absolute right-3 top-3" />
href={makeThreadPath(url, event.id)}>
{#if title} {#if title}
<div class="flex w-full items-center justify-between gap-2"> <div class="flex w-full items-center justify-between gap-2">
<p class="text-xl">{title}</p> <p class="text-xl">{title}</p>

View file

@ -92,7 +92,7 @@
Reply Reply
</Button> </Button>
{#if isComment} {#if isComment}
<CommentActions segment="threads" {event} {url} {context} /> <CommentActions {event} {url} {context} />
{:else} {:else}
<ThreadActions {event} {url} {context} /> <ThreadActions {event} {url} {context} />
{/if} {/if}

View file

@ -0,0 +1,16 @@
<script lang="ts">
import cx from "classnames"
import {fade} from "@lib/transition"
import {notifications} from "@app/notifications"
type Props = {
path: string
class?: string
}
const {path, class: className}: Props = $props()
</script>
{#if $notifications.has(path)}
<div class={cx("inline-block h-2 w-2 rounded-full bg-primary", className)} transition:fade></div>
{/if}

View file

@ -1,18 +1,7 @@
import {derived, get, writable} from "svelte/store" import {derived, get, writable} from "svelte/store"
import {Badge} from "@capawesome/capacitor-badge" import {Badge} from "@capawesome/capacitor-badge"
import {page} from "$app/stores" import {page} from "$app/stores"
import { import {assoc, first, identity, groupBy, now, remove, throttle, parseJson, gt} from "@welshman/lib"
assoc,
prop,
first,
identity,
groupBy,
now,
remove,
throttle,
parseJson,
gt,
} from "@welshman/lib"
import type {SignedEvent, TrustedEvent} from "@welshman/util" import type {SignedEvent, TrustedEvent} from "@welshman/util"
import { import {
getIdOrAddress, getIdOrAddress,
@ -48,8 +37,6 @@ export const checked = withGetter(
export const getChecked = (key: string) => checked.get()[key] export const getChecked = (key: string) => checked.get()[key]
export const deriveChecked = (key: string) => derived(checked, prop<number>(key))
export const setChecked = (key: string) => checked.update(assoc(key, now())) export const setChecked = (key: string) => checked.update(assoc(key, now()))
/** Room path while video call UI hides chat; checked + badge stay active until chat is shown. */ /** Room path while video call UI hides chat; checked + badge stay active until chat is shown. */

View file

@ -101,7 +101,7 @@
<NoteCard event={reply} {url} class="card z-feature w-full"> <NoteCard event={reply} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12"> <div class="flex flex-col gap-3 ml-12">
<NoteContent showEntire event={reply} {url} /> <NoteContent showEntire event={reply} {url} />
<CommentActions segment="calendar" event={reply} {url} {context} /> <CommentActions event={reply} {url} {context} />
</div> </div>
</NoteCard> </NoteCard>
{/each} {/each}

View file

@ -89,7 +89,7 @@
<NoteCard event={reply} {url} class="card z-feature w-full"> <NoteCard event={reply} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12"> <div class="flex flex-col gap-3 ml-12">
<NoteContent showEntire event={reply} {url} /> <NoteContent showEntire event={reply} {url} />
<CommentActions segment="classifieds" event={reply} {url} {context} /> <CommentActions event={reply} {url} {context} />
</div> </div>
</NoteCard> </NoteCard>
{/each} {/each}

View file

@ -90,7 +90,7 @@
<NoteCard event={reply} {url} class="card z-feature w-full"> <NoteCard event={reply} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12"> <div class="flex flex-col gap-3 ml-12">
<NoteContent showEntire event={reply} {url} /> <NoteContent showEntire event={reply} {url} />
<CommentActions segment="goals" event={reply} {url} {context} /> <CommentActions event={reply} {url} {context} />
</div> </div>
</NoteCard> </NoteCard>
{/each} {/each}

View file

@ -86,7 +86,7 @@
<NoteCard event={$event} {url} class="card z-feature w-full"> <NoteCard event={$event} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12 flex flex-col gap-3"> <div class="flex flex-col gap-3 ml-12 flex flex-col gap-3">
<NoteContent showEntire event={$event} {url} /> <NoteContent showEntire event={$event} {url} />
<CommentActions segment="polls" showActivity {url} event={$event} {context} /> <CommentActions showActivity {url} event={$event} {context} />
</div> </div>
</NoteCard> </NoteCard>
{#if !showAll && $comments.length > 4} {#if !showAll && $comments.length > 4}
@ -101,7 +101,7 @@
<NoteCard event={reply} {url} class="card z-feature w-full"> <NoteCard event={reply} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12"> <div class="flex flex-col gap-3 ml-12">
<NoteContent showEntire event={reply} {url} /> <NoteContent showEntire event={reply} {url} />
<CommentActions segment="polls" event={reply} {url} {context} /> <CommentActions event={reply} {url} {context} />
</div> </div>
</NoteCard> </NoteCard>
{/each} {/each}