feat: conversations on a ticket are settled, and you see who is typing
A top-level comment and its replies can be resolved by whoever can
change the ticket (CommentThreadResolvedEvent), with an optional
conclusion added as a reply marked as a decision, so it shows above the
ticket. A settled conversation folds to one line (topic, who settled it
and the conclusion) and can be unfolded or reopened; the header counts
the open ones, and finishing the ticket with one open asks first.
«Ana está escribiendo…» shows in the conversation being answered, at
once: a `typing` mutation publishes to a Redis channel per ticket
(`typing` subscription), never stored. In replies, Enter sends and
Shift+Enter breaks the line, except inside lists and code.
API: resolveThread / PUT-DELETE /api/comments/{id}/resolution, Comment
.resolved/resolvedBy/resolvedAt, typing mutation and subscription. MCP:
resolve_thread.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,11 @@ export const issuePageEvents = eventGroup({
|
||||
commentEditRequested: type<{ commentId: string; body: string }>(),
|
||||
reactRequested: type<{ commentId: string; emoji: string; on: boolean }>(),
|
||||
decisionRequested: type<{ commentId: string; on: boolean }>(),
|
||||
threadResolveRequested: type<{
|
||||
commentId: string;
|
||||
resolved: boolean;
|
||||
conclusion: string | null;
|
||||
}>(),
|
||||
voteRequested: type<{ issueId: string; on: boolean }>(),
|
||||
/** An image pasted into a text was uploaded (it joins the ticket's files). */
|
||||
imagePasted: type<{ issueId: string }>(),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { type } from '@ngrx/signals';
|
||||
import { eventGroup } from '@ngrx/signals/events';
|
||||
import { Presence } from '@task-stream/web/tracker/domain';
|
||||
import { Presence, Typing } from '@task-stream/web/tracker/domain';
|
||||
|
||||
export const presencePageEvents = eventGroup({
|
||||
source: 'Issue Presence',
|
||||
events: {
|
||||
opened: type<string>(),
|
||||
editingChanged: type<boolean>(),
|
||||
/** I typed in an answer to this conversation (null: a new comment). */
|
||||
typed: type<string | null>(),
|
||||
left: type<void>(),
|
||||
},
|
||||
});
|
||||
@@ -15,5 +17,9 @@ export const presenceApiEvents = eventGroup({
|
||||
source: 'Presence API',
|
||||
events: {
|
||||
seen: type<Presence[]>(),
|
||||
/** Someone else is writing. */
|
||||
typing: type<Typing>(),
|
||||
/** …and stopped a while ago (the `at` it was last seen). */
|
||||
typingStopped: type<{ userId: string; at: string }>(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -231,6 +231,22 @@ export class IssueFacade {
|
||||
this.dispatch.reactRequested({ commentId, emoji, on });
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles a conversation (or reopens it); a conclusion is added as a
|
||||
* reply marked as a decision.
|
||||
*/
|
||||
resolveThread(
|
||||
commentId: string,
|
||||
resolved: boolean,
|
||||
conclusion: string | null = null,
|
||||
): void {
|
||||
this.dispatch.threadResolveRequested({
|
||||
commentId,
|
||||
resolved,
|
||||
conclusion: conclusion?.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
/** Marks a comment as a decision of the ticket (`on`) or unmarks it. */
|
||||
markDecision(commentId: string, on: boolean): void {
|
||||
this.dispatch.decisionRequested({ commentId, on });
|
||||
|
||||
@@ -11,6 +11,8 @@ export class PresenceFacade {
|
||||
|
||||
/** The others, the ones editing first. */
|
||||
readonly others = this.store.others;
|
||||
/** Who else is writing now, and where (`threadId` null: a new comment). */
|
||||
readonly typists = this.store.typists;
|
||||
|
||||
/** I am on this ticket: beats until the page closes. */
|
||||
open(issueId: string): void {
|
||||
@@ -20,6 +22,11 @@ export class PresenceFacade {
|
||||
setEditing(editing: boolean): void {
|
||||
this.dispatch.editingChanged(editing);
|
||||
}
|
||||
|
||||
/** I am writing an answer to `threadId` (null: a new comment). */
|
||||
typed(threadId: string | null): void {
|
||||
this.dispatch.typed(threadId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Store + facade of a ticket's presence, for the ticket's page. */
|
||||
|
||||
@@ -479,6 +479,26 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.threadResolveRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
comments.resolveThread(
|
||||
payload.commentId,
|
||||
payload.resolved,
|
||||
payload.conclusion,
|
||||
),
|
||||
!payload.resolved
|
||||
? 'Conversación reabierta'
|
||||
: payload.conclusion
|
||||
? 'Resuelta: la conclusión queda como decisión'
|
||||
: 'Conversación resuelta',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events.on(issuePageEvents.reactRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
comments.react(payload.commentId, payload.emoji, payload.on).pipe(
|
||||
|
||||
@@ -6,18 +6,26 @@ import {
|
||||
withEventHandlers,
|
||||
withReducer,
|
||||
} from '@ngrx/signals/events';
|
||||
import { Presence, PresenceGateway } from '@task-stream/web/tracker/domain';
|
||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import {
|
||||
Presence,
|
||||
PresenceGateway,
|
||||
Typing,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
EMPTY,
|
||||
filter,
|
||||
groupBy,
|
||||
map,
|
||||
mergeMap,
|
||||
pairwise,
|
||||
startWith,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
throttleTime,
|
||||
timer,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
@@ -27,10 +35,16 @@ import {
|
||||
|
||||
/** A heartbeat every so often; the API forgets a page after 30 s without one. */
|
||||
export const PRESENCE_BEAT_MS = 10_000;
|
||||
/** While typing, I say so at most this often… */
|
||||
export const TYPING_EVERY_MS = 3_000;
|
||||
/** …and someone silent this long has stopped. */
|
||||
export const TYPING_GONE_MS = 6_000;
|
||||
|
||||
interface PresenceState {
|
||||
issueId: string | null;
|
||||
others: Presence[];
|
||||
/** Who else is writing now, one entry each. */
|
||||
typists: Typing[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,17 +53,75 @@ interface PresenceState {
|
||||
* to the ticket's page.
|
||||
*/
|
||||
export const PresenceStore = signalStore(
|
||||
withState<PresenceState>({ issueId: null, others: [] }),
|
||||
withState<PresenceState>({ issueId: null, others: [], typists: [] }),
|
||||
withReducer(
|
||||
on(presencePageEvents.opened, ({ payload }) => ({
|
||||
issueId: payload,
|
||||
others: [],
|
||||
typists: [],
|
||||
})),
|
||||
on(presenceApiEvents.seen, ({ payload }) => ({ others: payload })),
|
||||
on(presencePageEvents.left, () => ({ issueId: null, others: [] })),
|
||||
on(presenceApiEvents.typing, ({ payload }, state) => ({
|
||||
typists: [
|
||||
...state.typists.filter((t) => t.user.id !== payload.user.id),
|
||||
payload,
|
||||
],
|
||||
})),
|
||||
on(presenceApiEvents.typingStopped, ({ payload }, state) => ({
|
||||
typists: state.typists.filter(
|
||||
(t) => !(t.user.id === payload.userId && t.at === payload.at),
|
||||
),
|
||||
})),
|
||||
on(presencePageEvents.left, () => ({
|
||||
issueId: null,
|
||||
others: [],
|
||||
typists: [],
|
||||
})),
|
||||
),
|
||||
withEventHandlers(
|
||||
(store, events = inject(Events), presence = inject(PresenceGateway)) => ({
|
||||
(
|
||||
store,
|
||||
events = inject(Events),
|
||||
presence = inject(PresenceGateway),
|
||||
auth = inject(AuthFacade),
|
||||
) => ({
|
||||
// Each answer (or the new comment) tells the others at most every 3 s.
|
||||
typed$: events.on(presencePageEvents.typed).pipe(
|
||||
map(({ payload }) => payload),
|
||||
groupBy((threadId) => threadId ?? ''),
|
||||
mergeMap((thread$) => thread$.pipe(throttleTime(TYPING_EVERY_MS))),
|
||||
mergeMap((threadId) => {
|
||||
const issueId = store.issueId();
|
||||
// Telling is a nicety: nothing to say if it fails.
|
||||
return issueId
|
||||
? presence.typing(issueId, threadId).pipe(catchError(() => EMPTY))
|
||||
: EMPTY;
|
||||
}),
|
||||
switchMap(() => EMPTY),
|
||||
),
|
||||
typists$: events.on(presencePageEvents.opened).pipe(
|
||||
switchMap(({ payload: issueId }) =>
|
||||
presence.typists(issueId).pipe(
|
||||
filter((typing) => typing.user.id !== auth.user()?.id),
|
||||
map((typing) => presenceApiEvents.typing(typing)),
|
||||
catchError(() => EMPTY),
|
||||
takeUntil(events.on(presencePageEvents.left)),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Silent for a while: stopped (unless a newer one came meanwhile).
|
||||
typingStopped$: events.on(presenceApiEvents.typing).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
timer(TYPING_GONE_MS).pipe(
|
||||
map(() =>
|
||||
presenceApiEvents.typingStopped({
|
||||
userId: payload.user.id,
|
||||
at: payload.at,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
beat$: events.on(presencePageEvents.opened).pipe(
|
||||
switchMap(({ payload: issueId }) =>
|
||||
combineLatest([
|
||||
|
||||
@@ -202,6 +202,14 @@ export interface Issue extends IssueSummary {
|
||||
}
|
||||
|
||||
/** Someone else on a ticket's page right now. */
|
||||
/** Someone writing on a ticket right now. */
|
||||
export interface Typing {
|
||||
user: UserRef;
|
||||
/** The conversation being answered; null: a new comment. */
|
||||
threadId: string | null;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface Presence {
|
||||
user: UserRef;
|
||||
/** Editing the ticket or writing a comment. */
|
||||
@@ -342,6 +350,10 @@ export interface Comment {
|
||||
decision: boolean;
|
||||
/** Who marked it as a decision (null when it is not one). */
|
||||
decidedBy: UserRef | null;
|
||||
/** A conversation (top-level comment and its replies) that was settled. */
|
||||
resolved: boolean;
|
||||
/** Who settled it (null while open). */
|
||||
resolvedBy: UserRef | null;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
AlertIntegration,
|
||||
Comment,
|
||||
Decision,
|
||||
Typing,
|
||||
Epic,
|
||||
GitIntegration,
|
||||
GitRepository,
|
||||
@@ -313,6 +314,15 @@ export abstract class CommentsGateway {
|
||||
on: boolean,
|
||||
): Observable<CommandResult>;
|
||||
abstract remove(commentId: string): Observable<CommandResult>;
|
||||
/**
|
||||
* Settles a conversation (or reopens it); a conclusion is added as a
|
||||
* reply marked as a decision.
|
||||
*/
|
||||
abstract resolveThread(
|
||||
commentId: string,
|
||||
resolved: boolean,
|
||||
conclusion: string | null,
|
||||
): Observable<CommandResult>;
|
||||
/** Marks it as a decision of its ticket (`on`) or unmarks it. */
|
||||
abstract markDecision(
|
||||
commentId: string,
|
||||
@@ -496,4 +506,8 @@ export abstract class TrackerRealtimeGateway {
|
||||
export abstract class PresenceGateway {
|
||||
abstract touch(issueId: string, editing: boolean): Observable<Presence[]>;
|
||||
abstract leave(issueId: string): Observable<void>;
|
||||
/** I am writing (an answer to `threadId`, or a new comment). */
|
||||
abstract typing(issueId: string, threadId: string | null): Observable<void>;
|
||||
/** Who is writing on the ticket, as they type (me included). */
|
||||
abstract typists(issueId: string): Observable<Typing>;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
IssueCommentsDocument,
|
||||
ReactToCommentDocument,
|
||||
MarkDecisionDocument,
|
||||
ResolveThreadDocument,
|
||||
PublishReleaseDocument,
|
||||
ReleaseBurnupDocument,
|
||||
ReleaseNotesDraftDocument,
|
||||
@@ -77,6 +78,16 @@ export class GraphqlCommentsGateway extends CommentsGateway {
|
||||
.pipe(map((d) => toCommandResult(d.deleteComment)));
|
||||
}
|
||||
|
||||
resolveThread(
|
||||
commentId: string,
|
||||
resolved: boolean,
|
||||
conclusion: string | null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(ResolveThreadDocument, { commentId, resolved, conclusion })
|
||||
.pipe(map((d) => toCommandResult(d.resolveThread)));
|
||||
}
|
||||
|
||||
markDecision(commentId: string, on: boolean): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(MarkDecisionDocument, { commentId, on })
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Presence, PresenceGateway } from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
Presence,
|
||||
PresenceGateway,
|
||||
Typing,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
LeavePresenceDocument,
|
||||
TouchPresenceDocument,
|
||||
TypingDocument,
|
||||
TypistsDocument,
|
||||
} from '../graphql/generated';
|
||||
import { GraphqlOperations } from './graphql-operations';
|
||||
|
||||
@@ -17,6 +23,18 @@ export class GraphqlPresenceGateway extends PresenceGateway {
|
||||
.pipe(map((d) => d.touchPresence));
|
||||
}
|
||||
|
||||
typing(issueId: string, threadId: string | null): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(TypingDocument, { issueId, threadId })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
typists(issueId: string): Observable<Typing> {
|
||||
return this.gql
|
||||
.subscribe(TypistsDocument, { issueId })
|
||||
.pipe(map((d) => d.typing));
|
||||
}
|
||||
|
||||
leave(issueId: string): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(LeavePresenceDocument, { issueId })
|
||||
|
||||
@@ -185,6 +185,10 @@ fragment CommentFields on Comment {
|
||||
decidedBy {
|
||||
...UserRefFields
|
||||
}
|
||||
resolved
|
||||
resolvedBy {
|
||||
...UserRefFields
|
||||
}
|
||||
createdAt
|
||||
editedAt
|
||||
author {
|
||||
|
||||
@@ -720,6 +720,7 @@ export type IssueFieldsFragment = {
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
resolved: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
@@ -727,6 +728,7 @@ export type IssueFieldsFragment = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
reporter: { id: string; name: string };
|
||||
@@ -773,6 +775,7 @@ export type CommentFieldsFragment = {
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
resolved: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
@@ -780,6 +783,7 @@ export type CommentFieldsFragment = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
};
|
||||
|
||||
@@ -961,6 +965,7 @@ export type IssueQuery = {
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
resolved: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
@@ -968,6 +973,7 @@ export type IssueQuery = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
reporter: { id: string; name: string };
|
||||
@@ -1181,6 +1187,7 @@ export type IssueCommentsQuery = {
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
resolved: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
@@ -1188,6 +1195,7 @@ export type IssueCommentsQuery = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
};
|
||||
@@ -1589,6 +1597,16 @@ export type ReactToCommentMutation = {
|
||||
reactToComment: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type ResolveThreadMutationVariables = Exact<{
|
||||
commentId: string;
|
||||
resolved: boolean;
|
||||
conclusion?: string | null | undefined;
|
||||
}>;
|
||||
|
||||
export type ResolveThreadMutation = {
|
||||
resolveThread: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type MarkDecisionMutationVariables = Exact<{
|
||||
commentId: string;
|
||||
on: boolean;
|
||||
@@ -1612,6 +1630,7 @@ export type SearchDecisionsQuery = {
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
resolved: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
@@ -1619,6 +1638,7 @@ export type SearchDecisionsQuery = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
};
|
||||
issue: { id: string; key: string; title: string };
|
||||
@@ -1758,6 +1778,25 @@ export type LeavePresenceMutationVariables = Exact<{
|
||||
|
||||
export type LeavePresenceMutation = { leavePresence: boolean };
|
||||
|
||||
export type TypingMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
threadId?: string | null | undefined;
|
||||
}>;
|
||||
|
||||
export type TypingMutation = { typing: boolean };
|
||||
|
||||
export type TypistsSubscriptionVariables = Exact<{
|
||||
issueId: string;
|
||||
}>;
|
||||
|
||||
export type TypistsSubscription = {
|
||||
typing: {
|
||||
threadId: string | null;
|
||||
at: string;
|
||||
user: { id: string; name: string };
|
||||
};
|
||||
};
|
||||
|
||||
export type MyProjectsQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type MyProjectsQuery = {
|
||||
@@ -2916,6 +2955,7 @@ export type CommentChangedSubscription = {
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
resolved: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
@@ -2923,6 +2963,7 @@ export type CommentChangedSubscription = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
} | null;
|
||||
};
|
||||
@@ -3712,6 +3753,20 @@ export const CommentFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'resolvedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -4056,6 +4111,20 @@ export const IssueFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'resolvedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -6304,6 +6373,20 @@ export const IssueDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'resolvedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -7684,6 +7767,20 @@ export const IssueCommentsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'resolvedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -10172,6 +10269,114 @@ export const ReactToCommentDocument = {
|
||||
ReactToCommentMutation,
|
||||
ReactToCommentMutationVariables
|
||||
>;
|
||||
export const ResolveThreadDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'ResolveThread' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'resolved' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'conclusion' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'resolveThread' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'resolved' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'resolved' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'conclusion' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'conclusion' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'CommandResult' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
ResolveThreadMutation,
|
||||
ResolveThreadMutationVariables
|
||||
>;
|
||||
export const MarkDecisionDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -10414,6 +10619,20 @@ export const SearchDecisionsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'resolvedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -11196,6 +11415,141 @@ export const LeavePresenceDocument = {
|
||||
LeavePresenceMutation,
|
||||
LeavePresenceMutationVariables
|
||||
>;
|
||||
export const TypingDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'Typing' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'threadId' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'typing' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'threadId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'threadId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<TypingMutation, TypingMutationVariables>;
|
||||
export const TypistsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'subscription',
|
||||
name: { kind: 'Name', value: 'Typists' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'typing' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'user' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'threadId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<TypistsSubscription, TypistsSubscriptionVariables>;
|
||||
export const MyProjectsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -17206,6 +17560,20 @@ export const CommentChangedDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'resolvedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
|
||||
@@ -293,6 +293,20 @@ mutation ReactToComment($commentId: ID!, $emoji: String!, $on: Boolean!) {
|
||||
}
|
||||
}
|
||||
|
||||
mutation ResolveThread(
|
||||
$commentId: ID!
|
||||
$resolved: Boolean!
|
||||
$conclusion: String
|
||||
) {
|
||||
resolveThread(
|
||||
commentId: $commentId
|
||||
resolved: $resolved
|
||||
conclusion: $conclusion
|
||||
) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation MarkDecision($commentId: ID!, $on: Boolean!) {
|
||||
markDecision(commentId: $commentId, on: $on) {
|
||||
...CommandResultFields
|
||||
|
||||
@@ -11,3 +11,17 @@ mutation TouchPresence($issueId: ID!, $editing: Boolean!) {
|
||||
mutation LeavePresence($issueId: ID!) {
|
||||
leavePresence(issueId: $issueId)
|
||||
}
|
||||
|
||||
mutation Typing($issueId: ID!, $threadId: ID) {
|
||||
typing(issueId: $issueId, threadId: $threadId)
|
||||
}
|
||||
|
||||
subscription Typists($issueId: ID!) {
|
||||
typing(issueId: $issueId) {
|
||||
user {
|
||||
...UserRefFields
|
||||
}
|
||||
threadId
|
||||
at
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,24 +641,104 @@ a.ts-key {
|
||||
|
||||
// A text link, past @pi/ui's global button look: as a 40 px pill it sat on
|
||||
// the line under the comment.
|
||||
.ts-reply__open {
|
||||
justify-self: start;
|
||||
.ts-thread__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
/* Small text buttons, not @pi/ui's sized ones. */
|
||||
.ts-thread__action {
|
||||
flex: none;
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.125rem 0 !important;
|
||||
border: 0;
|
||||
background: none !important;
|
||||
color: var(--ts-brand);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* A settled conversation, folded to one line. */
|
||||
.ts-thread-done {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--ts-line);
|
||||
border-radius: 0.625rem;
|
||||
background: var(--ts-sunken);
|
||||
}
|
||||
|
||||
.ts-thread-done__check {
|
||||
flex: none;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--ts-c-green) 18%, transparent);
|
||||
color: var(--ts-c-green);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ts-thread-done__text {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.875rem;
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-thread-done__topic {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ts-open-threads {
|
||||
margin-left: auto;
|
||||
padding: 0.0625rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ts-c-amber) 18%, transparent);
|
||||
color: var(--ts-ink);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.ts-typing {
|
||||
margin: 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ts-resolve__label {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.ts-resolve__text {
|
||||
font: inherit;
|
||||
color: var(--ts-ink);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.ts-reply__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
isTyping,
|
||||
POINT_SCALE,
|
||||
ReleaseStatus,
|
||||
StatusCategory,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
clock,
|
||||
@@ -654,6 +655,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<h2 class="ts-section">
|
||||
Comentarios
|
||||
<span class="ts-mono">{{ commentCount() }}</span>
|
||||
@if (openThreads()) {
|
||||
<span class="ts-open-threads"
|
||||
>{{ openThreads() }}
|
||||
{{
|
||||
openThreads() === 1
|
||||
? 'conversación abierta'
|
||||
: 'conversaciones abiertas'
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
</h2>
|
||||
<ng-template #commentView let-comment>
|
||||
<div
|
||||
@@ -761,68 +772,188 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
</div>
|
||||
</ng-template>
|
||||
@for (thread of threads(); track thread.comment.id) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
commentView;
|
||||
context: { $implicit: thread.comment }
|
||||
"
|
||||
/>
|
||||
<div class="ts-replies">
|
||||
@for (reply of thread.replies; track reply.id) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
commentView;
|
||||
context: { $implicit: reply }
|
||||
"
|
||||
/>
|
||||
}
|
||||
@if (replyingTo() === thread.comment.id) {
|
||||
<form
|
||||
class="ts-form ts-reply"
|
||||
(submit)="$event.preventDefault(); reply(thread.comment)"
|
||||
novalidate
|
||||
@if (
|
||||
thread.comment.resolved && !expanded().has(thread.comment.id)
|
||||
) {
|
||||
<!-- Settled: one line, the conversation a click away. -->
|
||||
<div class="ts-thread-done">
|
||||
<span class="ts-thread-done__check" aria-hidden="true"
|
||||
>✓</span
|
||||
>
|
||||
<ts-rich-editor
|
||||
[placeholder]="
|
||||
'Responde a ' + thread.comment.author.name + '…'
|
||||
"
|
||||
[(value)]="replyBody"
|
||||
[members]="mentionable()"
|
||||
[me]="facade.me()"
|
||||
[upload]="pasteImage"
|
||||
[loader]="loadImage"
|
||||
(submitted)="reply(thread.comment)"
|
||||
/>
|
||||
<div class="ts-reply__actions">
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
text="Responder"
|
||||
[disabled]="!replyBody().trim()"
|
||||
(execute)="reply(thread.comment)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
(click)="replyingTo.set(null)"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
} @else if (project.canReport() && !thread.comment.deleted) {
|
||||
<div class="ts-thread-done__text">
|
||||
<span class="ts-thread-done__topic">{{
|
||||
thread.comment.deleted
|
||||
? 'Conversación'
|
||||
: excerpt(thread.comment.body)
|
||||
}}</span>
|
||||
<span class="ts-muted"
|
||||
>Resuelta{{
|
||||
thread.comment.resolvedBy
|
||||
? ' por ' + thread.comment.resolvedBy.name
|
||||
: ''
|
||||
}}{{
|
||||
conclusionOf(thread)
|
||||
? ': ' + excerpt(conclusionOf(thread) ?? '')
|
||||
: ''
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link ts-reply__open"
|
||||
(click)="startReply(thread.comment)"
|
||||
class="ts-thread__action"
|
||||
(click)="toggleThread(thread.comment.id)"
|
||||
>
|
||||
Responder{{
|
||||
thread.replies.length
|
||||
? ' (' + thread.replies.length + ')'
|
||||
: ''
|
||||
}}
|
||||
Ver conversación ({{ thread.replies.length + 1 }})
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
commentView;
|
||||
context: { $implicit: thread.comment }
|
||||
"
|
||||
/>
|
||||
<div class="ts-replies">
|
||||
@for (reply of thread.replies; track reply.id) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
commentView;
|
||||
context: { $implicit: reply }
|
||||
"
|
||||
/>
|
||||
}
|
||||
@if (typistsIn(thread.comment.id); as typing) {
|
||||
<p class="ts-typing" aria-live="polite">{{ typing }}</p>
|
||||
}
|
||||
@if (resolving() === thread.comment.id) {
|
||||
<form
|
||||
class="ts-form ts-reply"
|
||||
(submit)="
|
||||
$event.preventDefault(); resolve(thread.comment)
|
||||
"
|
||||
novalidate
|
||||
>
|
||||
<label class="ts-resolve__label">
|
||||
Conclusión (opcional): quedará como decisión, arriba del
|
||||
ticket
|
||||
<textarea
|
||||
class="ts-input ts-resolve__text"
|
||||
rows="2"
|
||||
maxlength="20000"
|
||||
placeholder="Vamos con…"
|
||||
[value]="conclusion()"
|
||||
(input)="conclusion.set($any($event.target).value)"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="ts-reply__actions">
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
text="Resolver"
|
||||
(execute)="resolve(thread.comment)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
(click)="resolving.set(null)"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
} @else if (replyingTo() === thread.comment.id) {
|
||||
<form
|
||||
class="ts-form ts-reply"
|
||||
(submit)="$event.preventDefault(); reply(thread.comment)"
|
||||
novalidate
|
||||
>
|
||||
<ts-rich-editor
|
||||
[placeholder]="
|
||||
'Responde a ' + thread.comment.author.name + '…'
|
||||
"
|
||||
[(value)]="replyBody"
|
||||
[members]="mentionable()"
|
||||
[me]="facade.me()"
|
||||
[upload]="pasteImage"
|
||||
[loader]="loadImage"
|
||||
[enterSends]="true"
|
||||
(submitted)="reply(thread.comment)"
|
||||
/>
|
||||
<div class="ts-reply__actions">
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
text="Responder"
|
||||
[disabled]="!replyBody().trim()"
|
||||
(execute)="reply(thread.comment)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
(click)="replyingTo.set(null)"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
} @else {
|
||||
<div class="ts-thread__actions">
|
||||
@if (project.canReport() && !thread.comment.deleted) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-thread__action"
|
||||
(click)="startReply(thread.comment)"
|
||||
>
|
||||
Responder{{
|
||||
thread.replies.length
|
||||
? ' (' + thread.replies.length + ')'
|
||||
: ''
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
@if (thread.comment.resolved) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-thread__action"
|
||||
(click)="
|
||||
facade.resolveThread(thread.comment.id, false)
|
||||
"
|
||||
>
|
||||
Reabrir
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-thread__action"
|
||||
(click)="toggleThread(thread.comment.id)"
|
||||
>
|
||||
Plegar
|
||||
</button>
|
||||
} @else if (
|
||||
thread.replies.length && !thread.comment.deleted
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-thread__action"
|
||||
(click)="startResolve(thread.comment)"
|
||||
>
|
||||
✓ Resolver
|
||||
</button>
|
||||
}
|
||||
} @else if (thread.comment.resolved) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-thread__action"
|
||||
(click)="toggleThread(thread.comment.id)"
|
||||
>
|
||||
Plegar
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (typistsIn(null); as typing) {
|
||||
<p class="ts-typing" aria-live="polite">{{ typing }}</p>
|
||||
}
|
||||
@if (project.canReport()) {
|
||||
<form
|
||||
@@ -1247,6 +1378,11 @@ export class IssuePage {
|
||||
/** The comment being answered, and the answer being written. */
|
||||
protected readonly replyingTo = signal<string | null>(null);
|
||||
protected readonly replyBody = signal('');
|
||||
/** The conversation being settled, and its conclusion. */
|
||||
protected readonly resolving = signal<string | null>(null);
|
||||
protected readonly conclusion = signal('');
|
||||
/** Settled conversations unfolded to read. */
|
||||
protected readonly expanded = signal<ReadonlySet<string>>(new Set());
|
||||
/** Top-level comments, each with its replies (oldest first). */
|
||||
protected readonly threads = computed(() => {
|
||||
const comments = this.facade.comments();
|
||||
@@ -1414,6 +1550,14 @@ export class IssuePage {
|
||||
if (id && this.facade.comments().some((c) => c.id === id))
|
||||
untracked(() => setTimeout(() => this.goToComment(id)));
|
||||
});
|
||||
// Writing an answer or a comment: the others see it as I type.
|
||||
effect(() => {
|
||||
if (this.replyBody().trim())
|
||||
untracked(() => this.presence.typed(this.replyingTo()));
|
||||
});
|
||||
effect(() => {
|
||||
if (this.commentBody().trim()) untracked(() => this.presence.typed(null));
|
||||
});
|
||||
effect(() => {
|
||||
const editing = this.editingInDrawer() || !!this.commentBody().trim();
|
||||
untracked(() => this.presence.setEditing(editing));
|
||||
@@ -1473,6 +1617,17 @@ export class IssuePage {
|
||||
}
|
||||
|
||||
protected move(to: string): void {
|
||||
const open = this.openThreads();
|
||||
if (
|
||||
open &&
|
||||
this.project.workflow().status(to)?.category === StatusCategory.DONE &&
|
||||
!confirm(
|
||||
open === 1
|
||||
? 'Queda una conversación sin resolver. ¿Terminar el ticket igualmente?'
|
||||
: `Quedan ${open} conversaciones sin resolver. ¿Terminar el ticket igualmente?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (this.project.workflow().requiresReason(to)) {
|
||||
this.pendingStatus = to;
|
||||
this.reason().reset({ text: '' });
|
||||
@@ -1565,6 +1720,68 @@ export class IssuePage {
|
||||
this.replyingTo.set(comment.id);
|
||||
}
|
||||
|
||||
protected startResolve(comment: Comment): void {
|
||||
this.conclusion.set('');
|
||||
this.replyingTo.set(null);
|
||||
this.resolving.set(comment.id);
|
||||
}
|
||||
|
||||
protected resolve(comment: Comment): void {
|
||||
this.facade.resolveThread(comment.id, true, this.conclusion());
|
||||
this.resolving.set(null);
|
||||
this.conclusion.set('');
|
||||
// It folds once settled.
|
||||
this.expanded.update((ids) => {
|
||||
const next = new Set(ids);
|
||||
next.delete(comment.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
protected toggleThread(id: string): void {
|
||||
this.expanded.update((ids) => {
|
||||
const next = new Set(ids);
|
||||
if (!next.delete(id)) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/** Conversations with answers still to settle. */
|
||||
protected readonly openThreads = computed(
|
||||
() =>
|
||||
this.threads().filter(
|
||||
(t) => t.replies.length && !t.comment.resolved && !t.comment.deleted,
|
||||
).length,
|
||||
);
|
||||
|
||||
/** What a settled conversation concluded: its last reply marked as a decision. */
|
||||
protected conclusionOf(thread: { replies: Comment[] }): string | null {
|
||||
const decided = thread.replies.filter((r) => r.decision && !r.deleted);
|
||||
return decided[decided.length - 1]?.body ?? null;
|
||||
}
|
||||
|
||||
/** The first words of a comment, on one line. */
|
||||
protected excerpt(body: string): string {
|
||||
const text = body
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[*_`~#>]+/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return text.length > 80 ? `${text.slice(0, 79)}…` : text;
|
||||
}
|
||||
|
||||
/** «Ana está escribiendo…» in a conversation (null: a new comment). */
|
||||
protected typistsIn(threadId: string | null): string | null {
|
||||
const names = this.presence
|
||||
.typists()
|
||||
.filter((t) => t.threadId === threadId)
|
||||
.map((t) => t.user.name);
|
||||
if (!names.length) return null;
|
||||
return names.length === 1
|
||||
? `${names[0]} está escribiendo…`
|
||||
: `${names.slice(0, -1).join(', ')} y ${names[names.length - 1]} están escribiendo…`;
|
||||
}
|
||||
|
||||
protected reply(comment: Comment): void {
|
||||
const body = this.replyBody().trim();
|
||||
if (!body) return;
|
||||
|
||||
@@ -423,7 +423,9 @@ export class RichEditorComponent implements FormValueControl<string> {
|
||||
/** Turns pasting screenshots on. */
|
||||
readonly upload = input<ImageUploader | null>(null);
|
||||
readonly loader = input<AttachmentLoader | null>(null);
|
||||
/** Ctrl/Cmd+Enter. */
|
||||
/** Plain Enter sends too (Shift+Enter breaks the line), like a chat. */
|
||||
readonly enterSends = input(false);
|
||||
/** Ctrl/Cmd+Enter (and Enter with `enterSends`). */
|
||||
readonly submitted = output<void>();
|
||||
readonly touch = output<void>();
|
||||
|
||||
@@ -440,6 +442,7 @@ export class RichEditorComponent implements FormValueControl<string> {
|
||||
});
|
||||
protected readonly hint = computed(() =>
|
||||
[
|
||||
this.enterSends() ? 'Enter envía, Shift+Enter cambia de línea' : '',
|
||||
this.members().length ? 'Escribe @ para mencionar a alguien' : '',
|
||||
this.upload() ? 'pega capturas con Ctrl+V' : '',
|
||||
]
|
||||
@@ -539,6 +542,7 @@ export class RichEditorComponent implements FormValueControl<string> {
|
||||
placeholder: () => this.placeholder(),
|
||||
loader: this.loader(),
|
||||
onSubmit: () => this.submitted.emit(),
|
||||
enterSends: () => this.enterSends(),
|
||||
suggestion: {
|
||||
items: ({ query }) =>
|
||||
this.members()
|
||||
|
||||
@@ -106,6 +106,11 @@ export interface RichTextOptions {
|
||||
suggestion?: Omit<SuggestionOptions<Mentionable>, 'editor'> | null;
|
||||
/** Ctrl/Cmd+Enter. */
|
||||
onSubmit?: () => void;
|
||||
/**
|
||||
* Plain Enter submits too (Shift+Enter breaks the line), like a chat;
|
||||
* not inside a list or code, where Enter means a new item or line.
|
||||
*/
|
||||
enterSends?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,6 +157,17 @@ export function richTextExtensions(
|
||||
options.onSubmit?.();
|
||||
return !!options.onSubmit;
|
||||
},
|
||||
Enter: ({ editor }) => {
|
||||
if (!options.onSubmit || !options.enterSends?.()) return false;
|
||||
if (
|
||||
editor.isActive('codeBlock') ||
|
||||
editor.isActive('listItem') ||
|
||||
editor.isActive('taskItem')
|
||||
)
|
||||
return false;
|
||||
options.onSubmit();
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
Markdown.configure({ markedOptions: { gfm: true, breaks: true } }),
|
||||
|
||||
Reference in New Issue
Block a user