diff options
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/Assets.xcassets/Link Color.colorset/Contents.json | 10 | ||||
| -rw-r--r-- | Hotline/Shared/AsyncLinkPreview.swift | 57 | ||||
| -rw-r--r-- | Hotline/Shared/FileImageView.swift (renamed from Hotline/Shared/AnimatedImageView.swift) | 2 | ||||
| -rw-r--r-- | Hotline/Utility/FoundationExtensions.swift | 43 | ||||
| -rw-r--r-- | Hotline/macOS/ChatView.swift | 173 | ||||
| -rw-r--r-- | Hotline/macOS/FilePreviewImageView.swift | 2 |
6 files changed, 201 insertions, 86 deletions
diff --git a/Hotline/Assets.xcassets/Link Color.colorset/Contents.json b/Hotline/Assets.xcassets/Link Color.colorset/Contents.json index 4373edf..e1d527b 100644 --- a/Hotline/Assets.xcassets/Link Color.colorset/Contents.json +++ b/Hotline/Assets.xcassets/Link Color.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xF1", - "green" : "0x61", - "red" : "0x39" + "blue" : "0xFF", + "green" : "0x86", + "red" : "0x1F" } }, "idiom" : "universal" @@ -24,8 +24,8 @@ "components" : { "alpha" : "1.000", "blue" : "0xFF", - "green" : "0xA3", - "red" : "0x8A" + "green" : "0xB0", + "red" : "0x6C" } }, "idiom" : "universal" diff --git a/Hotline/Shared/AsyncLinkPreview.swift b/Hotline/Shared/AsyncLinkPreview.swift new file mode 100644 index 0000000..9f0c4c2 --- /dev/null +++ b/Hotline/Shared/AsyncLinkPreview.swift @@ -0,0 +1,57 @@ +import SwiftUI +import LinkPresentation + +fileprivate class CustomLinkView: LPLinkView { + override var intrinsicContentSize: CGSize { CGSize(width: 0, height: super.intrinsicContentSize.height) } +} + +struct AsyncLinkPreview: View { + @State private var metadata: LPLinkMetadata? + @State private var isLoading = true + let url: URL? + + func fetchMetadata() async { + guard let url else { + self.isLoading = false + return + } + do { + let provider = LPMetadataProvider() + let metadata = try await provider.startFetchingMetadata(for: url) + self.metadata = metadata + self.isLoading = false + } catch { + self.isLoading = false + } + } + + var body: some View { + if isLoading { + ProgressView() + .controlSize(.small) + .task { + await self.fetchMetadata() + } + } else if let metadata = metadata { + LinkView(metadata: metadata) + .frame(width: 200) + } else { + Text(LocalizedStringKey(url!.absoluteString.convertLinksToMarkdown())) + .multilineTextAlignment(.leading) + .tint(Color("Link Color")) + } + } +} + +struct LinkView: NSViewRepresentable { + var metadata: LPLinkMetadata + + func makeNSView(context: Context) -> LPLinkView { + let linkView = CustomLinkView(metadata: metadata) + return linkView + } + + func updateNSView(_ nsView: LPLinkView, context: Context) { + // Nothing required + } +} diff --git a/Hotline/Shared/AnimatedImageView.swift b/Hotline/Shared/FileImageView.swift index 0d92715..0cc50f1 100644 --- a/Hotline/Shared/AnimatedImageView.swift +++ b/Hotline/Shared/FileImageView.swift @@ -1,6 +1,6 @@ import SwiftUI -struct AnimatedImageView: NSViewRepresentable { +struct FileImageView: NSViewRepresentable { var image: NSImage? let minimumSize: CGSize = CGSize(width: 350, height: 350) diff --git a/Hotline/Utility/FoundationExtensions.swift b/Hotline/Utility/FoundationExtensions.swift index f2cd39c..9ad0d8b 100644 --- a/Hotline/Utility/FoundationExtensions.swift +++ b/Hotline/Utility/FoundationExtensions.swift @@ -6,17 +6,54 @@ enum Endianness { } extension String { + func convertToAttributedStringWithLinks(relaxed: Bool = false) -> AttributedString { + let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: self) + let urlPattern = relaxed ? + #"(hotline|http|https)?(://)?[\w-]+(\.[\w-]+)+([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?"# : + #"(hotline|http|https)://[\w-]+(\.[\w-]+)+([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?"# + + guard let regex = try? NSRegularExpression(pattern: urlPattern, options: .caseInsensitive) else { + return AttributedString(attributedString) + } + + regex.enumerateMatches(in: self, range: NSMakeRange(0, self.count)) { (result: NSTextCheckingResult!, _, _) in + let range = result.range + if let newRange = Range(result.range, in: self) { + let str = String(self[newRange]) + attributedString.addAttribute(.link, value: str, range: result.range) + } + } + + return AttributedString(attributedString) + } + + func isWebURL() -> Bool { + guard let url = URL(string: self) else { + return false + } + switch url.scheme?.lowercased() { + case "http", "https": + return true + default: + return false + } + } + func isImageURL() -> Bool { guard let url = URL(string: self) else { return false } - let validImageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "webp"] - return validImageExtensions.contains(url.pathExtension.lowercased()) + switch url.pathExtension.lowercased() { + case "jpg", "jpeg", "png", "gif": + return true + default: + return false + } } func convertLinksToMarkdown() -> String { - let urlPattern = #"https?://[\w-]+(\.[\w-]+)+([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?"# + let urlPattern = #"(hotline|http|https)?(://)?[\w-]+(\.[\w-]+)+([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?"# guard let regex = try? NSRegularExpression(pattern: urlPattern, options: .caseInsensitive) else { return self diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index 57814e2..35ff28e 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -24,20 +24,19 @@ struct ChatView: View { var body: some View { NavigationStack { ScrollViewReader { reader in - + VStack(alignment: .leading, spacing: 0) { // MARK: Scroll View GeometryReader { gm in ScrollView(.vertical) { LazyVStack(alignment: .leading) { - + ForEach(model.chat) { msg in // MARK: Agreement if msg.type == .agreement { - VStack(alignment: .center, spacing: 16) { - #if os(iOS) +#if os(iOS) if let bannerImage = self.model.bannerImage { Image(uiImage: bannerImage) .resizable() @@ -45,35 +44,29 @@ struct ChatView: View { .frame(maxWidth: 468.0) .clipShape(RoundedRectangle(cornerRadius: 3)) } - #endif - HStack(spacing: 0) { - Spacer() - - ScrollView(.vertical) { - HStack { - Text(LocalizedStringKey(msg.text.convertLinksToMarkdown())) - .font(.system(size: 12)) - .fontDesign(.monospaced) - .textSelection(.enabled) - .tint(Color("Link Color")) - Spacer() - } - .padding(16) +#endif + ScrollView(.vertical) { + HStack { + Spacer() + Text(msg.text.convertToAttributedStringWithLinks(relaxed: false)) + .font(.system(size: 12)) + .fontDesign(.monospaced) + .textSelection(.enabled) + .tint(Color("Link Color")) + .frame(maxWidth: 400, alignment: .center) + .padding(16) + Spacer() } - .scrollBounceBehavior(.basedOnSize) - .frame(maxWidth: 468, maxHeight: 375) - - Spacer() } - #if os(iOS) + .frame(maxWidth: .infinity, maxHeight: 375) + .scrollBounceBehavior(.basedOnSize) +#if os(iOS) .background(Color("Agreement Background")) - #elseif os(macOS) +#elseif os(macOS) .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) - #endif +#endif .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .frame(maxWidth: .infinity) - .padding() + .padding(.bottom, 16) } // MARK: Status else if msg.type == .status { @@ -86,16 +79,49 @@ struct ChatView: View { .opacity(0.3) Spacer() } - .padding() + .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) } else { HStack(alignment: .firstTextBaseline) { if let username = msg.username { - Text(LocalizedStringKey("**\(username):** \(msg.text)".convertLinksToMarkdown())) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) +// if msg.text.isImageURL() { +// HStack(alignment: .bottom) { +// Text("**\(username):** ") +// +// let imageURL = URL(string: msg.text)! +// AsyncImage(url: imageURL) { phase in +// switch phase { +// case .failure: +// Text(LocalizedStringKey(msg.text.convertLinksToMarkdown())) +// .lineSpacing(4) +// .multilineTextAlignment(.leading) +// .textSelection(.enabled) +// .tint(Color("Link Color")) +// case .success(let img): +// Link(destination: imageURL) { +// img +// .resizable() +// .scaledToFit() +// .frame(maxWidth: 250, maxHeight: 150, alignment: .leading) +// .onAppear { +// reader.scrollTo(bottomID, anchor: .bottom) +// } +// } +// default: +// ProgressView().controlSize(.small) +// } +// } +// +// Spacer() +// } +// } +// else { + Text(LocalizedStringKey("**\(username):** \(msg.text)".convertLinksToMarkdown())) + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .tint(Color("Link Color")) +// } } else { Text(LocalizedStringKey(msg.text.convertLinksToMarkdown())) @@ -106,68 +132,63 @@ struct ChatView: View { } Spacer() } - .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) } } EmptyView().id(bottomID) } - // .padding(.bottom, 12) + .padding() } - // .padding(.bottom, 60) .frame(maxWidth: .infinity, maxHeight: .infinity) .defaultScrollAnchor(.bottom) .onChange(of: model.chat.count) { -// withAnimation { reader.scrollTo(bottomID, anchor: .bottom) -// } } .onAppear { reader.scrollTo(bottomID, anchor: .bottom) -// focusedField = .chatInput } .onChange(of: gm.size) { reader.scrollTo(bottomID, anchor: .bottom) } } - } - - // MARK: Input Divider - Divider() - .padding([.top, .bottom], 0) - - // MARK: Input Bar - HStack(alignment: .lastTextBaseline, spacing: 0) { - TextField("", text: $input, axis: .vertical) - .focused($focusedField, equals: .chatInput) - .textFieldStyle(.plain) - .lineLimit(1...5) - .multilineTextAlignment(.leading) -// .frame(maxWidth: .infinity) - .onSubmit { - if !self.input.isEmpty { - model.sendChat(self.input) + + // MARK: Input Divider + Divider() + + // MARK: Input Bar + HStack(alignment: .lastTextBaseline, spacing: 0) { + TextField("", text: $input, axis: .vertical) + .focused($focusedField, equals: .chatInput) + .textFieldStyle(.plain) + .lineLimit(1...5) + .multilineTextAlignment(.leading) + // .frame(maxWidth: .infinity) + .onSubmit { + if !self.input.isEmpty { + model.sendChat(self.input) + } + self.input = "" } - self.input = "" + .frame(maxWidth: .infinity) + .padding() + } + .frame(maxWidth: .infinity, minHeight: 28) + .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + .overlay(alignment: .leadingLastTextBaseline) { + Image(systemName: "chevron.right").opacity(0.4).offset(x: 16) + } + .onContinuousHover { phase in + switch phase { + case .active(_): + NSCursor.iBeam.set() + case .ended: + NSCursor.arrow.set() + break } - .frame(maxWidth: .infinity) - .padding() - } - .frame(maxWidth: .infinity, minHeight: 28) - .padding(EdgeInsets(top: 0, leading: 16, bottom: 8, trailing: 16)) - .overlay(alignment: .leadingLastTextBaseline) { - Image(systemName: "chevron.right").opacity(0.4).offset(x: 16) - } - .onContinuousHover { phase in - switch phase { - case .active(_): - NSCursor.iBeam.set() - case .ended: - NSCursor.arrow.set() - break } - } - .onTapGesture { - focusedField = .chatInput + .onTapGesture { + focusedField = .chatInput + } } } } diff --git a/Hotline/macOS/FilePreviewImageView.swift b/Hotline/macOS/FilePreviewImageView.swift index 461f5a4..ae9a825 100644 --- a/Hotline/macOS/FilePreviewImageView.swift +++ b/Hotline/macOS/FilePreviewImageView.swift @@ -31,7 +31,7 @@ struct FilePreviewImageView: View { } else { if let image = preview?.image { - AnimatedImageView(image: image) + FileImageView(image: image) .frame(minWidth: 200, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity) } else { |