diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift index 18a6be608..e4d03e2fc 100644 --- a/damus/Nostr/NostrEvent.swift +++ b/damus/Nostr/NostrEvent.swift @@ -955,6 +955,29 @@ func first_eref_mention(ev: NostrEvent, privkey: Privkey?) -> Mention? { return nil } +func separate_images(ev: NostrEvent, privkey: Privkey?) -> [MediaUrl]? { + let urlBlocks: [URL] = ev.blocks(privkey).blocks.reduce(into: []) { urls, block in + guard case .url(let url) = block else { + return + } + if classify_url(url).is_img != nil { + urls.append(url) + } + } + let mediaUrls = urlBlocks.map { MediaUrl.image($0) } + return mediaUrls.isEmpty ? nil : mediaUrls +} + +func separate_invoices(ev: NostrEvent, privkey: Privkey?) -> [Invoice]? { + let invoiceBlocks: [Invoice] = ev.blocks(privkey).blocks.reduce(into: []) { invoices, block in + guard case .invoice(let invoice) = block else { + return + } + invoices.append(invoice) + } + return invoiceBlocks.isEmpty ? nil : invoiceBlocks +} + /** Transforms a `NostrEvent` of known kind `NostrKind.like`to a human-readable emoji. If the known kind is not a `NostrKind.like`, it will return `nil`. diff --git a/damus/Util/EventCache.swift b/damus/Util/EventCache.swift index 57235f8c8..5db3b5116 100644 --- a/damus/Util/EventCache.swift +++ b/damus/Util/EventCache.swift @@ -411,7 +411,7 @@ func is_animated_image(url: URL) -> Bool { return ext == "gif" } -func preload_event(plan: PreloadPlan, state: DamusState) async { +func preload_event(plan: PreloadPlan, state: DamusState, only_text: Bool = false) async { var artifacts: NoteArtifacts? = plan.data.artifacts.artifacts let settings = state.settings let profiles = state.profiles @@ -420,7 +420,7 @@ func preload_event(plan: PreloadPlan, state: DamusState) async { print("Preloading event \(plan.event.content)") if artifacts == nil && plan.load_artifacts { - let arts = render_note_content(ev: plan.event, profiles: profiles, privkey: our_keypair.privkey) + let arts = render_note_content(ev: plan.event, profiles: profiles, privkey: our_keypair.privkey, only_text: only_text) artifacts = arts // we need these asap @@ -441,7 +441,7 @@ func preload_event(plan: PreloadPlan, state: DamusState) async { } if plan.load_preview, note_artifact_is_separated(kind: plan.event.known_kind) { - let arts = artifacts ?? render_note_content(ev: plan.event, profiles: profiles, privkey: our_keypair.privkey) + let arts = artifacts ?? render_note_content(ev: plan.event, profiles: profiles, privkey: our_keypair.privkey, only_text: only_text) // only separated artifacts have previews if case .separated(let sep) = arts { @@ -479,7 +479,7 @@ func preload_event(plan: PreloadPlan, state: DamusState) async { } -func preload_events(state: DamusState, events: [NostrEvent]) { +func preload_events(state: DamusState, events: [NostrEvent], only_text: Bool = false) { let event_cache = state.events let our_keypair = state.keypair let settings = state.settings @@ -494,7 +494,7 @@ func preload_events(state: DamusState, events: [NostrEvent]) { Task { for plan in plans { - await preload_event(plan: plan, state: state) + await preload_event(plan: plan, state: state, only_text: only_text) } } } diff --git a/damus/Views/DMChatView.swift b/damus/Views/DMChatView.swift index a905c68a3..c6bb01537 100644 --- a/damus/Views/DMChatView.swift +++ b/damus/Views/DMChatView.swift @@ -12,40 +12,124 @@ struct DMChatView: View, KeyboardReadable { let damus_state: DamusState @ObservedObject var dms: DirectMessageModel @State var showPrivateKeyWarning: Bool = false + @State private var textHeight: CGFloat = 0 + + enum FocusedField { + case message + } + @FocusState private var focusedField: FocusedField? + + let placeholder = "Send a message" var pubkey: Pubkey { dms.pubkey } - + + // Group events by date and then by pubkey and sort them by creation time + func groupEventsByDateAndPubkey(events: [NostrEvent]) -> [Date: [String: [NostrEvent]]] { + let groups = Dictionary(grouping: events) { event in + Calendar.current.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(event.created_at))) + }.mapValues { events in + var lastEventDate: Date? + var groupCounter = 0 + + var eventGroups = [String: [NostrEvent]]() + + for event in events { + let currentEventDate = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + defer { lastEventDate = currentEventDate } + + let pubkey = event.pubkey + if let lastDate = lastEventDate, + currentEventDate.timeIntervalSince(lastDate) <= 120, + let lastEvent = eventGroups["\(groupCounter)-\(pubkey)"]?.last, + lastEvent.pubkey == pubkey { + eventGroups["\(groupCounter)-\(pubkey)"]?.append(event) + } else { + groupCounter += 1 + eventGroups["\(groupCounter)-\(pubkey)"] = [event] + } + } + + return eventGroups.mapValues { $0.sorted(by: { $0.created_at < $1.created_at }) } + } as [Date: [String: [NostrEvent]]] + + return groups + } + var Messages: some View { ScrollViewReader { scroller in ScrollView { - LazyVStack(alignment: .leading) { - ForEach(Array(zip(dms.events, dms.events.indices)), id: \.0.id) { (ev, ind) in - DMView(event: dms.events[ind], damus_state: damus_state) - .contextMenu{MenuItems(event: ev, keypair: damus_state.keypair, target_pubkey: ev.pubkey, bookmarks: damus_state.bookmarks, muted_threads: damus_state.muted_threads, settings: damus_state.settings)} - } - EndBlock(height: 1) + if dms.events.isEmpty { + EmptyMessageView() + } else { + GroupedEventsView(groups: groupEventsByDateAndPubkey(events: dms.events), damus_state: damus_state) } - .padding(.horizontal) - } - .dismissKeyboardOnTap() .onAppear { scroll_to_end(scroller) }.onChange(of: dms.events.count) { _ in scroll_to_end(scroller, animated: true) + }.onChange(of: textHeight) { _ in + scroll_to_end(scroller, animated: true) } - - Footer - .onReceive(keyboardPublisher) { visible in - guard visible else { - return - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - scroll_to_end(scroller, animated: true) - } + } + } + + struct EmptyMessageView: View { + var body: some View { + Text("Send a message to start the conversation...", comment: "Text prompt for user to send a message to the other user.") + .lineLimit(nil) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + .padding(.vertical) + .foregroundColor(.gray) + } + } + + struct GroupedEventsView: View { + var groups: [Date: [String: [NostrEvent]]] + var damus_state: DamusState + + var body: some View { + VStack(alignment: .leading) { + ForEach(Array(groups.keys).sorted(), id: \.self) { date in + GroupedDateView(date: date, events: groups[date]!, damus_state: damus_state) } + EndBlock(height: 5) + }.padding(.horizontal) + } + } + + struct GroupedDateView: View { + var date: Date + var events: [String: [NostrEvent]] + var damus_state: DamusState + + var body: some View { + VStack(alignment: .leading) { + Text(date, style: .date) + .font(.footnote) + .fontWeight(.bold) + .foregroundColor(.gray) + .padding(.horizontal) + .frame(maxWidth: .infinity) + + ForEach(Array(events.keys).sorted(), id: \.self) { key in + GroupedEventView(events: events[key]!, damus_state: damus_state) + } + } + } + } + + struct GroupedEventView: View { + var events: [NostrEvent] + var damus_state: DamusState + + var body: some View { + ForEach(events, id: \.self) { event in + DMView(event: event, damus_state: damus_state, isLastInGroup: event == events.last) + } } } @@ -65,66 +149,89 @@ struct DMChatView: View, KeyboardReadable { HStack { ProfilePicView(pubkey: pubkey, size: 24, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation) - ProfileName(pubkey: pubkey, profile: profile, damus: damus_state) + ProfileName(pubkey: pubkey, profile: profile, damus: damus_state, show_nip5_domain: false) } } .buttonStyle(PlainButtonStyle()) } var InputField: some View { - TextEditor(text: $dms.draft) - .textEditorBackground { - InputBackground() + ZStack(alignment: .bottomTrailing) { + VStack(alignment: .leading) { + ZStack(alignment: .topLeading) { + HStack { + TextEditor(text: $dms.draft) + .font(.body) + .frame(minHeight: 15, maxHeight: 150) + .foregroundColor(Color.primary) + .padding(.horizontal, 10) + .fixedSize(horizontal: false, vertical: true) + .focused($focusedField, equals: .message) + .overlay( + GeometryReader { geo in + Color.clear + .preference(key: ViewHeightKey.self, value: geo.size.height) + } + ) + .onPreferenceChange(ViewHeightKey.self) { + textHeight = $0 + } + .onChange(of: dms.draft) { _ in + DispatchQueue.main.async { + textHeight = getTextHeight() + } + } + + if !dms.draft.isEmpty { + Button( + role: .none, + action: { + showPrivateKeyWarning = contentContainsPrivateKey(dms.draft) + + if !showPrivateKeyWarning { + send_message() + } + } + ) { + Label("", image: "send") + .font(.title) + .foregroundStyle(LINEAR_GRADIENT) + } + } + } + + RoundedRectangle(cornerRadius: 20) + .stroke(style: .init(lineWidth: 0.75)) + .foregroundColor(.secondary.opacity(0.35)) + + Text(dms.draft == "" ? placeholder : "") + .font(.body) + .padding(.leading, 15) + .foregroundColor(.gray) + .opacity(dms.draft == "" ? 0.35 : 0) + .frame(minHeight: 15, maxHeight: 150, alignment: .center) + .onTapGesture(perform: { + focusedField = .message + }) + } } - .cornerRadius(8) - .background( - RoundedRectangle(cornerRadius: 8) - .stroke(style: .init(lineWidth: 2)) - .foregroundColor(.secondary.opacity(0.2)) - ) - .padding(16) - .foregroundColor(Color.primary) - .frame(minHeight: 70, maxHeight: 150, alignment: .bottom) - .fixedSize(horizontal: false, vertical: true) - } - - @Environment(\.colorScheme) var colorScheme - - func InputBackground() -> Color { - if colorScheme == .light { - return Color.init(.sRGB, red: 0.9, green: 0.9, blue: 0.9, opacity: 1.0) - } else { - return Color.init(.sRGB, red: 0.1, green: 0.1, blue: 0.1, opacity: 1.0) } + .frame(height: textHeight) } - var Footer: some View { - - HStack(spacing: 0) { - InputField - - if !dms.draft.isEmpty { - Button( - role: .none, - action: { - showPrivateKeyWarning = contentContainsPrivateKey(dms.draft) - - if !showPrivateKeyWarning { - send_message() - } - } - ) { - Label("", image: "send") - .font(.title) - } - } - } + private func getTextHeight() -> CGFloat { + let textHeight = dms.draft.isEmpty ? 15 : dms.draft.getHeight(width: UIScreen.main.bounds.width - 32, font: .systemFont(ofSize: 16)) + let height = textHeight < 150 ? textHeight : 150 + return height + 16 + } - /* - Text(dms.draft).opacity(0).padding(.all, 8) - .fixedSize(horizontal: false, vertical: true) - .frame(minHeight: 70, maxHeight: 150, alignment: .bottom) - */ + var Footer: some View { + VStack { + Divider() + InputField + .padding(10) + .padding(.top, -5) + } } func send_message() { @@ -147,18 +254,19 @@ struct DMChatView: View, KeyboardReadable { } var body: some View { - ZStack { - Messages - - Text("Send a message to start the conversation...", comment: "Text prompt for user to send a message to the other user.") - .lineLimit(nil) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) - .opacity(((dms.events.count == 0) ? 1.0 : 0.0)) - .foregroundColor(.gray) + VStack(spacing: 0) { + VStack(spacing: 0){ + Messages + .dismissKeyboardOnTap() + } + Footer } .navigationTitle(NSLocalizedString("DMs", comment: "Navigation title for DMs view, where DM is the English abbreviation for Direct Message.")) - .toolbar { Header } + .toolbar { + ToolbarItem(placement: .principal) { + Header + } + } .onDisappear { if dms.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { dms.draft = "" @@ -219,6 +327,21 @@ func create_encrypted_event(_ message: String, to_pk: Pubkey, tags: [[String]], return NostrEvent(content: enc_content, keypair: keypair.to_keypair(), kind: kind, tags: tags, createdAt: created_at) } +struct ViewHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +extension String { + func getHeight(width: CGFloat, font: UIFont) -> CGFloat { + let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude) + let boundingBox = self.boundingRect(with: constraintRect, options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font], context: nil) + return ceil(boundingBox.height) + } +} + func create_dm(_ message: String, to_pk: Pubkey, tags: [[String]], keypair: Keypair, created_at: UInt32? = nil) -> NostrEvent? { let created = created_at ?? UInt32(Date().timeIntervalSince1970) diff --git a/damus/Views/DMView.swift b/damus/Views/DMView.swift index 1290cd8b5..001ec2962 100644 --- a/damus/Views/DMView.swift +++ b/damus/Views/DMView.swift @@ -10,6 +10,7 @@ import SwiftUI struct DMView: View { let event: NostrEvent let damus_state: DamusState + let isLastInGroup: Bool var is_ours: Bool { event.pubkey == damus_state.pubkey @@ -26,56 +27,364 @@ struct DMView: View { } var dm_options: EventViewOptions { - if self.damus_state.settings.translate_dms { - return [] + var options: EventViewOptions = [.only_text] + + if !self.damus_state.settings.translate_dms { + options.insert(.no_translate) } - return [.no_translate] + return options } - var DM: some View { - HStack { - if is_ours { - Spacer(minLength: UIScreen.main.bounds.width * 0.2) - } + func format_timestamp(timestamp: UInt32) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "h:mm a" + let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) + return dateFormatter.string(from: date) + } + + let LINEAR_GRADIENT_DM = LinearGradient(gradient: Gradient(colors: [ + DamusColors.purple, + .pink + ]), startPoint: .topTrailing, endPoint: .bottomTrailing) + + func DM(content: CompatibleText, isLastInDM: Bool) -> some View { + return Group { + HStack { + if is_ours { + Spacer(minLength: UIScreen.main.bounds.width * 0.1) + } - let should_show_img = should_show_images(settings: damus_state.settings, contacts: damus_state.contacts, ev: event, our_pubkey: damus_state.pubkey) + let should_show_img = should_show_images(settings: damus_state.settings, contacts: damus_state.contacts, ev: event, our_pubkey: damus_state.pubkey) - VStack(alignment: .trailing) { NoteContentView(damus_state: damus_state, event: event, show_images: should_show_img, size: .normal, options: dm_options) - .fixedSize(horizontal: false, vertical: true) - .padding([.top, .leading, .trailing], 10) - .padding([.bottom], 25) - .background(VisualEffectView(effect: UIBlurEffect(style: .prominent)) - .background(is_ours ? Color.accentColor.opacity(0.9) : Color.secondary.opacity(0.15)) + .frame(minWidth: 30) + .contentShape(Rectangle()) + .padding(.horizontal, 12.5) + .padding(.vertical, 9) + .foregroundColor(.primary) + .background( + Group { + if is_ours { + LINEAR_GRADIENT_DM.opacity(0.75) + } else { + Color.secondary.opacity(0.15) + } + } ) - .cornerRadius(8.0) - .tint(is_ours ? Color.white : Color.accentColor) + .background(VisualEffectView(effect: UIBlurEffect(style: .prominent))) + .clipShape(ChatBubbleShape(direction: (isLastInGroup && isLastInDM) ? (is_ours ? ChatBubbleShape.Direction.right: ChatBubbleShape.Direction.left): ChatBubbleShape.Direction.none)) + .contextMenu{MenuItems(event: event, keypair: damus_state.keypair, target_pubkey: event.pubkey, bookmarks: damus_state.bookmarks, muted_threads: damus_state.muted_threads, settings: damus_state.settings)} + + if !is_ours { + Spacer(minLength: UIScreen.main.bounds.width * 0.1) + } + } + } + } + + func Mention(mention: Mention) -> some View { + Group { + HStack { + if is_ours { + Spacer(minLength: UIScreen.main.bounds.width * 0.1) + } + + BuilderEventView(damus: damus_state, event_id: mention.ref) + .contextMenu{MenuItems(event: event, keypair: damus_state.keypair, target_pubkey: event.pubkey, bookmarks: damus_state.bookmarks, muted_threads: damus_state.muted_threads, settings: damus_state.settings)} - Text(format_relative_time(event.created_at)) - .font(.footnote) - .foregroundColor(.gray) - .opacity(0.8) + if !is_ours { + Spacer(minLength: UIScreen.main.bounds.width * 0.1) + } } + } + } - if !is_ours { - Spacer(minLength: UIScreen.main.bounds.width * 0.2) + @MainActor + func Image(urls: [MediaUrl], isLastInDM: Bool) -> some View { + return Group { + HStack { + if is_ours { + Spacer(minLength: UIScreen.main.bounds.width * 0.1) + } + + let should_show_img = should_show_images(settings: damus_state.settings, contacts: damus_state.contacts, ev: event, our_pubkey: damus_state.pubkey) + if should_show_img { + ImageCarousel(state: damus_state, evid: event.id, urls: urls) + .clipShape(ChatBubbleShape(direction: (isLastInGroup && isLastInDM) ? (is_ours ? ChatBubbleShape.Direction.right: ChatBubbleShape.Direction.left): ChatBubbleShape.Direction.none)) + .contextMenu{MenuItems(event: event, keypair: damus_state.keypair, target_pubkey: event.pubkey, bookmarks: damus_state.bookmarks, muted_threads: damus_state.muted_threads, settings: damus_state.settings)} + } else if !should_show_img { + ZStack { + ImageCarousel(state: damus_state, evid: event.id, urls: urls) + Blur() + .disabled(true) + } + .clipShape(ChatBubbleShape(direction: (isLastInGroup && isLastInDM) ? (is_ours ? ChatBubbleShape.Direction.right: ChatBubbleShape.Direction.left): ChatBubbleShape.Direction.none)) + .contextMenu{MenuItems(event: event, keypair: damus_state.keypair, target_pubkey: event.pubkey, bookmarks: damus_state.bookmarks, muted_threads: damus_state.muted_threads, settings: damus_state.settings)} + } + + if !is_ours { + Spacer(minLength: UIScreen.main.bounds.width * 0.1) + } } } } + + func Invoice(invoices: [Invoice], isLastInDM: Bool) -> some View { + return Group { + HStack { + if is_ours { + Spacer(minLength: UIScreen.main.bounds.width * 0.1) + } + + InvoicesView(our_pubkey: damus_state.keypair.pubkey, invoices: invoices, settings: damus_state.settings) + .clipShape(ChatBubbleShape(direction: (isLastInGroup && isLastInDM) ? (is_ours ? ChatBubbleShape.Direction.right: ChatBubbleShape.Direction.left): ChatBubbleShape.Direction.none)) + .contextMenu{MenuItems(event: event, keypair: damus_state.keypair, target_pubkey: event.pubkey, bookmarks: damus_state.bookmarks, muted_threads: damus_state.muted_threads, settings: damus_state.settings)} + + if !is_ours { + Spacer(minLength: UIScreen.main.bounds.width * 0.1) + } + } + } + } + + func TimeStamp() -> some View { + return Group { + HStack { + if is_ours { + Spacer(minLength: UIScreen.main.bounds.width * 0.1) + } + + Text(format_timestamp(timestamp: event.created_at)) + .font(.system(size: 11)) + .font(.footnote) + .foregroundColor(.gray) + + if !is_ours { + Spacer(minLength: UIScreen.main.bounds.width * 0.1) + } + } + } + } + + func filter_content(blocks bs: Blocks, profiles: Profiles, privkey: Privkey?) -> (Bool, CompatibleText?) { + let blocks = bs.blocks + + let one_note_ref = blocks + .filter({ $0.is_note_mention }) + .count == 1 + + var ind: Int = -1 + var show_text: Bool = false + let txt: CompatibleText = blocks.reduce(CompatibleText()) { str, block in + ind = ind + 1 + + switch block { + case .mention(let m): + if case .note = m.ref, one_note_ref { + return str + } + if case .pubkey(_) = m.ref { + show_text = true + } + return str + mention_str(m, profiles: profiles) + case .text(let txt): + var trimmed = txt + if let prev = blocks[safe: ind-1], case .url(let u) = prev, classify_url(u).is_media != nil { + trimmed = " " + trim_prefix(trimmed) + } + + if let next = blocks[safe: ind+1] { + if case .url(let u) = next, classify_url(u).is_media != nil { + trimmed = trim_suffix(trimmed) + } else if case .mention(let m) = next, case .note = m.ref, one_note_ref { + trimmed = trim_suffix(trimmed) + } + } + if (!trimmed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) { + show_text = true + } + return str + CompatibleText(stringLiteral: trimmed) + case .relay(let relay): + show_text = true + return str + CompatibleText(stringLiteral: relay) + case .hashtag(let htag): + show_text = true + return str + hashtag_str(htag) + case .invoice: + return str + case .url(let url): + if !(classify_url(url).is_media != nil) { + show_text = true + return str + url_str(url) + } else { + return str + } + } + } + + return (show_text, txt) + } + func getLastInDM(text: CompatibleText?, mention: Mention?, url: [MediaUrl]?, invoices: [Invoice]?) -> DMContentType? { + var last: DMContentType? + if let text { + last = .text + } + if let mention { + last = .mention + } + if let url { + last = .url + } + if let invoices { + last = .invoice + } + return last + } + var body: some View { VStack { - Mention - DM + let (show_text, filtered_content): (Bool, CompatibleText?) = filter_content(blocks: event.blocks(damus_state.keypair.privkey), profiles: damus_state.profiles, privkey: damus_state.keypair.privkey) + let mention = first_eref_mention(ev: event, privkey: damus_state.keypair.privkey) + let url = separate_images(ev: event, privkey: damus_state.keypair.privkey) + let invoices = separate_invoices(ev: event, privkey: damus_state.keypair.privkey) + let lastInDM = getLastInDM(text: filtered_content, mention: mention, url: url, invoices: invoices) + + if show_text, let filtered_content = filtered_content { + DM(content: filtered_content, isLastInDM: lastInDM == .text).padding(.bottom, (isLastInGroup && lastInDM == .text) ? 0 : -6) + } + if let mention { + Mention(mention: mention).padding(.bottom, (isLastInGroup && lastInDM == .mention) ? 0 : -6) + } + if let url { + Image(urls: url, isLastInDM: lastInDM == .url).padding(.bottom, (isLastInGroup && lastInDM == .url) ? 0 : -6) + } + if let invoices { + Invoice(invoices: invoices, isLastInDM: lastInDM == .invoice).padding(.bottom, (isLastInGroup && lastInDM == .invoice) ? 0 : -6) + } + if (isLastInGroup) { + TimeStamp().padding(.top, -5) + } } - + } +} + +enum DMContentType { + case text + case mention + case url + case invoice +} + +struct ChatBubbleShape: Shape { + enum Direction { + case left + case right + case none + } + + let direction: Direction + + func path(in rect: CGRect) -> Path { + return (direction == .none) ? getBubblePath(in: rect) : ( (direction == .left) ? getLeftBubblePath(in: rect) : getRightBubblePath(in: rect) ) + } + + private func getBubblePath(in rect: CGRect) -> Path { + let width = rect.width + let height = rect.height + let cornerRadius: CGFloat = 17 + let path = Path { p in + p.move(to: CGPoint(x: cornerRadius, y: height)) + p.addLine(to: CGPoint(x: width - cornerRadius, y: height)) + p.addCurve(to: CGPoint(x: width, y: height - cornerRadius), + control1: CGPoint(x: width - cornerRadius/2, y: height), + control2: CGPoint(x: width, y: height - cornerRadius/2)) + p.addLine(to: CGPoint(x: width, y: cornerRadius)) + p.addCurve(to: CGPoint(x: width - cornerRadius, y: 0), + control1: CGPoint(x: width, y: cornerRadius/2), + control2: CGPoint(x: width - cornerRadius/2, y: 0)) + p.addLine(to: CGPoint(x: cornerRadius, y: 0)) + p.addCurve(to: CGPoint(x: 0, y: cornerRadius), + control1: CGPoint(x: cornerRadius/2, y: 0), + control2: CGPoint(x: 0, y: cornerRadius/2)) + p.addLine(to: CGPoint(x: 0, y: height - cornerRadius)) + p.addCurve(to: CGPoint(x: cornerRadius, y: height), + control1: CGPoint(x: 0, y: height - cornerRadius/2), + control2: CGPoint(x: cornerRadius/2, y: height)) + } + return path + } + + private func getLeftBubblePath(in rect: CGRect) -> Path { + let width = rect.width + let height = rect.height + let path = Path { p in + p.move(to: CGPoint(x: 25, y: height)) + p.addLine(to: CGPoint(x: width - 20, y: height)) + p.addCurve(to: CGPoint(x: width, y: height - 20), + control1: CGPoint(x: width - 8, y: height), + control2: CGPoint(x: width, y: height - 8)) + p.addLine(to: CGPoint(x: width, y: 20)) + p.addCurve(to: CGPoint(x: width - 20, y: 0), + control1: CGPoint(x: width, y: 8), + control2: CGPoint(x: width - 8, y: 0)) + p.addLine(to: CGPoint(x: 21, y: 0)) + p.addCurve(to: CGPoint(x: 4, y: 20), + control1: CGPoint(x: 12, y: 0), + control2: CGPoint(x: 4, y: 8)) + p.addLine(to: CGPoint(x: 4, y: height - 11)) + p.addCurve(to: CGPoint(x: 0, y: height), + control1: CGPoint(x: 4, y: height - 1), + control2: CGPoint(x: 0, y: height)) + p.addLine(to: CGPoint(x: -0.05, y: height - 0.01)) + p.addCurve(to: CGPoint(x: 11.0, y: height - 4.0), + control1: CGPoint(x: 4.0, y: height + 0.5), + control2: CGPoint(x: 8, y: height - 1)) + p.addCurve(to: CGPoint(x: 25, y: height), + control1: CGPoint(x: 16, y: height), + control2: CGPoint(x: 20, y: height)) + + } + return path + } + + private func getRightBubblePath(in rect: CGRect) -> Path { + let width = rect.width + let height = rect.height + let path = Path { p in + p.move(to: CGPoint(x: 25, y: height)) + p.addLine(to: CGPoint(x: 20, y: height)) + p.addCurve(to: CGPoint(x: 0, y: height - 20), + control1: CGPoint(x: 8, y: height), + control2: CGPoint(x: 0, y: height - 8)) + p.addLine(to: CGPoint(x: 0, y: 20)) + p.addCurve(to: CGPoint(x: 20, y: 0), + control1: CGPoint(x: 0, y: 8), + control2: CGPoint(x: 8, y: 0)) + p.addLine(to: CGPoint(x: width - 21, y: 0)) + p.addCurve(to: CGPoint(x: width - 4, y: 20), + control1: CGPoint(x: width - 12, y: 0), + control2: CGPoint(x: width - 4, y: 8)) + p.addLine(to: CGPoint(x: width - 4, y: height - 11)) + p.addCurve(to: CGPoint(x: width, y: height), + control1: CGPoint(x: width - 4, y: height - 1), + control2: CGPoint(x: width, y: height)) + p.addLine(to: CGPoint(x: width + 0.05, y: height - 0.01)) + p.addCurve(to: CGPoint(x: width - 11, y: height - 4), + control1: CGPoint(x: width - 4, y: height + 0.5), + control2: CGPoint(x: width - 8, y: height - 1)) + p.addCurve(to: CGPoint(x: width - 25, y: height), + control1: CGPoint(x: width - 16, y: height), + control2: CGPoint(x: width - 20, y: height)) + } + return path } } struct DMView_Previews: PreviewProvider { static var previews: some View { let ev = NostrEvent(content: "Hey there *buddy*, want to grab some drinks later? 🍻", keypair: test_keypair, kind: 1, tags: [])! - DMView(event: ev, damus_state: test_damus_state()) + DMView(event: ev, damus_state: test_damus_state(), isLastInGroup: false) } } diff --git a/damus/Views/DirectMessagesView.swift b/damus/Views/DirectMessagesView.swift index d6e4d0ec6..a4445f0d2 100644 --- a/damus/Views/DirectMessagesView.swift +++ b/damus/Views/DirectMessagesView.swift @@ -38,10 +38,10 @@ struct DirectMessagesView: View { var options: EventViewOptions { if self.damus_state.settings.translate_dms { - return [.truncate_content, .no_action_bar] + return [.truncate_content, .no_action_bar, .only_text] } - return [.truncate_content, .no_action_bar, .no_translate] + return [.truncate_content, .no_action_bar, .no_translate, .only_text] } func MaybeEvent(_ model: DirectMessageModel) -> some View { diff --git a/damus/Views/Events/TextEvent.swift b/damus/Views/Events/TextEvent.swift index d37a84b34..e9005653c 100644 --- a/damus/Views/Events/TextEvent.swift +++ b/damus/Views/Events/TextEvent.swift @@ -19,6 +19,7 @@ struct EventViewOptions: OptionSet { static let nested = EventViewOptions(rawValue: 1 << 7) static let top_zap = EventViewOptions(rawValue: 1 << 8) static let no_mentions = EventViewOptions(rawValue: 1 << 9) + static let only_text = EventViewOptions(rawValue: 1 << 10) static let embedded: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested] } diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift index d8cc9766d..c20dee95e 100644 --- a/damus/Views/NoteContentView.swift +++ b/damus/Views/NoteContentView.swift @@ -60,6 +60,10 @@ struct NoteContentView: View { return options.contains(.wide) } + var only_txt: Bool { + return options.contains(.only_text) + } + var preview: LinkViewRepresentable? { guard show_images, case .loaded(let preview) = preview_model.state, @@ -178,9 +182,9 @@ struct NoteContentView: View { if force_artifacts { plan.load_artifacts = true } - await preload_event(plan: plan, state: damus_state) + await preload_event(plan: plan, state: damus_state, only_text: only_txt) } else if force_artifacts { - let arts = render_note_content(ev: event, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey) + let arts = render_note_content(ev: event, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey, only_text: only_txt) self.artifacts_model.state = .loaded(arts) } } @@ -394,14 +398,14 @@ func note_artifact_is_separated(kind: NostrKind?) -> Bool { return kind != .longform } -func render_note_content(ev: NostrEvent, profiles: Profiles, privkey: Privkey?) -> NoteArtifacts { +func render_note_content(ev: NostrEvent, profiles: Profiles, privkey: Privkey?, only_text: Bool = false) -> NoteArtifacts { let blocks = ev.blocks(privkey) if ev.known_kind == .longform { return .longform(LongformContent(ev.content)) } - return .separated(render_blocks(blocks: blocks, profiles: profiles)) + return .separated(render_blocks(blocks: blocks, profiles: profiles, only_text: only_text)) } fileprivate func artifact_part_last_text_ind(parts: [ArtifactPart]) -> (Int, Text)? { @@ -439,7 +443,7 @@ func reduce_text_block(blocks: [Block], ind: Int, txt: String, one_note_ref: Boo return CompatibleText(stringLiteral: trimmed) } -func render_blocks(blocks bs: Blocks, profiles: Profiles) -> NoteArtifactsSeparated { +func render_blocks(blocks bs: Blocks, profiles: Profiles, only_text: Bool = false) -> NoteArtifactsSeparated { var invoices: [Invoice] = [] var urls: [UrlType] = [] let blocks = bs.blocks @@ -482,6 +486,10 @@ func render_blocks(blocks bs: Blocks, profiles: Profiles) -> NoteArtifactsSepara } } + if (only_text) { + return NoteArtifactsSeparated(content: txt, words: bs.words, urls: [], invoices: []) + } + return NoteArtifactsSeparated(content: txt, words: bs.words, urls: urls, invoices: invoices) }