iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: extract shared CommentSheetContent for gallery + story

Both gallery and story comment sheets rendered the same threaded list,
glass input pill, mention autocomplete, and reply banner through
duplicate code. Extract CommentSheetContent as the shared inner view;
each wrapper now owns only its VM, load lifecycle, and post/delete
closures. Adopt the story path's optimistic-clear post flow in both,
and route the gallery path through CommentService to match.

StoryCommentSheet's init signature is unchanged.

+265 -375
+214
Grain/Views/Comments/CommentSheetContent.swift
··· 1 + import SwiftUI 2 + 3 + enum CommentDismissStyle { 4 + case xmark 5 + case done 6 + } 7 + 8 + struct CommentSheetContent: View { 9 + let comments: [GrainComment] 10 + let isLoading: Bool 11 + let isPostingComment: Bool 12 + 13 + var onPost: (String, GrainComment?) async -> Void 14 + var onDelete: (GrainComment) async -> Void 15 + 16 + var onDismiss: () -> Void = {} 17 + var onProfileTap: ((String) -> Void)? 18 + var onHashtagTap: ((String) -> Void)? 19 + var onStoryTap: ((GrainStoryAuthor) -> Void)? 20 + 21 + var dismissStyle: CommentDismissStyle = .xmark 22 + var focusOnAppear: Bool = false 23 + 24 + @Environment(AuthManager.self) private var auth 25 + @Environment(\.dismiss) private var dismiss 26 + 27 + @State private var commentText = "" 28 + @State private var replyingTo: GrainComment? 29 + @State private var mentionState = MentionAutocompleteState() 30 + @FocusState private var commentFocused: Bool 31 + 32 + private var threadedComments: [(root: GrainComment, replies: [GrainComment])] { 33 + let roots = comments.filter { $0.replyTo == nil } 34 + let replyMap = Dictionary(grouping: comments.filter { $0.replyTo != nil }, by: { $0.replyTo! }) 35 + return roots.map { root in 36 + (root: root, replies: replyMap[root.uri] ?? []) 37 + } 38 + } 39 + 40 + var body: some View { 41 + NavigationStack { 42 + VStack(spacing: 0) { 43 + commentList 44 + } 45 + .safeAreaInset(edge: .bottom) { 46 + VStack(spacing: 0) { 47 + MentionSuggestionOverlay(state: mentionState) { suggestion in 48 + mentionState.complete(handle: suggestion.handle, in: &commentText) 49 + } 50 + glassInputPill 51 + } 52 + } 53 + .navigationTitle("Comments") 54 + .navigationBarTitleDisplayMode(.inline) 55 + .toolbar { 56 + switch dismissStyle { 57 + case .xmark: 58 + ToolbarItem(placement: .topBarLeading) { 59 + Button { 60 + onDismiss() 61 + } label: { 62 + Image(systemName: "xmark") 63 + .font(.subheadline.weight(.semibold)) 64 + .foregroundStyle(.primary) 65 + } 66 + } 67 + case .done: 68 + ToolbarItem(placement: .cancellationAction) { 69 + Button("Done") { 70 + onDismiss() 71 + dismiss() 72 + } 73 + } 74 + } 75 + } 76 + } 77 + .task { 78 + if focusOnAppear { 79 + commentFocused = true 80 + } 81 + } 82 + } 83 + 84 + // MARK: - Comment list 85 + 86 + @ViewBuilder 87 + private var commentList: some View { 88 + if isLoading, comments.isEmpty { 89 + Spacer() 90 + ProgressView() 91 + Spacer() 92 + } else if comments.isEmpty { 93 + Spacer() 94 + Text("No comments yet") 95 + .foregroundStyle(.secondary) 96 + .font(.subheadline) 97 + Spacer() 98 + } else { 99 + ScrollView { 100 + LazyVStack(alignment: .leading, spacing: 0) { 101 + ForEach(threadedComments, id: \.root.id) { thread in 102 + CommentRow( 103 + comment: thread.root, 104 + userDID: auth.userDID, 105 + isOwn: thread.root.author.did == auth.userDID, 106 + isReply: false, 107 + onProfileTap: onProfileTap, 108 + onHashtagTap: onHashtagTap, 109 + onStoryTap: onStoryTap, 110 + onReply: { startReply(to: thread.root) }, 111 + onDelete: { Task { await onDelete(thread.root) } } 112 + ) 113 + 114 + ForEach(thread.replies) { reply in 115 + CommentRow( 116 + comment: reply, 117 + userDID: auth.userDID, 118 + isOwn: reply.author.did == auth.userDID, 119 + isReply: true, 120 + onProfileTap: onProfileTap, 121 + onHashtagTap: onHashtagTap, 122 + onStoryTap: onStoryTap, 123 + onReply: { startReplyToReply(reply, root: thread.root) }, 124 + onDelete: { Task { await onDelete(reply) } } 125 + ) 126 + } 127 + } 128 + } 129 + } 130 + } 131 + } 132 + 133 + // MARK: - Glass input pill 134 + 135 + private var glassInputPill: some View { 136 + VStack(spacing: 0) { 137 + if let replyTarget = replyingTo { 138 + HStack { 139 + Text("Replying to @\(replyTarget.author.handle)") 140 + .font(.caption) 141 + .foregroundStyle(.secondary) 142 + Spacer() 143 + Button { 144 + replyingTo = nil 145 + } label: { 146 + Image(systemName: "xmark.circle.fill") 147 + .font(.caption) 148 + .foregroundStyle(.secondary) 149 + } 150 + } 151 + .padding(.horizontal, 16) 152 + .padding(.top, 4) 153 + } 154 + 155 + GlassEffectContainer(spacing: 8) { 156 + HStack(alignment: .bottom, spacing: 10) { 157 + TextField(replyingTo != nil ? "Reply..." : "Add a comment...", text: $commentText, axis: .vertical) 158 + .textFieldStyle(.plain) 159 + .font(.body) 160 + .focused($commentFocused) 161 + .lineLimit(1 ... 5) 162 + .onChange(of: commentText) { mentionState.update(text: commentText) } 163 + .padding(.horizontal, 18) 164 + .padding(.vertical, 12) 165 + .glassEffect(.regular, in: .capsule) 166 + 167 + let isEmpty = commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 168 + if !isEmpty { 169 + Button { 170 + Task { await postComment() } 171 + } label: { 172 + Image(systemName: "arrow.up.circle.fill") 173 + .font(.system(size: 28)) 174 + .foregroundStyle(Color("AccentColor")) 175 + .frame(width: 44, height: 44) 176 + } 177 + .glassEffect(.regular.interactive(), in: .circle) 178 + .disabled(isPostingComment) 179 + .transition(.scale.combined(with: .opacity)) 180 + } 181 + } 182 + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: commentText.isEmpty) 183 + .padding(.horizontal, 12) 184 + .padding(.vertical, 10) 185 + } 186 + } 187 + .padding(.horizontal, 12) 188 + .padding(.bottom, 8) 189 + } 190 + 191 + // MARK: - Actions 192 + 193 + private func startReply(to comment: GrainComment) { 194 + replyingTo = comment 195 + commentText = "" 196 + commentFocused = true 197 + } 198 + 199 + private func startReplyToReply(_ reply: GrainComment, root: GrainComment) { 200 + replyingTo = root 201 + commentText = "@\(reply.author.handle) " 202 + commentFocused = true 203 + } 204 + 205 + private func postComment() async { 206 + let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines) 207 + guard !text.isEmpty else { return } 208 + let reply = replyingTo 209 + commentText = "" 210 + replyingTo = nil 211 + commentFocused = false 212 + await onPost(text, reply) 213 + } 214 + }
+34 -199
Grain/Views/Gallery/CommentSheetView.swift
··· 3 3 struct CommentSheetView: View { 4 4 @Environment(AuthManager.self) private var auth 5 5 @State private var viewModel: GalleryDetailViewModel 6 - @State private var commentText = "" 7 6 @State private var isPostingComment = false 8 - @State private var replyingTo: GrainComment? 9 - @State private var mentionState = MentionAutocompleteState() 10 - @FocusState private var commentFocused: Bool 11 7 12 8 let client: XRPCClient 13 9 let galleryUri: String ··· 36 32 _viewModel = State(initialValue: GalleryDetailViewModel(client: client)) 37 33 } 38 34 39 - private var threadedComments: [(root: GrainComment, replies: [GrainComment])] { 40 - let roots = viewModel.comments.filter { $0.replyTo == nil } 41 - let replyMap = Dictionary(grouping: viewModel.comments.filter { $0.replyTo != nil }, by: { $0.replyTo! }) 42 - return roots.map { root in 43 - (root: root, replies: replyMap[root.uri] ?? []) 44 - } 45 - } 46 - 47 35 var body: some View { 48 - NavigationStack { 49 - VStack(spacing: 0) { 50 - ScrollView { 51 - if viewModel.comments.isEmpty, !viewModel.isLoading { 52 - Text("No comments yet") 53 - .font(.subheadline) 54 - .foregroundStyle(.tertiary) 55 - .frame(maxWidth: .infinity) 56 - .padding(.top, 60) 57 - } else if viewModel.isLoading, viewModel.comments.isEmpty { 58 - ProgressView() 59 - .frame(maxWidth: .infinity) 60 - .padding(.top, 60) 61 - } else { 62 - LazyVStack(alignment: .leading, spacing: 0) { 63 - ForEach(threadedComments, id: \.root.id) { thread in 64 - CommentRow( 65 - comment: thread.root, 66 - userDID: auth.userDID, 67 - isOwn: thread.root.author.did == auth.userDID, 68 - isReply: false, 69 - onProfileTap: { did in 70 - onDismiss() 71 - onProfileTap?(did) 72 - }, 73 - onHashtagTap: { tag in 74 - onDismiss() 75 - onHashtagTap?(tag) 76 - }, 77 - onStoryTap: { author in 78 - onDismiss() 79 - onStoryTap?(author) 80 - }, 81 - onReply: { startReply(to: thread.root) }, 82 - onDelete: { Task { await deleteComment(thread.root) } } 83 - ) 84 - 85 - ForEach(thread.replies) { reply in 86 - CommentRow( 87 - comment: reply, 88 - userDID: auth.userDID, 89 - isOwn: reply.author.did == auth.userDID, 90 - isReply: true, 91 - onProfileTap: { did in 92 - onDismiss() 93 - onProfileTap?(did) 94 - }, 95 - onHashtagTap: { tag in 96 - onDismiss() 97 - onHashtagTap?(tag) 98 - }, 99 - onStoryTap: { author in 100 - onDismiss() 101 - onStoryTap?(author) 102 - }, 103 - onReply: { startReplyToReply(reply, root: thread.root) }, 104 - onDelete: { Task { await deleteComment(reply) } } 105 - ) 106 - } 107 - } 108 - } 109 - } 110 - } 111 - .safeAreaInset(edge: .bottom) { 112 - VStack(spacing: 0) { 113 - MentionSuggestionOverlay(state: mentionState) { suggestion in 114 - mentionState.complete(handle: suggestion.handle, in: &commentText) 115 - } 116 - 117 - if let replyTarget = replyingTo { 118 - HStack { 119 - Text("Replying to @\(replyTarget.author.handle)") 120 - .font(.caption) 121 - .foregroundStyle(.secondary) 122 - Spacer() 123 - Button { 124 - replyingTo = nil 125 - } label: { 126 - Image(systemName: "xmark.circle.fill") 127 - .font(.caption) 128 - .foregroundStyle(.secondary) 129 - } 130 - } 131 - .padding(.horizontal, 16) 132 - .padding(.top, 4) 133 - } 134 - 135 - GlassEffectContainer(spacing: 8) { 136 - HStack(alignment: .bottom, spacing: 10) { 137 - TextField( 138 - replyingTo != nil ? "Reply..." : "Add a comment...", 139 - text: $commentText, 140 - axis: .vertical 141 - ) 142 - .textFieldStyle(.plain) 143 - .font(.body) 144 - .focused($commentFocused) 145 - .lineLimit(1 ... 6) 146 - .onChange(of: commentText) { mentionState.update(text: commentText) } 147 - .padding(.horizontal, 18) 148 - .padding(.vertical, 14) 149 - .glassEffect(.regular, in: .capsule) 150 - 151 - let isEmpty = commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 152 - if !isEmpty { 153 - Button { 154 - Task { await postComment() } 155 - } label: { 156 - Image(systemName: "arrow.up.circle.fill") 157 - .font(.system(size: 28)) 158 - .foregroundStyle(Color("AccentColor")) 159 - .frame(width: 44, height: 44) 160 - } 161 - .glassEffect(.regular.interactive(), in: .circle) 162 - .disabled(isPostingComment) 163 - .transition(.scale.combined(with: .opacity)) 164 - } 165 - } 166 - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: commentText.isEmpty) 167 - .padding(.horizontal, 12) 168 - .padding(.vertical, 10) 169 - } 170 - } 171 - } 172 - } 173 - .navigationTitle("Comments") 174 - .navigationBarTitleDisplayMode(.inline) 175 - .toolbar { 176 - ToolbarItem(placement: .topBarLeading) { 177 - Button { 178 - onDismiss() 179 - } label: { 180 - Image(systemName: "xmark") 181 - .font(.subheadline.weight(.semibold)) 182 - .foregroundStyle(.primary) 183 - } 184 - } 185 - } 186 - } 36 + CommentSheetContent( 37 + comments: viewModel.comments, 38 + isLoading: viewModel.isLoading, 39 + isPostingComment: isPostingComment, 40 + onPost: { text, replyTo in 41 + guard let authContext = await auth.authContext() else { return } 42 + isPostingComment = true 43 + do { 44 + _ = try await CommentService.create( 45 + subject: galleryUri, 46 + text: text, 47 + replyTo: replyTo?.uri, 48 + client: client, 49 + auth: authContext 50 + ) 51 + await viewModel.load(uri: galleryUri, auth: authContext) 52 + onCommentCountChanged?(viewModel.comments.count) 53 + } catch {} 54 + isPostingComment = false 55 + }, 56 + onDelete: { comment in 57 + guard let authContext = await auth.authContext() else { return } 58 + do { 59 + try await CommentService.delete(commentUri: comment.uri, client: client, auth: authContext) 60 + viewModel.comments.removeAll { $0.uri == comment.uri } 61 + onCommentCountChanged?(viewModel.comments.count) 62 + } catch {} 63 + }, 64 + onDismiss: onDismiss, 65 + onProfileTap: onProfileTap.map { cb in { did in onDismiss(); cb(did) } }, 66 + onHashtagTap: onHashtagTap.map { cb in { tag in onDismiss(); cb(tag) } }, 67 + onStoryTap: onStoryTap.map { cb in { author in onDismiss(); cb(author) } }, 68 + dismissStyle: .xmark 69 + ) 187 70 .presentationDetents([.medium, .large]) 188 71 .presentationDragIndicator(.visible) 189 72 .task { 190 73 await viewModel.load(uri: galleryUri, auth: auth.authContext()) 191 74 } 192 - } 193 - 194 - private func startReply(to comment: GrainComment) { 195 - replyingTo = comment 196 - commentText = "" 197 - commentFocused = true 198 - } 199 - 200 - private func startReplyToReply(_ reply: GrainComment, root: GrainComment) { 201 - replyingTo = root 202 - commentText = "@\(reply.author.handle) " 203 - commentFocused = true 204 - } 205 - 206 - private func postComment() async { 207 - let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines) 208 - guard !text.isEmpty, let authContext = await auth.authContext() else { return } 209 - 210 - isPostingComment = true 211 - var recordDict: [String: String] = [ 212 - "text": text, 213 - "subject": galleryUri, 214 - "createdAt": DateFormatting.nowISO(), 215 - ] 216 - if let replyTarget = replyingTo { 217 - recordDict["replyTo"] = replyTarget.uri 218 - } 219 - let record = AnyCodable(recordDict) 220 - let repo = TokenStorage.userDID ?? "" 221 - do { 222 - _ = try await client.createRecord(collection: "social.grain.comment", repo: repo, record: record, auth: authContext) 223 - commentText = "" 224 - replyingTo = nil 225 - commentFocused = false 226 - await viewModel.load(uri: galleryUri, auth: authContext) 227 - onCommentCountChanged?(viewModel.comments.count) 228 - } catch {} 229 - isPostingComment = false 230 - } 231 - 232 - private func deleteComment(_ comment: GrainComment) async { 233 - guard let authContext = await auth.authContext() else { return } 234 - let rkey = comment.uri.split(separator: "/").last.map(String.init) ?? "" 235 - do { 236 - try await client.deleteRecord(collection: "social.grain.comment", rkey: rkey, auth: authContext) 237 - viewModel.comments.removeAll { $0.uri == comment.uri } 238 - onCommentCountChanged?(viewModel.comments.count) 239 - } catch {} 240 75 } 241 76 }
+17 -176
Grain/Views/Stories/StoryCommentSheet.swift
··· 1 1 import SwiftUI 2 2 3 3 struct StoryCommentSheet: View { 4 - @Environment(\.dismiss) private var dismiss 5 4 @Environment(AuthManager.self) private var auth 6 5 @Environment(StoryStatusCache.self) private var storyStatusCache 7 6 @Environment(ViewedStoryStorage.self) private var viewedStories ··· 12 11 var onProfileTap: ((String) -> Void)? 13 12 var onDismiss: (() -> Void)? 14 13 15 - @State private var commentText = "" 16 - @State private var replyingTo: GrainComment? 17 - @State private var mentionState = MentionAutocompleteState() 18 - @FocusState private var commentFocused: Bool 19 - 20 - private var threadedComments: [(root: GrainComment, replies: [GrainComment])] { 21 - let roots = viewModel.comments.filter { $0.replyTo == nil } 22 - let replyMap = Dictionary(grouping: viewModel.comments.filter { $0.replyTo != nil }, by: { $0.replyTo! }) 23 - return roots.map { root in 24 - (root: root, replies: replyMap[root.uri] ?? []) 25 - } 26 - } 27 - 28 14 var body: some View { 29 - NavigationStack { 30 - VStack(spacing: 0) { 31 - commentList 32 - } 33 - .safeAreaInset(edge: .bottom) { 34 - VStack(spacing: 0) { 35 - MentionSuggestionOverlay(state: mentionState) { suggestion in 36 - mentionState.complete(handle: suggestion.handle, in: &commentText) 37 - } 38 - glassInputPill 39 - } 40 - } 41 - .navigationTitle("Comments") 42 - .navigationBarTitleDisplayMode(.inline) 43 - .toolbar { 44 - ToolbarItem(placement: .cancellationAction) { 45 - Button("Done") { 46 - onDismiss?() 47 - dismiss() 48 - } 49 - } 50 - } 51 - } 15 + CommentSheetContent( 16 + comments: viewModel.comments, 17 + isLoading: viewModel.isLoading, 18 + isPostingComment: viewModel.isPostingComment, 19 + onPost: { text, replyTo in 20 + guard let authContext = await auth.authContext() else { return } 21 + await viewModel.postComment(text: text, storyUri: storyUri, replyTo: replyTo, auth: authContext) 22 + }, 23 + onDelete: { comment in 24 + guard let authContext = await auth.authContext() else { return } 25 + await viewModel.deleteComment(comment, storyUri: storyUri, auth: authContext) 26 + }, 27 + onDismiss: { onDismiss?() }, 28 + onProfileTap: onProfileTap, 29 + dismissStyle: .done, 30 + focusOnAppear: focusInput 31 + ) 52 32 .presentationDetents([.medium, .large]) 53 33 .task { 54 34 await viewModel.loadComments(storyUri: storyUri, auth: auth.authContext()) 55 - if focusInput { 56 - commentFocused = true 57 - } 58 35 } 59 - } 60 - 61 - // MARK: - Comment list 62 - 63 - @ViewBuilder 64 - private var commentList: some View { 65 - if viewModel.isLoading, viewModel.comments.isEmpty { 66 - Spacer() 67 - ProgressView() 68 - Spacer() 69 - } else if viewModel.comments.isEmpty { 70 - Spacer() 71 - Text("No comments yet") 72 - .foregroundStyle(.secondary) 73 - .font(.subheadline) 74 - Spacer() 75 - } else { 76 - ScrollView { 77 - LazyVStack(alignment: .leading, spacing: 0) { 78 - ForEach(threadedComments, id: \.root.id) { thread in 79 - CommentRow( 80 - comment: thread.root, 81 - userDID: auth.userDID, 82 - isOwn: thread.root.author.did == auth.userDID, 83 - isReply: false, 84 - onProfileTap: onProfileTap, 85 - onHashtagTap: nil, 86 - onStoryTap: nil, 87 - onReply: { startReply(to: thread.root) }, 88 - onDelete: { Task { await deleteComment(thread.root) } } 89 - ) 90 - 91 - ForEach(thread.replies) { reply in 92 - CommentRow( 93 - comment: reply, 94 - userDID: auth.userDID, 95 - isOwn: reply.author.did == auth.userDID, 96 - isReply: true, 97 - onProfileTap: onProfileTap, 98 - onHashtagTap: nil, 99 - onStoryTap: nil, 100 - onReply: { startReplyToReply(reply, root: thread.root) }, 101 - onDelete: { Task { await deleteComment(reply) } } 102 - ) 103 - } 104 - } 105 - } 106 - } 107 - } 108 - } 109 - 110 - // MARK: - Glass input pill 111 - 112 - private var glassInputPill: some View { 113 - VStack(spacing: 0) { 114 - if let replyTarget = replyingTo { 115 - HStack { 116 - Text("Replying to @\(replyTarget.author.handle)") 117 - .font(.caption) 118 - .foregroundStyle(.secondary) 119 - Spacer() 120 - Button { 121 - replyingTo = nil 122 - } label: { 123 - Image(systemName: "xmark.circle.fill") 124 - .font(.caption) 125 - .foregroundStyle(.secondary) 126 - } 127 - } 128 - .padding(.horizontal, 16) 129 - .padding(.top, 4) 130 - } 131 - 132 - GlassEffectContainer(spacing: 8) { 133 - HStack(alignment: .bottom, spacing: 10) { 134 - TextField(replyingTo != nil ? "Reply..." : "Add a comment...", text: $commentText, axis: .vertical) 135 - .textFieldStyle(.plain) 136 - .font(.body) 137 - .focused($commentFocused) 138 - .lineLimit(1 ... 5) 139 - .onChange(of: commentText) { mentionState.update(text: commentText) } 140 - .padding(.horizontal, 18) 141 - .padding(.vertical, 12) 142 - .glassEffect(.regular, in: .capsule) 143 - 144 - let isEmpty = commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 145 - if !isEmpty { 146 - Button { 147 - Task { await postComment() } 148 - } label: { 149 - Image(systemName: "arrow.up.circle.fill") 150 - .font(.system(size: 28)) 151 - .foregroundStyle(Color("AccentColor")) 152 - .frame(width: 44, height: 44) 153 - } 154 - .glassEffect(.regular.interactive(), in: .circle) 155 - .disabled(viewModel.isPostingComment) 156 - .transition(.scale.combined(with: .opacity)) 157 - } 158 - } 159 - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: commentText.isEmpty) 160 - .padding(.horizontal, 12) 161 - .padding(.vertical, 10) 162 - } 163 - } 164 - .padding(.horizontal, 12) 165 - .padding(.bottom, 8) 166 - } 167 - 168 - // MARK: - Actions 169 - 170 - private func startReply(to comment: GrainComment) { 171 - replyingTo = comment 172 - commentText = "" 173 - commentFocused = true 174 - } 175 - 176 - private func startReplyToReply(_ reply: GrainComment, root: GrainComment) { 177 - replyingTo = root 178 - commentText = "@\(reply.author.handle) " 179 - commentFocused = true 180 - } 181 - 182 - private func postComment() async { 183 - guard let authContext = await auth.authContext() else { return } 184 - let text = commentText 185 - let reply = replyingTo 186 - commentText = "" 187 - replyingTo = nil 188 - commentFocused = false 189 - await viewModel.postComment(text: text, storyUri: storyUri, replyTo: reply, auth: authContext) 190 - } 191 - 192 - private func deleteComment(_ comment: GrainComment) async { 193 - guard let authContext = await auth.authContext() else { return } 194 - await viewModel.deleteComment(comment, storyUri: storyUri, auth: authContext) 195 36 } 196 37 } 197 38