Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 3m51s
CI / OCR Service Tests (pull_request) Successful in 47s
CI / Backend Unit Tests (pull_request) Failing after 3m31s
- CommentData.java: add @Nullable on annotationId to match codebase convention - DashboardService: isEmpty() → isBlank() for commentPreview null-guard - ChronikRow.svelte: always set aria-label on comment rows (not only when preview present) - ChronikRow.svelte.spec.ts: add test for aria-label on comment row without preview Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
178 lines
5.4 KiB
Svelte
178 lines
5.4 KiB
Svelte
<script lang="ts">
|
|
import * as m from '$lib/paraglide/messages.js';
|
|
import { relativeTime } from '$lib/shared/utils/time';
|
|
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
|
import type { components } from '$lib/generated/api';
|
|
|
|
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
|
type Variant = 'comment' | 'for-you' | 'rollup' | 'simple';
|
|
|
|
interface Props {
|
|
item: ActivityFeedItemDTO;
|
|
}
|
|
|
|
const { item }: Props = $props();
|
|
|
|
const variant: Variant = $derived(
|
|
item.kind === 'COMMENT_ADDED'
|
|
? 'comment'
|
|
: item.youMentioned
|
|
? 'for-you'
|
|
: item.count > 1
|
|
? 'rollup'
|
|
: 'simple'
|
|
);
|
|
|
|
function verbSingleton(kind: string, actor: string, doc: string): string {
|
|
switch (kind) {
|
|
case 'TEXT_SAVED':
|
|
return m.chronik_singleton_text_saved({ actor, doc });
|
|
case 'FILE_UPLOADED':
|
|
return m.chronik_singleton_uploaded({ actor, doc });
|
|
case 'BLOCK_REVIEWED':
|
|
return m.chronik_singleton_reviewed({ actor, doc });
|
|
case 'ANNOTATION_CREATED':
|
|
return m.chronik_singleton_annotated({ actor, doc });
|
|
case 'COMMENT_ADDED':
|
|
return m.chronik_comment_added({ actor, doc });
|
|
case 'MENTION_CREATED':
|
|
return m.chronik_mention_created({ actor, doc });
|
|
default:
|
|
return `${actor} · ${doc}`;
|
|
}
|
|
}
|
|
|
|
function verbRollup(kind: string, actor: string, doc: string, count: number): string {
|
|
switch (kind) {
|
|
case 'TEXT_SAVED':
|
|
return m.chronik_rollup_text_saved({ actor, doc, count });
|
|
case 'FILE_UPLOADED':
|
|
return m.chronik_rollup_uploaded({ actor, count });
|
|
case 'BLOCK_REVIEWED':
|
|
return m.chronik_rollup_reviewed({ actor, doc, count });
|
|
case 'ANNOTATION_CREATED':
|
|
return m.chronik_rollup_annotated({ actor, doc, count });
|
|
default:
|
|
return verbSingleton(kind, actor, doc);
|
|
}
|
|
}
|
|
|
|
function formatTimeHHMM(iso: string): string {
|
|
const d = new Date(iso);
|
|
return new Intl.DateTimeFormat('de-DE', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
}).format(d);
|
|
}
|
|
|
|
const actorName: string = $derived(item.actor?.name ?? item.actor?.initials ?? '?');
|
|
const docTitle: string = $derived(item.documentTitle);
|
|
|
|
// We split the translated verb around the document title so the title can be
|
|
// rendered as a styled <span> inside the <a> without nesting anchors. Using a
|
|
// non-printable sentinel (U+0001) as the {doc} interpolation value lets us
|
|
// split the compiled message regardless of what the actual title contains —
|
|
// empty strings, short substrings that also appear in the verb, and any
|
|
// translator sentence order all work without special cases.
|
|
const SENTINEL = '\u0001';
|
|
|
|
const verbText: string = $derived(
|
|
variant === 'rollup'
|
|
? verbRollup(item.kind, actorName, SENTINEL, item.count)
|
|
: verbSingleton(item.kind, actorName, SENTINEL)
|
|
);
|
|
|
|
const timeLabel: string = $derived(
|
|
variant === 'rollup' && item.happenedAtUntil
|
|
? `${formatTimeHHMM(item.happenedAt)}\u2013${formatTimeHHMM(item.happenedAtUntil)}`
|
|
: relativeTime(item.happenedAt)
|
|
);
|
|
|
|
const verbParts: { before: string; after: string } = $derived.by(() => {
|
|
const idx = verbText.indexOf(SENTINEL);
|
|
if (idx === -1) return { before: verbText, after: '' };
|
|
return {
|
|
before: verbText.slice(0, idx),
|
|
after: verbText.slice(idx + SENTINEL.length)
|
|
};
|
|
});
|
|
|
|
const rowHref: string = $derived(
|
|
item.commentId
|
|
? buildCommentHref(item.documentId, item.commentId, item.annotationId ?? null)
|
|
: `/documents/${item.documentId}`
|
|
);
|
|
</script>
|
|
|
|
<a
|
|
href={rowHref}
|
|
data-variant={variant}
|
|
aria-label={variant === 'comment'
|
|
? item.commentPreview
|
|
? `${m.chronik_comment_added({ actor: actorName, doc: docTitle })} — ${item.commentPreview}`
|
|
: m.chronik_comment_added({ actor: actorName, doc: docTitle })
|
|
: undefined}
|
|
class="group flex items-start gap-3 p-3 transition-colors hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none
|
|
{variant === 'for-you' ? 'border-l-[3px] border-accent bg-accent-bg/10' : ''}"
|
|
>
|
|
<!-- Avatar -->
|
|
{#if item.actor}
|
|
<span
|
|
class="mt-0.5 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full font-sans text-sm font-bold text-white"
|
|
style="background:{item.actor.color}"
|
|
aria-hidden="true"
|
|
>
|
|
{item.actor.initials}
|
|
</span>
|
|
{:else}
|
|
<span
|
|
data-testid="chronik-avatar-fallback"
|
|
class="mt-0.5 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-line font-sans text-sm text-ink-3"
|
|
aria-hidden="true"
|
|
>
|
|
?
|
|
</span>
|
|
{/if}
|
|
|
|
<!-- For-you marker (hidden on mobile) -->
|
|
{#if variant === 'for-you'}
|
|
<span
|
|
data-testid="chronik-foryou-marker"
|
|
aria-hidden="true"
|
|
class="mt-1 hidden h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent sm:inline-flex"
|
|
>
|
|
@
|
|
</span>
|
|
{/if}
|
|
|
|
<!-- Body -->
|
|
<div class="min-w-0 flex-1">
|
|
<p class="font-sans text-sm leading-snug text-ink">
|
|
{verbParts.before}<span
|
|
data-testid="chronik-doc-title"
|
|
class="underline decoration-accent underline-offset-2">{docTitle}</span
|
|
>{verbParts.after}
|
|
{#if variant === 'rollup'}
|
|
<span
|
|
data-testid="chronik-count-badge"
|
|
class="ml-1 inline-block rounded-sm bg-primary px-2 py-0.5 font-sans text-xs text-primary-fg"
|
|
>
|
|
{item.count}
|
|
</span>
|
|
{/if}
|
|
</p>
|
|
|
|
{#if variant === 'comment'}
|
|
<p
|
|
data-testid="chronik-comment-preview"
|
|
class="mt-1 line-clamp-1 font-serif text-sm text-ink-2 italic sm:line-clamp-2"
|
|
>
|
|
{item.commentPreview ?? '„…"'}
|
|
</p>
|
|
{/if}
|
|
|
|
<p class="mt-0.5 font-sans text-xs text-ink-3">{timeLabel}</p>
|
|
</div>
|
|
</a>
|