Hutch/Views/Tickets/TicketDetailView.swift
828 lines · 30297 bytes
1import SwiftUI
2import WebKit
3
4struct TicketDetailView: View {
5 let ownerUsername: String
6 let trackerName: String
7 let trackerId: Int
8 let trackerRid: String
9 let ticketId: Int
10
11 @Environment(AppState.self) private var appState
12 @Environment(\.colorScheme) private var colorScheme
13 @State private var viewModel: TicketDetailViewModel?
14
15 // Sheet state
16 @State private var showResolveSheet = false
17 @State private var showAssignSheet = false
18 @State private var showLabelsSheet = false
19
20 // Comment composer mode
21 @State private var commentMode: CommentMode = .write
22
23 private enum CommentMode: String, CaseIterable {
24 case write = "Write"
25 case preview = "Preview"
26 }
27
28 var body: some View {
29 Group {
30 if let viewModel {
31 detailContent(viewModel)
32 } else {
33 SRHTLoadingStateView(message: "Loading ticket…")
34 }
35 }
36 .navigationTitle("#\(ticketId)")
37 .navigationBarTitleDisplayMode(.inline)
38 .toolbar {
39 ToolbarItemGroup(placement: .topBarTrailing) {
40 SRHTShareButton(url: SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId), target: .ticket) {
41 Image(systemName: "square.and.arrow.up")
42 }
43
44 if let viewModel, viewModel.ticket != nil {
45 actionsMenu(viewModel)
46 }
47 }
48 }
49 .task {
50 if viewModel == nil {
51 let vm = TicketDetailViewModel(
52 ownerUsername: ownerUsername,
53 trackerName: trackerName,
54 trackerId: trackerId,
55 trackerRid: trackerRid,
56 ticketId: ticketId,
57 client: appState.client
58 )
59 viewModel = vm
60 await vm.loadTicket()
61 }
62 }
63 }
64
65 // MARK: - Actions Menu
66
67 @ViewBuilder
68 private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View {
69 Menu {
70 if let ticket = viewModel.ticket {
71 if ticket.status == .resolved {
72 Button {
73 Task {
74 await viewModel.updateStatus(
75 status: .reported,
76 resolution: .unresolved
77 )
78 }
79 } label: {
80 SwiftUI.Label("Reopen", systemImage: "arrow.uturn.backward")
81 }
82 } else {
83 Button {
84 showResolveSheet = true
85 } label: {
86 SwiftUI.Label("Resolve", systemImage: "checkmark.circle")
87 }
88 }
89 }
90
91 Button {
92 showAssignSheet = true
93 } label: {
94 SwiftUI.Label("Manage Assignees", systemImage: "person.badge.plus")
95 }
96
97 Button {
98 showLabelsSheet = true
99 Task { await viewModel.loadTrackerLabels() }
100 } label: {
101 SwiftUI.Label("Manage Labels", systemImage: "tag")
102 }
103 } label: {
104 Image(systemName: "ellipsis.circle")
105 }
106 .sheet(isPresented: $showResolveSheet) {
107 ResolveSheet(viewModel: viewModel, isPresented: $showResolveSheet)
108 .presentationDetents([.medium])
109 }
110 .sheet(isPresented: $showAssignSheet) {
111 AssignSheet(viewModel: viewModel, isPresented: $showAssignSheet)
112 .presentationDetents([.medium])
113 }
114 .sheet(isPresented: $showLabelsSheet) {
115 LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet)
116 .presentationDetents([.medium])
117 }
118 }
119
120 // MARK: - Detail Content
121
122 @ViewBuilder
123 private func detailContent(_ viewModel: TicketDetailViewModel) -> some View {
124 @Bindable var vm = viewModel
125
126 if viewModel.isLoading, viewModel.ticket == nil {
127 SRHTLoadingStateView(message: "Loading ticket…")
128 } else if let error = viewModel.error, viewModel.ticket == nil {
129 SRHTErrorStateView(
130 title: "Couldn't Load Ticket",
131 message: error,
132 retryAction: { await viewModel.loadTicket() }
133 )
134 } else if let ticket = viewModel.ticket {
135 ScrollView {
136 VStack(alignment: .leading, spacing: 0) {
137 // Header
138 ticketHeader(ticket)
139
140 Divider()
141 .padding(.vertical, 12)
142
143 // Description
144 if let description = ticket.description, !description.isEmpty {
145 MarkdownContentView(markdown: description)
146 .padding(.horizontal)
147 .padding(.bottom, 16)
148
149 Divider()
150 .padding(.bottom, 12)
151 }
152
153 // Event timeline
154 if !viewModel.events.isEmpty {
155 Text("Activity")
156 .font(.headline)
157 .padding(.horizontal)
158 .padding(.bottom, 8)
159
160 LazyVStack(alignment: .leading, spacing: 0) {
161 ForEach(viewModel.events) { event in
162 EventRow(
163 event: event,
164 ticketSubmitter: viewModel.ticket?.submitter.canonicalName,
165 ticketAssignees: viewModel.ticket?.assignees.map { $0.canonicalName }
166 )
167 if event.id != viewModel.events.last?.id {
168 Divider()
169 .padding(.leading, 40)
170 }
171 }
172 }
173
174 Divider()
175 .padding(.vertical, 12)
176 }
177
178 // Comment input
179 commentInput(viewModel)
180 }
181 }
182 .srhtErrorBanner(error: $vm.error)
183 .refreshable {
184 await viewModel.loadTicket()
185 }
186 }
187 }
188
189 // MARK: - Header
190
191 @ViewBuilder
192 private func ticketHeader(_ ticket: TicketDetail) -> some View {
193 VStack(alignment: .leading, spacing: 8) {
194 Text(ticket.title)
195 .font(.title3.weight(.semibold))
196
197 HStack(spacing: 8) {
198 TicketStatusIcon(status: ticket.status)
199 Text(ticket.status.displayName)
200 .font(.subheadline.weight(.medium))
201
202 if ticket.status == .resolved, let resolution = ticket.resolution {
203 Text("(\(resolution.displayName))")
204 .font(.subheadline)
205 .foregroundStyle(.secondary)
206 }
207 }
208
209 HStack(spacing: 4) {
210 Text("Opened by")
211 .foregroundStyle(.secondary)
212 Text(ticket.submitter.canonicalName)
213 .fontWeight(.medium)
214 Text(ticket.created.relativeDescription)
215 .foregroundStyle(.tertiary)
216 }
217 .font(.caption)
218
219 if !ticket.assignees.isEmpty {
220 HStack(spacing: 4) {
221 Image(systemName: "person.fill")
222 .font(.caption2)
223 .foregroundStyle(.secondary)
224 Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
225 .font(.caption)
226 .foregroundStyle(.secondary)
227 }
228 }
229
230 if !ticket.labels.isEmpty {
231 FlowLayout(spacing: 4) {
232 ForEach(ticket.labels) { label in
233 LabelPill(label: label)
234 }
235 }
236 }
237 }
238 .padding()
239 }
240
241 // MARK: - Comment Input
242
243 @ViewBuilder
244 private func commentInput(_ viewModel: TicketDetailViewModel) -> some View {
245 @Bindable var vm = viewModel
246
247 VStack(alignment: .leading, spacing: 8) {
248 Text("New Comment")
249 .font(.headline)
250
251 Picker("Mode", selection: $commentMode) {
252 ForEach(CommentMode.allCases, id: \.self) { mode in
253 Text(mode.rawValue).tag(mode)
254 }
255 }
256 .pickerStyle(.segmented)
257
258 if commentMode == .write {
259 TextField("Write your comment…", text: $vm.commentText, axis: .vertical)
260 .textFieldStyle(.roundedBorder)
261 .lineLimit(3...8)
262 } else {
263 // Markdown preview
264 if viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
265 Text("Nothing to preview")
266 .font(.subheadline)
267 .foregroundStyle(.secondary)
268 .frame(maxWidth: .infinity, minHeight: 80, alignment: .center)
269 .background(Color(.secondarySystemBackground))
270 .clipShape(RoundedRectangle(cornerRadius: 8))
271 } else {
272 MarkdownContentView(markdown: viewModel.commentText)
273 .frame(minHeight: 80, maxHeight: 200)
274 .clipShape(RoundedRectangle(cornerRadius: 8))
275 }
276 }
277
278 HStack {
279 Spacer()
280 Button {
281 Task { await viewModel.submitComment() }
282 } label: {
283 if viewModel.isSubmitting {
284 ProgressView()
285 .controlSize(.small)
286 } else {
287 Text("Post Comment")
288 }
289 }
290 .buttonStyle(.borderedProminent)
291 .disabled(viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
292 }
293 }
294 .padding()
295 }
296}
297
298// MARK: - Self-Sizing Markdown Web View
299
300/// Renders markdown as HTML in a WKWebView that auto-sizes its height to
301/// fit the rendered content. Reuses the same `markdownToHTML` converter and
302/// styling as the README renderer.
303private struct MarkdownContentView: View {
304 let markdown: String
305 @Environment(\.colorScheme) private var colorScheme
306 @State private var renderedHTML: String?
307
308 var body: some View {
309 Group {
310 if let renderedHTML {
311 HTMLWebView(
312 html: renderedHTML,
313 colorScheme: colorScheme,
314 style: .commentPreview
315 )
316 } else {
317 SRHTLoadingStateView(message: "Preparing content…")
318 .frame(minHeight: 80)
319 }
320 }
321 .task(id: markdown) {
322 if renderedHTML != nil {
323 try? await Task.sleep(for: .milliseconds(150))
324 guard !Task.isCancelled else { return }
325 }
326
327 let html = await Task.detached(priority: .userInitiated) {
328 markdownToHTML(markdown)
329 }.value
330 guard !Task.isCancelled else { return }
331 renderedHTML = html
332 }
333 }
334}
335
336// MARK: - Event Row
337
338private struct EventRow: View {
339 let event: TicketEvent
340 let ticketSubmitter: String?
341 let ticketAssignees: [String]?
342 @State private var isShowingSystemStatusInfo = false
343
344 var body: some View {
345 ForEach(event.changes) { change in
346 HStack(alignment: .top, spacing: 12) {
347 Image(systemName: icon(for: change))
348 .foregroundStyle(color(for: change))
349 .frame(width: 24)
350 .padding(.top, 2)
351
352 VStack(alignment: .leading, spacing: 4) {
353 HStack {
354 if kind(for: change) == .comment {
355 Text(change.author?.canonicalName ?? "")
356 .font(.subheadline.weight(.medium))
357 } else {
358 let descriptionText = description(for: change, in: event, ticketSubmitter: ticketSubmitter, ticketAssignees: ticketAssignees)
359 if descriptionText.hasPrefix("System") {
360 HStack(spacing: 4) {
361 Text(descriptionText)
362 .font(.subheadline)
363 .foregroundStyle(.secondary)
364 Button {
365 isShowingSystemStatusInfo = true
366 } label: {
367 Image(systemName: "info.circle")
368 .foregroundStyle(.gray)
369 }
370 .buttonStyle(.plain)
371 }
372 } else {
373 Text(descriptionText)
374 .font(.subheadline)
375 .foregroundStyle(.secondary)
376 }
377 }
378 Spacer()
379 Text(event.created.relativeDescription)
380 .font(.caption)
381 .foregroundStyle(.tertiary)
382 }
383 if kind(for: change) == .comment, let text = change.text {
384 MarkdownContentView(markdown: text)
385 }
386 }
387 }
388 .padding(.horizontal)
389 .padding(.vertical, 8)
390 .alert("System Status Change", isPresented: $isShowingSystemStatusInfo) {
391 Button("OK", role: .cancel) {}
392 } message: {
393 Text("This status change was recorded automatically or without a named user attached to the event.")
394 }
395 }
396 }
397
398 private func description(for change: EventChange, in event: TicketEvent, ticketSubmitter: String? = nil, ticketAssignees: [String]? = nil) -> String {
399 let eventKind = kind(for: change)
400 let authorName: String
401
402 if let commentAuthor = event.changes.first(where: {
403 kind(for: $0) == .comment && $0.author != nil
404 })?.author?.canonicalName {
405 authorName = commentAuthor
406 } else {
407 switch eventKind {
408 case .created:
409 authorName = change.author?.canonicalName ?? ticketSubmitter ?? "Someone"
410 case .statusChange:
411 authorName = "System"
412 case .labelAdded, .labelRemoved, .labelUpdated:
413 authorName = change.labeler?.canonicalName ?? "Someone"
414 case .assigned, .unassigned:
415 authorName = change.assigner?.canonicalName ?? "Someone"
416 case .comment:
417 authorName = change.author?.canonicalName ?? "Someone"
418 case .ticketMention, .userMention:
419 authorName = change.author?.canonicalName
420 ?? change.assigner?.canonicalName
421 ?? change.labeler?.canonicalName
422 ?? "Someone"
423 case .unknown:
424 authorName = change.author?.canonicalName
425 ?? change.assigner?.canonicalName
426 ?? change.labeler?.canonicalName
427 ?? change.assignee?.canonicalName
428 ?? change.mentioned?.canonicalName
429 ?? ticketAssignees?.first
430 ?? "Someone"
431 }
432 }
433
434 switch eventKind {
435 case .statusChange:
436 let oldStatus = change.oldStatus?.displayName ?? "unknown"
437 let newStatus = change.newStatus?.displayName ?? "unknown"
438 return "\(authorName) changed status from \(oldStatus) to \(newStatus)"
439 case .labelUpdated, .labelAdded:
440 let labelName = change.label?.name ?? "a label"
441 let verb = eventKind == .labelAdded ? "added" : "updated"
442 return "\(authorName) \(verb) label \"\(labelName)\""
443 case .labelRemoved:
444 let labelName = change.label?.name ?? "a label"
445 return "\(authorName) removed label \"\(labelName)\""
446 case .assigned:
447 let assigneeName = change.assignee?.canonicalName ?? "someone"
448 return "\(authorName) assigned \(assigneeName)"
449 case .unassigned:
450 let assigneeName = change.assignee?.canonicalName ?? "someone"
451 return "\(authorName) unassigned \(assigneeName)"
452 case .ticketMention:
453 if let ticketId = change.mentioned?.id {
454 return "\(authorName) mentioned ticket #\(ticketId)"
455 }
456 return "\(authorName) mentioned another ticket"
457 case .userMention:
458 let user = change.mentioned?.canonicalName ?? "someone"
459 return "\(authorName) mentioned \(user)"
460 case .created:
461 return "\(authorName) opened this ticket"
462 case .comment:
463 return "\(authorName) commented"
464 case .unknown:
465 return "\(authorName) updated this ticket"
466 }
467 }
468
469 private func icon(for change: EventChange) -> String {
470 switch kind(for: change) {
471 case .comment:
472 "text.bubble"
473 case .statusChange:
474 "arrow.triangle.2.circlepath"
475 case .labelAdded, .labelRemoved, .labelUpdated:
476 "tag"
477 case .assigned:
478 "person.badge.plus"
479 case .unassigned:
480 "person.badge.minus"
481 case .ticketMention, .userMention:
482 "at"
483 case .created:
484 "plus.circle"
485 case .unknown:
486 "circle.fill"
487 }
488 }
489
490 private func color(for change: EventChange) -> Color {
491 switch kind(for: change) {
492 case .comment:
493 .blue
494 case .statusChange:
495 change.newStatus == .resolved ? .green : .orange
496 case .labelAdded, .labelRemoved, .labelUpdated:
497 .purple
498 case .assigned, .unassigned:
499 .cyan
500 case .ticketMention, .userMention:
501 .indigo
502 case .created:
503 .green
504 case .unknown:
505 .gray
506 }
507 }
508
509 private func kind(for change: EventChange) -> EventKind {
510 switch change.eventType {
511 case "COMMENT", "Comment":
512 .comment
513 case "STATUS_CHANGE", "StatusChange":
514 .statusChange
515 case "LABEL_UPDATE", "LabelUpdate":
516 .labelUpdated
517 case "LABEL_ADDED", "LabelAdded":
518 .labelAdded
519 case "LABEL_REMOVED", "LabelRemoved":
520 .labelRemoved
521 case "ASSIGNMENT", "Assignment", "ASSIGNED_USER", "AssignedUser":
522 .assigned
523 case "UNASSIGNED_USER", "UnassignedUser":
524 .unassigned
525 case "TICKET_MENTION", "TicketMention":
526 .ticketMention
527 case "USER_MENTION", "UserMention":
528 .userMention
529 case "CREATED", "Created":
530 .created
531 default:
532 .unknown
533 }
534 }
535
536 private enum EventKind: Equatable {
537 case comment
538 case statusChange
539 case labelUpdated
540 case labelAdded
541 case labelRemoved
542 case assigned
543 case unassigned
544 case ticketMention
545 case userMention
546 case created
547 case unknown
548 }
549}
550
551// MARK: - Resolve Sheet
552
553private struct ResolveSheet: View {
554 let viewModel: TicketDetailViewModel
555 @Binding var isPresented: Bool
556 @State private var selectedResolution: TicketResolution = .fixed
557
558 private static let resolutionOptions: [TicketResolution] = [
559 .closed, .fixed, .implemented, .wontFix,
560 .byDesign, .invalid, .duplicate, .notOurBug
561 ]
562
563 var body: some View {
564 NavigationStack {
565 Form {
566 Section("Resolution") {
567 Picker("Resolution", selection: $selectedResolution) {
568 ForEach(Self.resolutionOptions, id: \.self) { resolution in
569 Text(resolution.displayName).tag(resolution)
570 }
571 }
572 .pickerStyle(.inline)
573 .labelsHidden()
574 }
575 }
576 .navigationTitle("Resolve Ticket")
577 .navigationBarTitleDisplayMode(.inline)
578 .toolbar {
579 ToolbarItem(placement: .cancellationAction) {
580 Button("Cancel") { isPresented = false }
581 }
582 ToolbarItem(placement: .confirmationAction) {
583 Button("Mark Resolved") {
584 Task {
585 await viewModel.updateStatus(
586 status: .resolved,
587 resolution: selectedResolution
588 )
589 if viewModel.error == nil {
590 isPresented = false
591 }
592 }
593 }
594 .disabled(viewModel.isPerformingAction)
595 }
596 }
597 .overlay {
598 if viewModel.isPerformingAction {
599 ProgressView()
600 }
601 }
602 }
603 }
604}
605
606// MARK: - Assign Sheet
607
608private struct AssignSheet: View {
609 let viewModel: TicketDetailViewModel
610 @Binding var isPresented: Bool
611 @State private var username = ""
612
613 var body: some View {
614 NavigationStack {
615 Form {
616 // Current assignees with remove buttons
617 if let ticket = viewModel.ticket, !ticket.assignees.isEmpty {
618 Section("Current Assignees") {
619 ForEach(ticket.assignees, id: \.canonicalName) { assignee in
620 HStack {
621 Text(assignee.canonicalName)
622 Spacer()
623 Button(role: .destructive) {
624 Task {
625 await viewModel.unassignUser(
626 username: assignee.canonicalName
627 )
628 }
629 } label: {
630 Image(systemName: "minus.circle.fill")
631 .foregroundStyle(.red)
632 }
633 .buttonStyle(.plain)
634 }
635 }
636 }
637 }
638
639 Section("Add Assignee") {
640 TextField("Username or ~username", text: $username)
641 .textContentType(.username)
642 .autocorrectionDisabled()
643 .textInputAutocapitalization(.never)
644
645 Button("Add Assignee") {
646 let name = username.trimmingCharacters(in: .whitespacesAndNewlines)
647 guard !name.isEmpty else { return }
648 Task {
649 await viewModel.assignUser(username: name)
650 username = ""
651 }
652 }
653 .disabled(
654 username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
655 || viewModel.isPerformingAction
656 )
657 }
658 }
659 .navigationTitle("Assignees")
660 .navigationBarTitleDisplayMode(.inline)
661 .toolbar {
662 ToolbarItem(placement: .confirmationAction) {
663 Button("Done") { isPresented = false }
664 }
665 }
666 .overlay {
667 if viewModel.isPerformingAction {
668 ProgressView()
669 }
670 }
671 }
672 }
673}
674
675// MARK: - Labels Sheet
676
677private struct LabelsSheet: View {
678 let viewModel: TicketDetailViewModel
679 @Binding var isPresented: Bool
680 @State private var showCreateLabel = false
681
682 var body: some View {
683 NavigationStack {
684 Group {
685 if viewModel.trackerLabels.isEmpty {
686 if viewModel.isPerformingAction {
687 ProgressView()
688 } else {
689 ContentUnavailableView(
690 "No Labels",
691 systemImage: "tag",
692 description: Text("This tracker has no labels defined.")
693 )
694 }
695 } else {
696 List {
697 ForEach(viewModel.trackerLabels) { label in
698 LabelToggleRow(
699 label: label,
700 isApplied: viewModel.ticket?.labels.contains(where: { $0.id == label.id }) ?? false,
701 isLoading: viewModel.isPerformingAction
702 ) { shouldApply in
703 Task {
704 if shouldApply {
705 await viewModel.labelTicket(labelId: label.id)
706 } else {
707 await viewModel.unlabelTicket(labelId: label.id)
708 }
709 }
710 }
711 }
712 }
713 }
714 }
715 .navigationTitle("Labels")
716 .navigationBarTitleDisplayMode(.inline)
717 .toolbar {
718 ToolbarItem(placement: .cancellationAction) {
719 Button("Done") { isPresented = false }
720 }
721 ToolbarItem(placement: .primaryAction) {
722 Button {
723 showCreateLabel = true
724 } label: {
725 SwiftUI.Label("New Label", systemImage: "plus")
726 }
727 }
728 }
729 .sheet(isPresented: $showCreateLabel) {
730 CreateLabelSheet(viewModel: viewModel, isPresented: $showCreateLabel)
731 .presentationDetents([.medium])
732 }
733 }
734 }
735}
736
737// MARK: - Create Label Sheet
738
739private struct CreateLabelSheet: View {
740 let viewModel: TicketDetailViewModel
741 @Binding var isPresented: Bool
742 @State private var labelName = ""
743 @State private var backgroundColor = Color.blue
744 @State private var foregroundColor = Color.white
745
746 var body: some View {
747 NavigationStack {
748 Form {
749 Section("Label Details") {
750 TextField("Label name", text: $labelName)
751 .autocorrectionDisabled()
752 }
753
754 Section("Colors") {
755 ColorPicker("Background color", selection: $backgroundColor, supportsOpacity: false)
756 ColorPicker("Text color", selection: $foregroundColor, supportsOpacity: false)
757 }
758
759 Section("Preview") {
760 HStack {
761 Spacer()
762 Text(labelName.isEmpty ? "Label" : labelName)
763 .font(.caption2.weight(.medium))
764 .padding(.horizontal, 6)
765 .padding(.vertical, 2)
766 .background(backgroundColor)
767 .foregroundStyle(foregroundColor)
768 .clipShape(Capsule())
769 Spacer()
770 }
771 }
772 }
773 .navigationTitle("New Label")
774 .navigationBarTitleDisplayMode(.inline)
775 .toolbar {
776 ToolbarItem(placement: .cancellationAction) {
777 Button("Cancel") { isPresented = false }
778 }
779 ToolbarItem(placement: .confirmationAction) {
780 Button("Create Label") {
781 Task {
782 await viewModel.createLabel(
783 name: labelName.trimmingCharacters(in: .whitespacesAndNewlines),
784 backgroundColor: backgroundColor.hexString,
785 foregroundColor: foregroundColor.hexString
786 )
787 if viewModel.error == nil {
788 isPresented = false
789 }
790 }
791 }
792 .disabled(
793 labelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
794 || viewModel.isPerformingAction
795 )
796 }
797 }
798 .overlay {
799 if viewModel.isPerformingAction {
800 ProgressView()
801 }
802 }
803 }
804 }
805}
806
807private struct LabelToggleRow: View {
808 let label: TicketLabel
809 let isApplied: Bool
810 let isLoading: Bool
811 let onToggle: (Bool) -> Void
812
813 var body: some View {
814 Button {
815 onToggle(!isApplied)
816 } label: {
817 HStack {
818 LabelPill(label: label)
819 Spacer()
820 if isApplied {
821 Image(systemName: "checkmark")
822 .foregroundStyle(.blue)
823 }
824 }
825 }
826 .disabled(isLoading)
827 }
828}