aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
Diffstat (limited to 'Hotline')
-rw-r--r--Hotline/Assets.xcassets/Link Color.colorset/Contents.json10
-rw-r--r--Hotline/Shared/AsyncLinkPreview.swift57
-rw-r--r--Hotline/Shared/FileImageView.swift (renamed from Hotline/Shared/AnimatedImageView.swift)2
-rw-r--r--Hotline/Utility/FoundationExtensions.swift43
-rw-r--r--Hotline/macOS/ChatView.swift173
-rw-r--r--Hotline/macOS/FilePreviewImageView.swift2
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 {