Compare commits

..

1 Commits

Author SHA1 Message Date
tyiu 6858d7e72f Remove nonfunctional LibreTranslate servers 2023-09-30 12:54:31 -04:00
201 changed files with 1487 additions and 8638 deletions
+32
View File
@@ -0,0 +1,32 @@
name: Run Test Suite
run-name: Testing ${{ github.ref }} by @${{ github.actor }}
on:
push:
branches:
- "master"
- "ci"
pull_request:
branches:
- "*"
jobs:
run_tests:
runs-on: macos-12
strategy:
matrix:
include:
- xcode: "14.2"
ios: "16.2"
name: Test iOS (${{ matrix.ios }})
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: ${{ matrix.xcode }}
- name: Run Tests
run: xcodebuild test -scheme damus -project damus.xcodeproj -destination 'platform=iOS Simulator,name=iPhone 14,OS=${{ matrix.ios }}' | xcpretty && exit ${PIPESTATUS[0]}
+1 -93
View File
@@ -1,96 +1,3 @@
## [1.6-25] - 2023-10-31
### Added
- Tap to dismiss keyboard on user status view (ericholguin)
- Add setting that allows users to optionally disable the new profile action sheet feature (Daniel DAquino)
- Add follow button to profile action sheet (Daniel DAquino)
- Added reaction counters to nostrdb (William Casarin)
- Record when profile is last fetched in nostrdb (William Casarin)
### Changed
- Automatically load extra regional Japanese relays during account creation if user's region is set to Japan. (Daniel DAquino)
- Updated customize zap view (ericholguin)
- Users are now notified when you quote repost them (William Casarin)
- Save bandwidth by only fetching new profiles after a certain amount of time (William Casarin)
- Zap button on profile action sheet now zaps with a single click, while a long press brings custom zap view (Daniel DAquino)
### Fixed
- Use white font color in qrcode view (ericholguin)
- Fixed an issue where zapping would silently fail on default settings if the user does not have a lightning wallet preinstalled on their device. (Daniel DAquino)
[1.6-25]: https://github.com/damus-io/damus/releases/tag/v1.6-25
## [1.6-24] - 2023-10-22 - AppStore Rejection Cope
### Added
- Improve discoverability of profile zaps with zappability badges and profile action sheets (Daniel DAquino)
- Add suggested hashtags to universe view (Daniel DAquino)
- Suggest first post during onboarding (Daniel DAquino)
- Add expiry date for images in cache to be auto-deleted after a preset time to save space on storage (Daniel DAquino)
- Add QR scan nsec logins. (Jericho Hasselbush)
### Changed
- Improved status view design (ericholguin)
- Improve clear cache functionality (Daniel DAquino)
### Fixed
- Reduce size of event menu hitbox (William Casarin)
- Do not show DMs from muted users (Daniel DAquino)
- Add more spacing between display name and username, and prefix username with `@` character (Daniel DAquino)
- Broadcast quoted notes when posting a note with quotes (Daniel DAquino)
[1.6-24]: https://github.com/damus-io/damus/releases/tag/v1.6-24
## [1.6-23] - 2023-10-06 - Appstore Release
### Added
- Added merch store button to sidebar menu (Daniel DAquino)
### Changed
- Damus icon now opens sidebar (Daniel DAquino)
### Fixed
- Stop tab buttons from causing the root view to scroll to the top unless user is coming from another tab or already at the root view (Daniel DAquino)
- Fix profiles not updating (William Casarin)
- Fix issue where relays with trailing slashes cannot be removed (#1531) (Daniel DAquino)
[1.6-23]: https://github.com/damus-io/damus/releases/tag/v1.6-23
## [1.6-20] - 2023-10-04
### Changed
- Improve UX around clearing cache (Daniel DAquino)
- Show muted thread replies at the bottom of the thread view (#1522) (Daniel DAquino)
### Fixed
- Fix situations where the note composer cursor gets stuck in one place after tagging a user (Daniel DAquino)
- Fix some note composer issues, such as when copying/pasting larger text, and make the post composer more robust. (Daniel DAquino)
- Apply filters to hashtag search timeline view (Daniel DAquino)
- Hide quoted or reposted notes from people whom the user has muted. (#1216) (Daniel DAquino)
- Fix profile not updating (William Casarin)
- Fix small graphical toolbar bug when scrolling profiles (Daniel DAquino)
- Fix localization issues and export strings for translation (Terry Yiu)
[1.6-20]: https://github.com/damus-io/damus/releases/tag/v1.6-20
## [1.6-18] - 2023-09-21
### Added
@@ -1652,3 +1559,4 @@
[0.1.2]: https://github.com/damus-io/damus/releases/tag/v0.1.2
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.damus</string>
</array>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>
@@ -1,49 +0,0 @@
//
// NostrEventInfoFromPushNotification.swift
// DamusNotificationService
//
// Created by Daniel DAquino on 2023-11-13.
//
import Foundation
/// The representation of a JSON-encoded Nostr Event used by the push notification server
/// Needs to match with https://gitlab.com/soapbox-pub/strfry-policies/-/raw/433459d8084d1f2d6500fdf916f22caa3b4d7be5/src/types.ts
struct NostrEventInfoFromPushNotification: Codable {
let id: String // Hex-encoded
let sig: String // Hex-encoded
let kind: NostrKind
let tags: [[String]]
let pubkey: String // Hex-encoded
let content: String
let created_at: Int
static func from(dictionary: [AnyHashable: Any]) -> NostrEventInfoFromPushNotification? {
guard let id = dictionary["id"] as? String,
let sig = dictionary["sig"] as? String,
let kind_int = dictionary["kind"] as? UInt32,
let kind = NostrKind(rawValue: kind_int),
let tags = dictionary["tags"] as? [[String]],
let pubkey = dictionary["pubkey"] as? String,
let content = dictionary["content"] as? String,
let created_at = dictionary["created_at"] as? Int else {
return nil
}
return NostrEventInfoFromPushNotification(id: id, sig: sig, kind: kind, tags: tags, pubkey: pubkey, content: content, created_at: created_at)
}
func reactionEmoji() -> String? {
guard self.kind == NostrKind.like else {
return nil
}
switch self.content {
case "", "+":
return "❤️"
case "-":
return "👎"
default:
return self.content
}
}
}
@@ -1,48 +0,0 @@
//
// NotificationFormatter.swift
// DamusNotificationService
//
// Created by Daniel DAquino on 2023-11-13.
//
import Foundation
import UserNotifications
struct NotificationFormatter {
static var shared = NotificationFormatter()
// TODO: These is a very generic notification formatter. Once we integrate NostrDB into the extension, we should reuse various functions present in `HomeModel.swift`
func formatMessage(event: NostrEventInfoFromPushNotification) -> UNNotificationContent? {
let content = UNMutableNotificationContent()
if let event_json_data = try? JSONEncoder().encode(event), // Must be encoded, as the notification completion handler requires this object to conform to `NSSecureCoding`
let event_json_string = String(data: event_json_data, encoding: .utf8) {
content.userInfo = [
"nostr_event_info": event_json_string
]
}
switch event.kind {
case .text:
content.title = NSLocalizedString("Someone posted a note", comment: "Title label for push notification where someone posted a note")
content.body = event.content
break
case .dm:
content.title = NSLocalizedString("New message", comment: "Title label for push notifications where a direct message was sent to the user")
content.body = NSLocalizedString("(Contents are encrypted)", comment: "Label on push notification indicating that the contents of the message are encrypted")
break
case .like:
guard let reactionEmoji = event.reactionEmoji() else {
content.title = NSLocalizedString("Someone reacted to your note", comment: "Generic title label for push notifications where someone reacted to the user's post")
break
}
content.title = NSLocalizedString("New note reaction", comment: "Title label for push notifications where someone reacted to the user's post with a specific emoji")
content.body = String(format: NSLocalizedString("Someone reacted to your note with %@", comment: "Body label for push notifications where someone reacted to the user's post with a specific emoji"), reactionEmoji)
break
case .zap:
content.title = NSLocalizedString("Someone zapped you ⚡️", comment: "Title label for a push notification where someone zapped the user")
break
default:
return nil
}
return content
}
}
@@ -1,47 +0,0 @@
//
// NotificationService.swift
// DamusNotificationService
//
// Created by Daniel DAquino on 2023-11-10.
//
import UserNotifications
import Foundation
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
let ndb: Ndb? = try? Ndb(owns_db_file: false)
// Modify the notification content here...
guard let nostrEventInfoDictionary = request.content.userInfo["nostr_event"] as? [AnyHashable: Any],
let nostrEventInfo = NostrEventInfoFromPushNotification.from(dictionary: nostrEventInfoDictionary) else {
contentHandler(request.content)
return;
}
// Log that we got a push notification
if let pubkey = Pubkey(hex: nostrEventInfo.pubkey),
let txn = ndb?.lookup_profile(pubkey) {
Log.debug("Got push notification from %s (%s)", for: .push_notifications, (txn.unsafeUnownedValue?.profile?.display_name ?? "Unknown"), nostrEventInfo.pubkey)
}
if let improvedContent = NotificationFormatter.shared.formatMessage(event: nostrEventInfo) {
contentHandler(improvedContent)
}
}
override func serviceExtensionTimeWillExpire() {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
}
-37
View File
@@ -96,16 +96,6 @@ static inline void copy_cursor(struct cursor *src, struct cursor *dest)
dest->end = src->end;
}
static inline int cursor_skip(struct cursor *cursor, int n)
{
if (cursor->p + n >= cursor->end)
return 0;
cursor->p += n;
return 1;
}
static inline int pull_byte(struct cursor *cursor, u8 *c)
{
if (unlikely(cursor->p >= cursor->end))
@@ -369,20 +359,6 @@ static inline int push_sized_str(struct cursor *cursor, const char *str, int len
return cursor_push(cursor, (u8*)str, len);
}
static inline int cursor_push_lowercase(struct cursor *cur, const char *str, int len)
{
int i;
if (unlikely(cur->p + len >= cur->end))
return 0;
for (i = 0; i < len; i++)
cur->p[i] = tolower(str[i]);
cur->p += len;
return 1;
}
static inline int cursor_push_str(struct cursor *cursor, const char *str)
{
return cursor_push(cursor, (u8*)str, (int)strlen(str));
@@ -687,17 +663,4 @@ static inline int consume_until_non_alphanumeric(struct cursor *cur, int or_end)
return or_end;
}
static inline int cursor_memset(struct cursor *cursor, unsigned char c, int n)
{
if (cursor->p + n >= cursor->end)
return 0;
memset(cursor->p, c, n);
cursor->p += n;
return 1;
}
#endif
+6 -42
View File
@@ -169,9 +169,6 @@ static int consume_url_host(struct cursor *cur)
static int parse_url(struct cursor *cur, struct note_block *block) {
u8 *start = cur->p;
u8 *host;
int host_len;
struct cursor path_cur;
if (!parse_str(cur, "http"))
return 0;
@@ -188,32 +185,12 @@ static int parse_url(struct cursor *cur, struct note_block *block) {
}
}
// make sure to save the hostname. We will use this to detect damus.io links
host = cur->p;
if (!consume_url_host(cur)) {
cur->p = start;
return 0;
}
// get the length of the host string
host_len = (int)(cur->p - host);
// save the current parse state so that we can continue from here when
// parsing the bech32 in the damus.io link if we have it
copy_cursor(cur, &path_cur);
// skip leading /
cursor_skip(&path_cur, 1);
if (!consume_url_path(cur)) {
cur->p = start;
return 0;
}
if (!consume_url_fragment(cur)) {
cur->p = start;
return 0;
if (!(consume_url_host(cur) &&
consume_url_path(cur) &&
consume_url_fragment(cur)))
{
cur->p = start;
return 0;
}
// smart parens
@@ -226,19 +203,6 @@ static int parse_url(struct cursor *cur, struct note_block *block) {
cur->p--;
}
// save the bech32 string pos in case we hit a damus.io link
block->block.str.start = (const char *)path_cur.p;
// if we have a damus link, make it a mention
if (host_len == 8
&& !strncmp((const char *)host, "damus.io", 8)
&& parse_nostr_bech32(&path_cur, &block->block.mention_bech32.bech32))
{
block->block.str.end = (const char *)path_cur.p;
block->type = BLOCK_MENTION_BECH32;
return 1;
}
block->type = BLOCK_URL;
block->block.str.start = (const char *)start;
block->block.str.end = (const char *)cur->p;
File diff suppressed because it is too large Load Diff
@@ -33,24 +33,6 @@
"state" : {
"revision" : "76bb7971da7fbf429de1c84f1244adf657242fee"
}
},
{
"identity" : "swift-snapshot-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
"state" : {
"revision" : "5b356adceabff6ca027f6574aac79e9fee145d26",
"version" : "1.14.1"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-syntax.git",
"state" : {
"revision" : "74203046135342e4a4a627476dd6caf8b28fe11b",
"version" : "509.0.0"
}
}
],
"version" : 2
@@ -1,100 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D79C4C132AFEB061003A41B4"
BuildableName = "DamusNotificationService.appex"
BlueprintName = "DamusNotificationService"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<RemoteRunnable
runnableDebuggingMode = "1"
BundleIdentifier = "com.jb55.damus2"
RemotePath = "/Users/danielnogueira/Library/Developer/CoreSimulator/Devices/99E60B35-CE5D-4B45-AC35-00818C0AF3CB/data/Containers/Bundle/Application/5A083DD0-FDE2-43D7-9172-2F97FAD18F20/damus.app">
</RemoteRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</MacroExpansion>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -1,38 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x00",
"green" : "0x00",
"red" : "0x00"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
-1
View File
@@ -11,7 +11,6 @@ import SwiftUI
class DamusColors {
static let adaptableGrey = Color("DamusAdaptableGrey")
static let adaptableBlack = Color("DamusAdaptableBlack")
static let adaptableWhite = Color("DamusAdaptableWhite")
static let white = Color("DamusWhite")
static let black = Color("DamusBlack")
static let brown = Color("DamusBrown")
+1 -1
View File
@@ -190,7 +190,7 @@ struct ImageCarousel: View {
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.fullScreenCover(isPresented: $open_sheet) {
ImageView(video_controller: state.video, urls: urls, settings: state.settings)
ImageView(video_controller: state.video, urls: urls, disable_animation: state.settings.disable_animation)
}
.frame(height: height)
.onChange(of: selectedIndex) { value in
+6 -16
View File
@@ -39,12 +39,7 @@ struct InvoiceView: View {
if settings.show_wallet_selector {
present_sheet(.select_wallet(invoice: invoice.string))
} else {
do {
try open_with_wallet(wallet: settings.default_wallet.model, invoice: invoice.string)
}
catch {
present_sheet(.select_wallet(invoice: invoice.string))
}
open_with_wallet(wallet: settings.default_wallet.model, invoice: invoice.string)
}
} label: {
RoundedRectangle(cornerRadius: 20, style: .circular)
@@ -87,26 +82,21 @@ struct InvoiceView: View {
}
}
enum OpenWalletError: Error {
case no_wallet_to_open
case store_link_invalid
case system_cannot_open_store_link
}
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
func open_with_wallet(wallet: Wallet.Model, invoice: String) {
if let url = URL(string: "\(wallet.link)\(invoice)"), UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
} else {
guard let store_link = wallet.appStoreLink else {
throw OpenWalletError.no_wallet_to_open
// TODO: do something here if we don't have an appstore link
return
}
guard let url = URL(string: store_link) else {
throw OpenWalletError.store_link_invalid
return
}
guard UIApplication.shared.canOpenURL(url) else {
throw OpenWalletError.system_cannot_open_store_link
return
}
UIApplication.shared.open(url)
-14
View File
@@ -20,20 +20,6 @@ struct NeutralButtonStyle: ButtonStyle {
}
}
struct NeutralCircleButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
return configuration.label
.padding(20)
.background(DamusColors.neutral1)
.cornerRadius(9999)
.overlay(
RoundedRectangle(cornerRadius: 9999)
.stroke(DamusColors.neutral3, lineWidth: 1)
)
.scaleEffect(configuration.isPressed ? 0.95 : 1)
}
}
struct NeutralButtonStyle_Previews: PreviewProvider {
static var previews: some View {
-11
View File
@@ -11,19 +11,12 @@ import SwiftUI
struct SelectableText: View {
let attributedString: AttributedString
let textAlignment: NSTextAlignment
@State private var selectedTextHeight: CGFloat = .zero
@State private var selectedTextWidth: CGFloat = .zero
let size: EventViewKind
init(attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) {
self.attributedString = attributedString
self.textAlignment = textAlignment ?? NSTextAlignment.natural
self.size = size
}
var body: some View {
GeometryReader { geo in
TextViewRepresentable(
@@ -31,7 +24,6 @@ struct SelectableText: View {
textColor: UIColor.label,
font: eventviewsize_to_uifont(size),
fixedWidth: selectedTextWidth,
textAlignment: self.textAlignment,
height: $selectedTextHeight
)
.padding([.leading, .trailing], -1.0)
@@ -56,7 +48,6 @@ struct SelectableText: View {
let textColor: UIColor
let font: UIFont
let fixedWidth: CGFloat
let textAlignment: NSTextAlignment
@Binding var height: CGFloat
@@ -70,14 +61,12 @@ struct SelectableText: View {
view.textContainerInset = .zero
view.textContainerInset.left = 1.0
view.textContainerInset.right = 1.0
view.textAlignment = textAlignment
return view
}
func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<Self>) {
let mutableAttributedString = createNSAttributedString()
uiView.attributedText = mutableAttributedString
uiView.textAlignment = self.textAlignment
let newHeight = mutableAttributedString.height(containerWidth: fixedWidth)
+59 -117
View File
@@ -52,21 +52,13 @@ enum StatusDuration: CustomStringConvertible, CaseIterable {
}
}
enum Fields{
case status
case link
}
struct UserStatusSheet: View {
let damus_state: DamusState
let postbox: PostBox
let keypair: Keypair
@State var duration: StatusDuration = .never
@State var show_link: Bool = false
@ObservedObject var status: UserStatusModel
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
var status_binding: Binding<String> {
@@ -94,125 +86,75 @@ struct UserStatusSheet: View {
}
var body: some View {
// This is needed to prevent the view from being moved when the keyboard is shown
GeometryReader { geometry in
VStack {
HStack {
Button(action: {
dismiss()
}, label: {
Text("Cancel", comment: "Cancel button text for dismissing profile status settings view.")
.padding(10)
})
.buttonStyle(NeutralButtonStyle())
Spacer()
Button(action: {
guard let status = self.status.general,
let kp = keypair.to_full(),
let ev = make_user_status_note(status: status, keypair: kp, expiry: duration.expiration)
else {
return
}
postbox.send(ev)
dismiss()
}, label: {
Text("Share", comment: "Save button text for saving profile status settings.")
})
.buttonStyle(GradientButtonStyle(padding: 10))
}
.padding(5)
Divider()
ZStack(alignment: .top) {
ProfilePicView(pubkey: keypair.pubkey, size: 120.0, highlight: .custom(DamusColors.white, 3.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
.padding(.top, 30)
VStack(spacing: 0) {
HStack {
TextField(NSLocalizedString("Staying humble...", comment: "Placeholder as an example of what the user could set as their profile status."), text: status_binding, axis: .vertical)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.lineLimit(3)
.frame(width: 175)
}
.padding(10)
.background(colorScheme == .light ? .white : DamusColors.neutral3)
.cornerRadius(15)
.shadow(color: colorScheme == .light ? DamusColors.neutral3 : .clear, radius: 15)
Circle()
.fill(colorScheme == .light ? .white : DamusColors.neutral3)
.frame(width: 12, height: 12)
.padding(.trailing, 140)
Circle()
.fill(colorScheme == .light ? .white : DamusColors.neutral3)
.frame(width: 7, height: 7)
.padding(.trailing, 120)
}
.padding(.leading, 60)
}
VStack {
HStack {
Image("link")
.foregroundColor(DamusColors.neutral3)
TextField(text: url_binding, label: {
Text("Add an external link", comment: "Placeholder as an example of what the user could set so that the link is opened when the status is tapped.")
})
.autocorrectionDisabled(true)
}
.padding(10)
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(DamusColors.neutral3, lineWidth: 1)
)
}
.padding()
Toggle(isOn: $status.playing_enabled, label: {
Text("Broadcast music playing on Apple Music", comment: "Toggle to enable or disable broadcasting what music is being played on Apple Music in their profile status.")
VStack(alignment: .leading, spacing: 20) {
Text("Set Status", comment: "Title of view that allows the user to set their profile status (e.g. working, studying, coding)")
.font(.largeTitle)
TextField(text: status_binding, label: {
Text("📋 Working", comment: "Placeholder as an example of what the user could set as their profile status.")
})
HStack {
Image("link")
TextField(text: url_binding, label: {
Text("https://example.com", comment: "Placeholder as an example of what the user could set so that the link is opened when the status is tapped.")
})
.tint(DamusColors.purple)
.padding(.horizontal)
HStack {
Text("Clear status", comment: "Label to prompt user to select an expiration time for the profile status to clear.")
Spacer()
Picker(NSLocalizedString("Duration", comment: "Label for profile status expiration duration picker."), selection: $duration) {
ForEach(StatusDuration.allCases, id: \.self) { d in
Text(verbatim: d.description)
.tag(d)
}
}
HStack {
Text("Clear status", comment: "Label to prompt user to select an expiration time for the profile status to clear.")
Spacer()
Picker(NSLocalizedString("Duration", comment: "Label for profile status expiration duration picker."), selection: $duration) {
ForEach(StatusDuration.allCases, id: \.self) { d in
Text(verbatim: d.description)
.tag(d)
}
}
.padding()
Spacer()
}
.padding(.top)
.background(DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all))
Toggle(isOn: $status.playing_enabled, label: {
Text("Broadcast music playing on Apple Music", comment: "Toggle to enable or disable broadcasting what music is being played on Apple Music in their profile status.")
})
HStack(alignment: .center) {
Button(action: {
dismiss()
}, label: {
Text("Cancel", comment: "Cancel button text for dismissing profile status settings view.")
})
Spacer()
Button(action: {
guard let status = self.status.general,
let kp = keypair.to_full(),
let ev = make_user_status_note(status: status, keypair: kp, expiry: duration.expiration)
else {
return
}
postbox.send(ev)
dismiss()
}, label: {
Text("Save", comment: "Save button text for saving profile status settings.")
})
.buttonStyle(GradientButtonStyle())
}
.padding([.top], 30)
Spacer()
}
.dismissKeyboardOnTap()
.ignoresSafeArea(.keyboard, edges: .bottom)
.padding(30)
}
}
struct UserStatusSheet_Previews: PreviewProvider {
static var previews: some View {
UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.postbox, keypair: test_keypair, status: .init())
UserStatusSheet(postbox: test_damus_state.postbox, keypair: test_keypair, status: .init())
}
}
+3 -27
View File
@@ -9,57 +9,33 @@ import SwiftUI
struct WebsiteLink: View {
let url: URL
let style: StyleVariant
@Environment(\.openURL) var openURL
init(url: URL, style: StyleVariant? = nil) {
self.url = url
self.style = style ?? .normal
}
var body: some View {
HStack {
Image("link")
.resizable()
.frame(width: 16, height: 16)
.foregroundColor(self.style == .accent ? .white : .gray)
.padding(.vertical, 5)
.padding([.leading], 10)
.foregroundColor(.gray)
.font(.footnote)
Button(action: {
openURL(url)
}, label: {
Text(link_text)
.font(.footnote)
.foregroundColor(self.style == .accent ? .white : .accentColor)
.foregroundColor(.accentColor)
.truncationMode(.tail)
.lineLimit(1)
})
.padding(.vertical, 5)
.padding([.trailing], 10)
}
.background(
self.style == .accent ?
AnyView(RoundedRectangle(cornerRadius: 50).fill(PinkGradient))
: AnyView(Color.clear)
)
}
var link_text: String {
url.host ?? url.absoluteString
}
enum StyleVariant {
case normal
case accent
}
}
struct WebsiteLink_Previews: PreviewProvider {
static var previews: some View {
WebsiteLink(url: URL(string: "https://jb55.com")!)
.previewDisplayName("Normal")
WebsiteLink(url: URL(string: "https://jb55.com")!, style: .accent)
.previewDisplayName("Accent")
}
}
@@ -1,5 +1,5 @@
//
// NoteZapButton.swift
// ZapButton.swift
// damus
//
// Created by William Casarin on 2023-01-17.
@@ -18,19 +18,6 @@ enum ZappingError {
case bad_lnurl
case canceled
case send_failed
func humanReadableMessage() -> String {
switch self {
case .fetching_invoice:
return NSLocalizedString("Error fetching lightning invoice", comment: "Message to display when there was an error fetching a lightning invoice while attempting to zap.")
case .bad_lnurl:
return NSLocalizedString("Invalid lightning address", comment: "Message to display when there was an error attempting to zap due to an invalid lightning address.")
case .canceled:
return NSLocalizedString("Zap attempt from connected wallet was canceled.", comment: "Message to display when a zap from the user's connected wallet was canceled.")
case .send_failed:
return NSLocalizedString("Zap attempt from connected wallet failed.", comment: "Message to display when sending a zap from the user's connected wallet failed.")
}
}
}
struct ZappingEvent {
@@ -39,7 +26,7 @@ struct ZappingEvent {
let target: ZapTarget
}
struct NoteZapButton: View {
struct ZapButton: View {
let damus_state: DamusState
let target: ZapTarget
let lnurl: String
@@ -157,7 +144,7 @@ struct ZapButton_Previews: PreviewProvider {
let pending_zap = PendingZap(amount_msat: 1000, target: ZapTarget.note(id: test_note.id, author: test_note.pubkey), request: .normal(test_zap_request), type: .pub, state: .external(.init(state: .fetching_invoice)))
let zaps = ZapsDataModel([.pending(pending_zap)])
NoteZapButton(damus_state: test_damus_state, target: ZapTarget.note(id: test_note.id, author: test_note.pubkey), lnurl: "lnurl", zaps: zaps)
ZapButton(damus_state: test_damus_state, target: ZapTarget.note(id: test_note.id, author: test_note.pubkey), lnurl: "lnurl", zaps: zaps)
}
}
+60 -83
View File
@@ -22,12 +22,11 @@ enum Sheets: Identifiable {
case post(PostAction)
case report(ReportTarget)
case event(NostrEvent)
case profile_action(Pubkey)
case zap(ZapSheet)
case select_wallet(SelectWallet)
case filter
case user_status
case onboardingSuggestions
case suggestedUsers
static func zap(target: ZapTarget, lnurl: String) -> Sheets {
return .zap(ZapSheet(target: target, lnurl: lnurl))
@@ -43,18 +42,16 @@ enum Sheets: Identifiable {
case .user_status: return "user_status"
case .post(let action): return "post-" + (action.ev?.id.hex() ?? "")
case .event(let ev): return "event-" + ev.id.hex()
case .profile_action(let pubkey): return "profile-action-" + pubkey.npub
case .zap(let sheet): return "zap-" + hex_encode(sheet.target.id)
case .select_wallet: return "select-wallet"
case .filter: return "filter"
case .onboardingSuggestions: return "onboarding-suggestions"
case .suggestedUsers: return "suggested-users"
}
}
}
struct ContentView: View {
let keypair: Keypair
let appDelegate: AppDelegate?
var pubkey: Pubkey {
return keypair.pubkey
@@ -67,7 +64,7 @@ struct ContentView: View {
@Environment(\.scenePhase) var scenePhase
@State var active_sheet: Sheets? = nil
@State var damus_state: DamusState!
@State var damus_state: DamusState? = nil
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home
@State var muting: Pubkey? = nil
@State var confirm_mute: Bool = false
@@ -77,7 +74,7 @@ struct ContentView: View {
@State private var isSideBarOpened = false
var home: HomeModel = HomeModel()
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
@AppStorage("has_seen_suggested_users") private var hasSeenSuggestedUsers = false
let sub_id = UUID().description
@Environment(\.colorScheme) var colorScheme
@@ -91,7 +88,7 @@ struct ContentView: View {
}
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: damus_state!)
var filters = ContentFilters.defaults(damus_state!.settings)
filters.append(fstate.filter)
return ContentFilters(filters: filters).filter
}
@@ -133,15 +130,13 @@ struct ContentView: View {
}
func contentTimelineView(filter: (@escaping (NostrEvent) -> Bool)) -> some View {
TimelineView(events: home.events, loading: .constant(false), damus: damus_state, show_friend_icon: false, filter: filter) {
PullDownSearchView(state: damus_state, on_cancel: {})
ZStack {
if let damus = self.damus_state {
TimelineView<AnyView>(events: home.events, loading: .constant(false), damus: damus, show_friend_icon: false, filter: filter)
}
}
}
func navIsAtRoot() -> Bool {
return navigationCoordinator.isAtRoot()
}
func popToRoot() {
navigationCoordinator.popToRoot()
isSideBarOpened = false
@@ -153,25 +148,26 @@ struct ContentView: View {
}
func MainContent(damus: DamusState) -> some View {
ZStack {
if #available(iOS 16.0, *) {
SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
.scrollDismissesKeyboard(.immediately)
.opacity(selected_timeline == .search ? 1 : 0)
} else {
// Fallback on earlier versions
SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
.opacity(selected_timeline == .search ? 1 : 0)
VStack {
switch selected_timeline {
case .search:
if #available(iOS 16.0, *) {
SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
.scrollDismissesKeyboard(.immediately)
} else {
// Fallback on earlier versions
SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
}
case .home:
PostingTimelineView
case .notifications:
NotificationsView(state: damus, notifications: home.notifications)
case .dms:
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
}
PostingTimelineView
.opacity(selected_timeline == .home ? 1 : 0)
NotificationsView(state: damus, notifications: home.notifications)
.opacity(selected_timeline == .notifications ? 1 : 0)
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
.opacity(selected_timeline == .dms ? 1 : 0)
}
.navigationBarTitle(timeline_name(selected_timeline), displayMode: .inline)
.toolbar {
@@ -184,9 +180,6 @@ struct ContentView: View {
.shadow(color: DamusColors.purple, radius: 2)
.opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
.onTapGesture {
isSideBarOpened.toggle()
}
} else {
timelineNavItem
.opacity(isSideBarOpened ? 0 : 1)
@@ -199,8 +192,12 @@ struct ContentView: View {
func MaybeReportView(target: ReportTarget) -> some View {
Group {
if let keypair = damus_state.keypair.to_full() {
ReportView(postbox: damus_state.postbox, target: target, keypair: keypair)
if let damus_state {
if let keypair = damus_state.keypair.to_full() {
ReportView(postbox: damus_state.postbox, target: target, keypair: keypair)
} else {
EmptyView()
}
} else {
EmptyView()
}
@@ -258,13 +255,16 @@ struct ContentView: View {
// maybe expand this to other timelines in the future
if selected_timeline == .search {
Button(action: {
//isFilterVisible.toggle()
present_sheet(.filter)
}, label: {
Image("filter")
}) {
// checklist, checklist.checked, lisdt.bullet, list.bullet.circle, line.3.horizontal.decrease..., line.3.horizontail.decrease
Label(NSLocalizedString("Filter", comment: "Button label text for filtering relay servers."), image: "filter")
.foregroundColor(.gray)
})
//.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}
@@ -293,11 +293,10 @@ struct ContentView: View {
self.connect()
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers)
setup_notifications()
if !hasSeenOnboardingSuggestions || damus_state!.settings.always_show_onboarding_suggestions {
active_sheet = .onboardingSuggestions
hasSeenOnboardingSuggestions = true
if !hasSeenSuggestedUsers {
active_sheet = .suggestedUsers
hasSeenSuggestedUsers = true
}
self.appDelegate?.settings = damus_state?.settings
}
.sheet(item: $active_sheet) { item in
switch item {
@@ -306,23 +305,24 @@ struct ContentView: View {
case .post(let action):
PostView(action: action, damus_state: damus_state!)
case .user_status:
UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
.presentationDragIndicator(.visible)
UserStatusSheet(postbox: damus_state!.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
case .event:
EventDetailView()
case .profile_action(let pubkey):
ProfileActionSheetView(damus_state: damus_state!, pubkey: pubkey)
case .zap(let zapsheet):
CustomizeZapView(state: damus_state!, target: zapsheet.target, lnurl: zapsheet.lnurl)
case .select_wallet(let select):
SelectWalletView(default_wallet: damus_state!.settings.default_wallet, active_sheet: $active_sheet, our_pubkey: damus_state!.pubkey, invoice: select.invoice)
case .filter:
let timeline = selected_timeline
RelayFilterView(state: damus_state!, timeline: timeline)
.presentationDetents([.height(550)])
.presentationDragIndicator(.visible)
case .onboardingSuggestions:
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: damus_state!))
if #available(iOS 16.0, *) {
RelayFilterView(state: damus_state!, timeline: timeline)
.presentationDetents([.height(550)])
.presentationDragIndicator(.visible)
} else {
RelayFilterView(state: damus_state!, timeline: timeline)
}
case .suggestedUsers:
SuggestedUsersView(model: SuggestedUsersViewModel(damus_state: damus_state!))
}
}
.onOpenURL { url in
@@ -436,12 +436,7 @@ struct ContentView: View {
present_sheet(.select_wallet(invoice: inv))
} else {
let wallet = damus_state!.settings.default_wallet.model
do {
try open_with_wallet(wallet: wallet, invoice: inv)
}
catch {
present_sheet(.select_wallet(invoice: inv))
}
open_with_wallet(wallet: wallet, invoice: inv)
}
case .sent_from_nwc:
break
@@ -586,12 +581,11 @@ struct ContentView: View {
func switch_timeline(_ timeline: Timeline) {
self.isSideBarOpened = false
let navWasAtRoot = self.navIsAtRoot()
self.popToRoot()
notify(.switched_timeline(timeline))
if timeline == self.selected_timeline && navWasAtRoot {
if timeline == self.selected_timeline {
notify(.scroll_to_top)
return
}
@@ -601,20 +595,7 @@ struct ContentView: View {
func connect() {
// nostrdb
var mndb = Ndb()
if mndb == nil {
// try recovery
print("DB ISSUE! RECOVERING")
mndb = Ndb.safemode()
// out of space or something?? maybe we need a in-memory fallback
if mndb == nil {
notify(.logout)
return
}
}
guard let ndb = mndb else { return }
let ndb = Ndb()!
let pool = RelayPool(ndb: ndb)
let model_cache = RelayModelCache()
@@ -641,6 +622,8 @@ struct ContentView: View {
try? pool.add_relay(.nwc(url: nwc.relay))
}
let user_search_cache = UserSearchCache()
self.damus_state = DamusState(pool: pool,
keypair: keypair,
likes: EventCounter(our_pubkey: pubkey),
@@ -700,7 +683,7 @@ struct ContentView: View {
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(keypair: Keypair(pubkey: test_pubkey, privkey: nil), appDelegate: nil)
ContentView(keypair: Keypair(pubkey: test_pubkey, privkey: nil))
}
}
@@ -996,12 +979,6 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
postbox.send(ev)
}
}
for qref in new_ev.referenced_quote_ids.prefix(3) {
// also broadcast at most 3 referenced quoted events
if let ev = events.lookup(qref.note_id) {
postbox.send(ev)
}
}
return true
case .cancel:
print("post cancelled")
+2 -2
View File
@@ -67,10 +67,10 @@
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>Damus needs access to your camera in order to upload photos and scan QR codes.</string>
<string>Damus needs access to your camera if you want to upload photos from it</string>
<key>NSAppleMusicUsageDescription</key>
<string>Damus needs access to your media library for playback statuses</string>
<key>NSMicrophoneUsageDescription</key>
<string>Damus needs access to your microphone for creating video recording posts</string>
<string>Damus needs access to your microphone if you want to upload recorded videos from it</string>
</dict>
</plist>
-122
View File
@@ -1,122 +0,0 @@
//
// CameraModel.swift
// damus
//
// Created by Suhail Saqan on 8/5/23.
//
import Foundation
import AVFoundation
import Combine
final class CameraModel: ObservableObject {
private let service = CameraService()
@Published var showAlertError = false
@Published var isFlashOn = false
@Published var willCapturePhoto = false
@Published var isCameraButtonDisabled = false
@Published var isPhotoProcessing = false
@Published var isRecording = false
@Published var captureMode: CameraMediaType = .image
@Published public var mediaItems: [MediaItem] = []
@Published var thumbnail: Thumbnail!
var alertError: AlertError!
var session: AVCaptureSession
private var subscriptions = Set<AnyCancellable>()
init() {
self.session = service.session
service.$shouldShowAlertView.sink { [weak self] (val) in
self?.alertError = self?.service.alertError
self?.showAlertError = val
}
.store(in: &self.subscriptions)
service.$flashMode.sink { [weak self] (mode) in
self?.isFlashOn = mode == .on
}
.store(in: &self.subscriptions)
service.$willCapturePhoto.sink { [weak self] (val) in
self?.willCapturePhoto = val
}
.store(in: &self.subscriptions)
service.$isCameraButtonDisabled.sink { [weak self] (val) in
self?.isCameraButtonDisabled = val
}
.store(in: &self.subscriptions)
service.$isPhotoProcessing.sink { [weak self] (val) in
self?.isPhotoProcessing = val
}
.store(in: &self.subscriptions)
service.$isRecording.sink { [weak self] (val) in
self?.isRecording = val
}
.store(in: &self.subscriptions)
service.$captureMode.sink { [weak self] (mode) in
self?.captureMode = mode
}
.store(in: &self.subscriptions)
service.$mediaItems.sink { [weak self] (mode) in
self?.mediaItems = mode
}
.store(in: &self.subscriptions)
service.$thumbnail.sink { [weak self] (thumbnail) in
guard let pic = thumbnail else { return }
self?.thumbnail = pic
}
.store(in: &self.subscriptions)
}
func configure() {
service.checkForPermissions()
service.configure()
}
func stop() {
service.stop()
}
func capturePhoto() {
service.capturePhoto()
}
func startRecording() {
service.startRecording()
}
func stopRecording() {
service.stopRecording()
}
func flipCamera() {
service.changeCamera()
}
func zoom(with factor: CGFloat) {
service.set(zoom: factor)
}
func switchFlash() {
service.flashMode = service.flashMode == .on ? .off : .on
}
}
@@ -1,32 +0,0 @@
//
// CameraService+Extensions.swift
// damus
//
// Created by Suhail Saqan on 8/5/23.
//
import Foundation
import UIKit
import AVFoundation
extension AVCaptureVideoOrientation {
init?(deviceOrientation: UIDeviceOrientation) {
switch deviceOrientation {
case .portrait: self = .portrait
case .portraitUpsideDown: self = .portraitUpsideDown
case .landscapeLeft: self = .landscapeRight
case .landscapeRight: self = .landscapeLeft
default: return nil
}
}
init?(interfaceOrientation: UIInterfaceOrientation) {
switch interfaceOrientation {
case .portrait: self = .portrait
case .portraitUpsideDown: self = .portraitUpsideDown
case .landscapeLeft: self = .landscapeLeft
case .landscapeRight: self = .landscapeRight
default: return nil
}
}
}
-693
View File
@@ -1,693 +0,0 @@
//
// CameraService.swift
// Campus
//
// Created by Suhail Saqan on 8/5/23.
//
import Foundation
import Combine
import AVFoundation
import Photos
import UIKit
public struct Thumbnail: Identifiable, Equatable {
public var id: String
public var type: CameraMediaType
public var url: URL
public init(id: String = UUID().uuidString, type: CameraMediaType, url: URL) {
self.id = id
self.type = type
self.url = url
}
public var thumbnailImage: UIImage? {
switch type {
case .image:
return ImageResizer(targetWidth: 100).resize(at: url)
case .video:
return generateVideoThumbnail(for: url)
}
}
}
public struct AlertError {
public var title: String = ""
public var message: String = ""
public var primaryButtonTitle = "Accept"
public var secondaryButtonTitle: String?
public var primaryAction: (() -> ())?
public var secondaryAction: (() -> ())?
public init(title: String = "", message: String = "", primaryButtonTitle: String = "Accept", secondaryButtonTitle: String? = nil, primaryAction: (() -> ())? = nil, secondaryAction: (() -> ())? = nil) {
self.title = title
self.message = message
self.primaryAction = primaryAction
self.primaryButtonTitle = primaryButtonTitle
self.secondaryAction = secondaryAction
}
}
func generateVideoThumbnail(for videoURL: URL) -> UIImage? {
let asset = AVAsset(url: videoURL)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
do {
let cgImage = try imageGenerator.copyCGImage(at: .zero, actualTime: nil)
return UIImage(cgImage: cgImage)
} catch {
print("Error generating thumbnail: \(error)")
return nil
}
}
public enum CameraMediaType {
case image
case video
}
public struct MediaItem {
let url: URL
let type: CameraMediaType
}
public class CameraService: NSObject, Identifiable {
public let session = AVCaptureSession()
public var isSessionRunning = false
public var isConfigured = false
var setupResult: SessionSetupResult = .success
public var alertError: AlertError = AlertError()
@Published public var flashMode: AVCaptureDevice.FlashMode = .off
@Published public var shouldShowAlertView = false
@Published public var isPhotoProcessing = false
@Published public var captureMode: CameraMediaType = .image
@Published public var isRecording: Bool = false
@Published public var willCapturePhoto = false
@Published public var isCameraButtonDisabled = false
@Published public var isCameraUnavailable = false
@Published public var thumbnail: Thumbnail?
@Published public var mediaItems: [MediaItem] = []
public let sessionQueue = DispatchQueue(label: "io.damus.camera")
@objc dynamic public var videoDeviceInput: AVCaptureDeviceInput!
@objc dynamic public var audioDeviceInput: AVCaptureDeviceInput!
public let videoDeviceDiscoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera, .builtInDualCamera, .builtInTrueDepthCamera], mediaType: .video, position: .unspecified)
public let photoOutput = AVCapturePhotoOutput()
public let movieOutput = AVCaptureMovieFileOutput()
var videoCaptureProcessor: VideoCaptureProcessor?
var photoCaptureProcessor: PhotoCaptureProcessor?
public var keyValueObservations = [NSKeyValueObservation]()
override public init() {
super.init()
DispatchQueue.main.async {
self.isCameraButtonDisabled = true
self.isCameraUnavailable = true
}
}
enum SessionSetupResult {
case success
case notAuthorized
case configurationFailed
}
public func configure() {
if !self.isSessionRunning && !self.isConfigured {
sessionQueue.async {
self.configureSession()
}
}
}
public func checkForPermissions() {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
break
case .notDetermined:
sessionQueue.suspend()
AVCaptureDevice.requestAccess(for: .video, completionHandler: { granted in
if !granted {
self.setupResult = .notAuthorized
}
self.sessionQueue.resume()
})
default:
setupResult = .notAuthorized
DispatchQueue.main.async {
self.alertError = AlertError(title: "Camera Access", message: "Damus needs camera and microphone access. Enable in settings.", primaryButtonTitle: "Go to settings", secondaryButtonTitle: nil, primaryAction: {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!,
options: [:], completionHandler: nil)
}, secondaryAction: nil)
self.shouldShowAlertView = true
self.isCameraUnavailable = true
self.isCameraButtonDisabled = true
}
}
}
private func configureSession() {
if setupResult != .success {
return
}
session.beginConfiguration()
session.sessionPreset = .high
// Add video input.
do {
var defaultVideoDevice: AVCaptureDevice?
if let backCameraDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
// If a rear dual camera is not available, default to the rear wide angle camera.
defaultVideoDevice = backCameraDevice
} else if let frontCameraDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) {
// If the rear wide angle camera isn't available, default to the front wide angle camera.
defaultVideoDevice = frontCameraDevice
}
guard let videoDevice = defaultVideoDevice else {
print("Default video device is unavailable.")
setupResult = .configurationFailed
session.commitConfiguration()
return
}
let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice)
if session.canAddInput(videoDeviceInput) {
session.addInput(videoDeviceInput)
self.videoDeviceInput = videoDeviceInput
} else {
print("Couldn't add video device input to the session.")
setupResult = .configurationFailed
session.commitConfiguration()
return
}
let audioDevice = AVCaptureDevice.default(for: .audio)
let audioDeviceInput = try AVCaptureDeviceInput(device: audioDevice!)
if session.canAddInput(audioDeviceInput) {
session.addInput(audioDeviceInput)
self.audioDeviceInput = audioDeviceInput
} else {
print("Couldn't add audio device input to the session.")
setupResult = .configurationFailed
session.commitConfiguration()
return
}
// Add video output
if session.canAddOutput(movieOutput) {
session.addOutput(movieOutput)
} else {
print("Could not add movie output to the session")
setupResult = .configurationFailed
session.commitConfiguration()
return
}
} catch {
print("Couldn't create video device input: \(error)")
setupResult = .configurationFailed
session.commitConfiguration()
return
}
// Add the photo output.
if session.canAddOutput(photoOutput) {
session.addOutput(photoOutput)
photoOutput.maxPhotoQualityPrioritization = .quality
} else {
print("Could not add photo output to the session")
setupResult = .configurationFailed
session.commitConfiguration()
return
}
session.commitConfiguration()
self.isConfigured = true
self.start()
}
private func resumeInterruptedSession() {
sessionQueue.async {
self.session.startRunning()
self.isSessionRunning = self.session.isRunning
if !self.session.isRunning {
DispatchQueue.main.async {
self.alertError = AlertError(title: "Camera Error", message: "Unable to resume camera", primaryButtonTitle: "Accept", secondaryButtonTitle: nil, primaryAction: nil, secondaryAction: nil)
self.shouldShowAlertView = true
self.isCameraUnavailable = true
self.isCameraButtonDisabled = true
}
} else {
DispatchQueue.main.async {
self.isCameraUnavailable = false
self.isCameraButtonDisabled = false
}
}
}
}
public func changeCamera() {
DispatchQueue.main.async {
self.isCameraButtonDisabled = true
}
sessionQueue.async {
let currentVideoDevice = self.videoDeviceInput.device
let currentPosition = currentVideoDevice.position
let preferredPosition: AVCaptureDevice.Position
let preferredDeviceType: AVCaptureDevice.DeviceType
switch currentPosition {
case .unspecified, .front:
preferredPosition = .back
preferredDeviceType = .builtInWideAngleCamera
case .back:
preferredPosition = .front
preferredDeviceType = .builtInWideAngleCamera
@unknown default:
print("Unknown capture position. Defaulting to back, dual-camera.")
preferredPosition = .back
preferredDeviceType = .builtInWideAngleCamera
}
let devices = self.videoDeviceDiscoverySession.devices
var newVideoDevice: AVCaptureDevice? = nil
if let device = devices.first(where: { $0.position == preferredPosition && $0.deviceType == preferredDeviceType }) {
newVideoDevice = device
} else if let device = devices.first(where: { $0.position == preferredPosition }) {
newVideoDevice = device
}
if let videoDevice = newVideoDevice {
do {
let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice)
self.session.beginConfiguration()
self.session.removeInput(self.videoDeviceInput)
if self.session.canAddInput(videoDeviceInput) {
NotificationCenter.default.removeObserver(self, name: .AVCaptureDeviceSubjectAreaDidChange, object: currentVideoDevice)
NotificationCenter.default.addObserver(self, selector: #selector(self.subjectAreaDidChange), name: .AVCaptureDeviceSubjectAreaDidChange, object: videoDeviceInput.device)
self.session.addInput(videoDeviceInput)
self.videoDeviceInput = videoDeviceInput
} else {
self.session.addInput(self.videoDeviceInput)
}
if let connection = self.photoOutput.connection(with: .video) {
if connection.isVideoStabilizationSupported {
connection.preferredVideoStabilizationMode = .auto
}
}
self.photoOutput.maxPhotoQualityPrioritization = .quality
self.session.commitConfiguration()
} catch {
print("Error occurred while creating video device input: \(error)")
}
}
DispatchQueue.main.async {
self.isCameraButtonDisabled = false
}
}
}
public func focus(with focusMode: AVCaptureDevice.FocusMode, exposureMode: AVCaptureDevice.ExposureMode, at devicePoint: CGPoint, monitorSubjectAreaChange: Bool) {
sessionQueue.async {
guard let device = self.videoDeviceInput?.device else { return }
do {
try device.lockForConfiguration()
if device.isFocusPointOfInterestSupported && device.isFocusModeSupported(focusMode) {
device.focusPointOfInterest = devicePoint
device.focusMode = focusMode
}
if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(exposureMode) {
device.exposurePointOfInterest = devicePoint
device.exposureMode = exposureMode
}
device.isSubjectAreaChangeMonitoringEnabled = monitorSubjectAreaChange
device.unlockForConfiguration()
} catch {
print("Could not lock device for configuration: \(error)")
}
}
}
public func focus(at focusPoint: CGPoint) {
let device = self.videoDeviceInput.device
do {
try device.lockForConfiguration()
if device.isFocusPointOfInterestSupported {
device.focusPointOfInterest = focusPoint
device.exposurePointOfInterest = focusPoint
device.exposureMode = .continuousAutoExposure
device.focusMode = .continuousAutoFocus
device.unlockForConfiguration()
}
}
catch {
print(error.localizedDescription)
}
}
@objc public func stop(completion: (() -> ())? = nil) {
sessionQueue.async {
if self.isSessionRunning {
if self.setupResult == .success {
self.session.stopRunning()
self.isSessionRunning = self.session.isRunning
print("CAMERA STOPPED")
self.removeObservers()
if !self.session.isRunning {
DispatchQueue.main.async {
self.isCameraButtonDisabled = true
self.isCameraUnavailable = true
completion?()
}
}
}
}
}
}
@objc public func start() {
sessionQueue.async {
if !self.isSessionRunning && self.isConfigured {
switch self.setupResult {
case .success:
self.addObservers()
self.session.startRunning()
print("CAMERA RUNNING")
self.isSessionRunning = self.session.isRunning
if self.session.isRunning {
DispatchQueue.main.async {
self.isCameraButtonDisabled = false
self.isCameraUnavailable = false
}
}
case .notAuthorized:
print("Application not authorized to use camera")
DispatchQueue.main.async {
self.isCameraButtonDisabled = true
self.isCameraUnavailable = true
}
case .configurationFailed:
DispatchQueue.main.async {
self.alertError = AlertError(title: "Camera Error", message: "Camera configuration failed. Either your device camera is not available or other application is using it", primaryButtonTitle: "Accept", secondaryButtonTitle: nil, primaryAction: nil, secondaryAction: nil)
self.shouldShowAlertView = true
self.isCameraButtonDisabled = true
self.isCameraUnavailable = true
}
}
}
}
}
public func set(zoom: CGFloat) {
let factor = zoom < 1 ? 1 : zoom
let device = self.videoDeviceInput.device
do {
try device.lockForConfiguration()
device.videoZoomFactor = factor
device.unlockForConfiguration()
}
catch {
print(error.localizedDescription)
}
}
public func capturePhoto() {
if self.setupResult != .configurationFailed {
let videoPreviewLayerOrientation: AVCaptureVideoOrientation = .portrait
self.isCameraButtonDisabled = true
sessionQueue.async {
if let photoOutputConnection = self.photoOutput.connection(with: .video) {
photoOutputConnection.videoOrientation = videoPreviewLayerOrientation
}
var photoSettings = AVCapturePhotoSettings()
// Capture HEIF photos when supported. Enable according to user settings and high-resolution photos.
if (self.photoOutput.availablePhotoCodecTypes.contains(.hevc)) {
photoSettings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc])
}
if self.videoDeviceInput.device.isFlashAvailable {
photoSettings.flashMode = self.flashMode
}
if !photoSettings.__availablePreviewPhotoPixelFormatTypes.isEmpty {
photoSettings.previewPhotoFormat = [kCVPixelBufferPixelFormatTypeKey as String: photoSettings.__availablePreviewPhotoPixelFormatTypes.first!]
}
photoSettings.photoQualityPrioritization = .speed
if self.photoCaptureProcessor == nil {
self.photoCaptureProcessor = PhotoCaptureProcessor(with: photoSettings, photoOutput: self.photoOutput, willCapturePhotoAnimation: {
DispatchQueue.main.async {
self.willCapturePhoto.toggle()
self.willCapturePhoto.toggle()
}
}, completionHandler: { (photoCaptureProcessor) in
if let data = photoCaptureProcessor.photoData {
let url = self.savePhoto(data: data)
if let unwrappedURL = url {
self.thumbnail = Thumbnail(type: .image, url: unwrappedURL)
}
} else {
print("Data for photo not found")
}
self.isCameraButtonDisabled = false
}, photoProcessingHandler: { animate in
self.isPhotoProcessing = animate
})
}
self.photoCaptureProcessor?.capturePhoto(settings: photoSettings)
}
}
}
public func startRecording() {
if self.setupResult != .configurationFailed {
let videoPreviewLayerOrientation: AVCaptureVideoOrientation = .portrait
self.isCameraButtonDisabled = true
sessionQueue.async {
if let videoOutputConnection = self.movieOutput.connection(with: .video) {
videoOutputConnection.videoOrientation = videoPreviewLayerOrientation
var videoSettings = [String: Any]()
if self.movieOutput.availableVideoCodecTypes.contains(.hevc) == true {
videoSettings[AVVideoCodecKey] = AVVideoCodecType.hevc
self.movieOutput.setOutputSettings(videoSettings, for: videoOutputConnection)
}
}
if self.videoCaptureProcessor == nil {
self.videoCaptureProcessor = VideoCaptureProcessor(movieOutput: self.movieOutput, beginHandler: {
self.isRecording = true
}, completionHandler: { (videoCaptureProcessor, outputFileURL) in
self.isCameraButtonDisabled = false
self.captureMode = .image
self.mediaItems.append(MediaItem(url: outputFileURL, type: .video))
self.thumbnail = Thumbnail(type: .video, url: outputFileURL)
}, videoProcessingHandler: { animate in
self.isPhotoProcessing = animate
})
}
self.videoCaptureProcessor?.startCapture(session: self.session)
}
}
}
func stopRecording() {
if let videoCaptureProcessor = self.videoCaptureProcessor {
isRecording = false
videoCaptureProcessor.stopCapture()
}
}
func savePhoto(imageType: String = "jpeg", data: Data) -> URL? {
guard let uiImage = UIImage(data: data) else {
print("Error converting media data to UIImage")
return nil
}
guard let compressedData = uiImage.jpegData(compressionQuality: 0.8) else {
print("Error converting UIImage to JPEG data")
return nil
}
let temporaryDirectory = NSTemporaryDirectory()
let tempFileName = "\(UUID().uuidString).\(imageType)"
let tempFileURL = URL(fileURLWithPath: temporaryDirectory).appendingPathComponent(tempFileName)
do {
try compressedData.write(to: tempFileURL)
self.mediaItems.append(MediaItem(url: tempFileURL, type: .image))
return tempFileURL
} catch {
print("Error saving image data to temporary URL: \(error.localizedDescription)")
}
return nil
}
private func addObservers() {
let systemPressureStateObservation = observe(\.videoDeviceInput.device.systemPressureState, options: .new) { _, change in
guard let systemPressureState = change.newValue else { return }
self.setRecommendedFrameRateRangeForPressureState(systemPressureState: systemPressureState)
}
keyValueObservations.append(systemPressureStateObservation)
// NotificationCenter.default.addObserver(self, selector: #selector(self.onOrientationChange), name: UIDevice.orientationDidChangeNotification, object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(subjectAreaDidChange),
name: .AVCaptureDeviceSubjectAreaDidChange,
object: videoDeviceInput.device)
NotificationCenter.default.addObserver(self, selector: #selector(uiRequestedNewFocusArea), name: .init(rawValue: "UserDidRequestNewFocusPoint"), object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(sessionRuntimeError),
name: .AVCaptureSessionRuntimeError,
object: session)
NotificationCenter.default.addObserver(self,
selector: #selector(sessionWasInterrupted),
name: .AVCaptureSessionWasInterrupted,
object: session)
NotificationCenter.default.addObserver(self,
selector: #selector(sessionInterruptionEnded),
name: .AVCaptureSessionInterruptionEnded,
object: session)
}
private func removeObservers() {
NotificationCenter.default.removeObserver(self)
for keyValueObservation in keyValueObservations {
keyValueObservation.invalidate()
}
keyValueObservations.removeAll()
}
@objc private func uiRequestedNewFocusArea(notification: NSNotification) {
guard let userInfo = notification.userInfo as? [String: Any], let devicePoint = userInfo["devicePoint"] as? CGPoint else { return }
self.focus(at: devicePoint)
}
@objc
private func subjectAreaDidChange(notification: NSNotification) {
let devicePoint = CGPoint(x: 0.5, y: 0.5)
focus(with: .continuousAutoFocus, exposureMode: .continuousAutoExposure, at: devicePoint, monitorSubjectAreaChange: false)
}
@objc
private func sessionRuntimeError(notification: NSNotification) {
guard let error = notification.userInfo?[AVCaptureSessionErrorKey] as? AVError else { return }
print("Capture session runtime error: \(error)")
if error.code == .mediaServicesWereReset {
sessionQueue.async {
if self.isSessionRunning {
self.session.startRunning()
self.isSessionRunning = self.session.isRunning
}
}
}
}
private func setRecommendedFrameRateRangeForPressureState(systemPressureState: AVCaptureDevice.SystemPressureState) {
let pressureLevel = systemPressureState.level
if pressureLevel == .serious || pressureLevel == .critical {
do {
try self.videoDeviceInput.device.lockForConfiguration()
print("WARNING: Reached elevated system pressure level: \(pressureLevel). Throttling frame rate.")
self.videoDeviceInput.device.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 20)
self.videoDeviceInput.device.activeVideoMaxFrameDuration = CMTime(value: 1, timescale: 15)
self.videoDeviceInput.device.unlockForConfiguration()
} catch {
print("Could not lock device for configuration: \(error)")
}
} else if pressureLevel == .shutdown {
print("Session stopped running due to shutdown system pressure level.")
}
}
@objc
private func sessionWasInterrupted(notification: NSNotification) {
DispatchQueue.main.async {
self.isCameraUnavailable = true
}
if let userInfoValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as AnyObject?,
let reasonIntegerValue = userInfoValue.integerValue,
let reason = AVCaptureSession.InterruptionReason(rawValue: reasonIntegerValue) {
print("Capture session was interrupted with reason \(reason)")
if reason == .audioDeviceInUseByAnotherClient || reason == .videoDeviceInUseByAnotherClient {
print("Session stopped running due to video devies in use by another client.")
} else if reason == .videoDeviceNotAvailableWithMultipleForegroundApps {
print("Session stopped running due to video devies is not available with multiple foreground apps.")
} else if reason == .videoDeviceNotAvailableDueToSystemPressure {
print("Session stopped running due to shutdown system pressure level.")
}
}
}
@objc
private func sessionInterruptionEnded(notification: NSNotification) {
print("Capture session interruption ended")
DispatchQueue.main.async {
self.isCameraUnavailable = false
}
}
}
@@ -1,91 +0,0 @@
//
// PhotoCaptureProcessor.swift
// damus
//
// Created by Suhail Saqan on 8/5/23.
//
import Foundation
import Photos
class PhotoCaptureProcessor: NSObject {
private(set) var requestedPhotoSettings: AVCapturePhotoSettings
private(set) var photoOutput: AVCapturePhotoOutput?
lazy var context = CIContext()
var photoData: Data?
private var maxPhotoProcessingTime: CMTime?
private let willCapturePhotoAnimation: () -> Void
private let completionHandler: (PhotoCaptureProcessor) -> Void
private let photoProcessingHandler: (Bool) -> Void
init(with requestedPhotoSettings: AVCapturePhotoSettings,
photoOutput: AVCapturePhotoOutput?,
willCapturePhotoAnimation: @escaping () -> Void,
completionHandler: @escaping (PhotoCaptureProcessor) -> Void,
photoProcessingHandler: @escaping (Bool) -> Void) {
self.requestedPhotoSettings = requestedPhotoSettings
self.willCapturePhotoAnimation = willCapturePhotoAnimation
self.completionHandler = completionHandler
self.photoProcessingHandler = photoProcessingHandler
self.photoOutput = photoOutput
}
func capturePhoto(settings: AVCapturePhotoSettings) {
if let photoOutput = self.photoOutput {
photoOutput.capturePhoto(with: settings, delegate: self)
}
}
}
extension PhotoCaptureProcessor: AVCapturePhotoCaptureDelegate {
func photoOutput(_ output: AVCapturePhotoOutput, willBeginCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings) {
maxPhotoProcessingTime = resolvedSettings.photoProcessingTimeRange.start + resolvedSettings.photoProcessingTimeRange.duration
}
func photoOutput(_ output: AVCapturePhotoOutput, willCapturePhotoFor resolvedSettings: AVCaptureResolvedPhotoSettings) {
DispatchQueue.main.async {
self.willCapturePhotoAnimation()
}
guard let maxPhotoProcessingTime = maxPhotoProcessingTime else {
return
}
DispatchQueue.main.async {
self.photoProcessingHandler(true)
}
let oneSecond = CMTime(seconds: 2, preferredTimescale: 1)
if maxPhotoProcessingTime > oneSecond {
DispatchQueue.main.async {
self.photoProcessingHandler(true)
}
}
}
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
DispatchQueue.main.async {
self.photoProcessingHandler(false)
}
if let error = error {
print("Error capturing photo: \(error)")
} else {
photoData = photo.fileDataRepresentation()
}
}
func photoOutput(_ output: AVCapturePhotoOutput, didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, error: Error?) {
if let error = error {
print("Error capturing photo: \(error)")
return
}
DispatchQueue.main.async {
self.completionHandler(self)
}
}
}
@@ -1,77 +0,0 @@
//
// VideoCaptureProcessor.swift
// damus
//
// Created by Suhail Saqan on 8/5/23.
//
import Foundation
import AVFoundation
import Photos
class VideoCaptureProcessor: NSObject {
private(set) var movieOutput: AVCaptureMovieFileOutput?
private let beginHandler: () -> Void
private let completionHandler: (VideoCaptureProcessor, URL) -> Void
private let videoProcessingHandler: (Bool) -> Void
private var session: AVCaptureSession?
init(movieOutput: AVCaptureMovieFileOutput?,
beginHandler: @escaping () -> Void,
completionHandler: @escaping (VideoCaptureProcessor, URL) -> Void,
videoProcessingHandler: @escaping (Bool) -> Void) {
self.beginHandler = beginHandler
self.completionHandler = completionHandler
self.videoProcessingHandler = videoProcessingHandler
self.movieOutput = movieOutput
}
func startCapture(session: AVCaptureSession) {
if let movieOutput = self.movieOutput, session.isRunning {
let outputFileURL = uniqueOutputFileURL()
movieOutput.startRecording(to: outputFileURL, recordingDelegate: self)
}
}
func stopCapture() {
if let movieOutput = self.movieOutput {
if movieOutput.isRecording {
movieOutput.stopRecording()
}
}
}
private func uniqueOutputFileURL() -> URL {
let tempDirectory = FileManager.default.temporaryDirectory
let fileName = UUID().uuidString + ".mov"
return tempDirectory.appendingPathComponent(fileName)
}
}
extension VideoCaptureProcessor: AVCaptureFileOutputRecordingDelegate {
func fileOutput(_ output: AVCaptureFileOutput, didStartRecordingTo fileURL: URL, from connections: [AVCaptureConnection]) {
DispatchQueue.main.async {
self.beginHandler()
}
}
func fileOutput(_ output: AVCaptureFileOutput, willFinishRecordingTo fileURL: URL, from connections: [AVCaptureConnection]) {
DispatchQueue.main.async {
self.videoProcessingHandler(true)
}
}
func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
if let error = error {
print("Error capturing video: \(error)")
return
}
DispatchQueue.main.async {
self.completionHandler(self, outputFileURL)
self.videoProcessingHandler(false)
}
}
}
+8 -25
View File
@@ -184,13 +184,8 @@ func decode_json_relays(_ content: String) -> [String: RelayInfo]? {
return decode_json(content)
}
func decode_json_relays(_ content: String) -> [RelayURL: RelayInfo]? {
return decode_json(content)
}
func remove_relay(ev: NostrEvent, current_relays: [RelayDescriptor], keypair: FullKeypair, relay: RelayURL) -> NostrEvent?{
func remove_relay(ev: NostrEvent, current_relays: [RelayDescriptor], keypair: FullKeypair, relay: String) -> NostrEvent?{
var relays = ensure_relay_info(relays: current_relays, content: ev.content)
relays.removeValue(forKey: relay)
guard let content = encode_json(relays) else {
@@ -200,10 +195,9 @@ func remove_relay(ev: NostrEvent, current_relays: [RelayDescriptor], keypair: Fu
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 3, tags: ev.tags.strings())
}
func add_relay(ev: NostrEvent, keypair: FullKeypair, current_relays: [RelayDescriptor], relay: RelayURL, info: RelayInfo) -> NostrEvent? {
func add_relay(ev: NostrEvent, keypair: FullKeypair, current_relays: [RelayDescriptor], relay: String, info: RelayInfo) -> NostrEvent? {
var relays = ensure_relay_info(relays: current_relays, content: ev.content)
guard relays.index(forKey: relay) == nil else {
return nil
}
@@ -217,22 +211,11 @@ func add_relay(ev: NostrEvent, keypair: FullKeypair, current_relays: [RelayDescr
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 3, tags: ev.tags.strings())
}
func make_relay_metadata(relays: [RelayDescriptor], keypair: FullKeypair) -> NostrEvent? {
let tags = relays.compactMap { r -> [String]? in
var tag = ["r", r.url.id]
if (r.info.read ?? true) != (r.info.write ?? true) {
tag += r.info.read == true ? ["read"] : ["write"]
}
if ((r.info.read ?? true) || (r.info.write ?? true)) && r.variant == .regular {
return tag;
}
return nil
func ensure_relay_info(relays: [RelayDescriptor], content: String) -> [String: RelayInfo] {
guard let relay_info = decode_json_relays(content) else {
return make_contact_relays(relays)
}
return NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 10_002, tags: tags)
}
func ensure_relay_info(relays: [RelayDescriptor], content: String) -> [RelayURL: RelayInfo] {
return decode_json_relays(content) ?? make_contact_relays(relays)
return relay_info
}
func is_already_following(contacts: NostrEvent, follow: FollowRef) -> Bool {
@@ -262,8 +245,8 @@ func follow_with_existing_contacts(keypair: FullKeypair, our_contacts: NostrEven
return NostrEvent(content: our_contacts.content, keypair: keypair.to_keypair(), kind: kind, tags: tags)
}
func make_contact_relays(_ relays: [RelayDescriptor]) -> [RelayURL: RelayInfo] {
func make_contact_relays(_ relays: [RelayDescriptor]) -> [String: RelayInfo] {
return relays.reduce(into: [:]) { acc, relay in
acc[relay.url] = relay.info
acc[relay.url.url.absoluteString] = relay.info
}
}
+3 -12
View File
@@ -25,15 +25,7 @@ enum FilterState : Int {
/// Simple filter to determine whether to show posts with #nsfw tags
func nsfw_tag_filter(ev: NostrEvent) -> Bool {
return ev.referenced_hashtags.first(where: { t in t.hashtag == "nsfw" }) == nil
}
func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEvent) -> Bool) {
return { ev in
guard ev.known_kind == .boost else { return true }
guard let inner_ev = ev.get_inner_event(cache: damus_state.events) else { return true }
return should_show_event(keypair: damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: inner_ev)
}
return ev.referenced_hashtags.first(where: { t in t.hashtag == "nsfw" }) == nil
}
/// Generic filter with various tweakable settings
@@ -52,12 +44,11 @@ struct ContentFilters {
}
extension ContentFilters {
static func defaults(damus_state: DamusState) -> [(NostrEvent) -> Bool] {
static func defaults(_ settings: UserSettingsStore) -> [(NostrEvent) -> Bool] {
var filters = Array<(NostrEvent) -> Bool>()
if damus_state.settings.hide_nsfw_tagged_content {
if settings.hide_nsfw_tagged_content {
filters.append(nsfw_tag_filter)
}
filters.append(get_repost_of_muted_user_filter(damus_state: damus_state))
return filters
}
}
-59
View File
@@ -1,59 +0,0 @@
//
// DamusCacheManager.swift
// damus
//
// Created by Daniel DAquino on 2023-10-04.
//
import Foundation
import Kingfisher
struct DamusCacheManager {
static var shared: DamusCacheManager = DamusCacheManager()
func clear_cache(damus_state: DamusState, completion: (() -> Void)? = nil) {
Log.info("Clearing all caches", for: .storage)
clear_kingfisher_cache(completion: {
clear_cache_folder(completion: {
Log.info("All caches cleared", for: .storage)
completion?()
})
})
}
func clear_kingfisher_cache(completion: (() -> Void)? = nil) {
Log.info("Clearing Kingfisher cache", for: .storage)
KingfisherManager.shared.cache.clearMemoryCache()
KingfisherManager.shared.cache.clearDiskCache {
Log.info("Kingfisher cache cleared", for: .storage)
completion?()
}
}
func clear_cache_folder(completion: (() -> Void)? = nil) {
Log.info("Clearing entire cache folder", for: .storage)
let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
do {
let fileNames = try FileManager.default.contentsOfDirectory(atPath: cacheURL.path)
for fileName in fileNames {
let filePath = cacheURL.appendingPathComponent(fileName)
// Prevent issues by double-checking if files are in use, and do not delete them if they are.
// This is not perfect. There is still a small chance for a race condition if a file is opened between this check and the file removal.
let isBusy = (!(access(filePath.path, F_OK) == -1 && errno == ETXTBSY))
if isBusy {
continue
}
try FileManager.default.removeItem(at: filePath)
}
Log.info("Cache folder cleared successfully.", for: .storage)
completion?()
} catch {
Log.error("Could not clear cache folder", for: .storage)
}
}
}
+1 -8
View File
@@ -7,7 +7,7 @@
import Foundation
class DraftArtifacts: Equatable {
class DraftArtifacts {
var content: NSMutableAttributedString
var media: [UploadedMedia]
@@ -15,13 +15,6 @@ class DraftArtifacts: Equatable {
self.content = content
self.media = media
}
static func == (lhs: DraftArtifacts, rhs: DraftArtifacts) -> Bool {
return (
lhs.media == rhs.media &&
lhs.content.string == rhs.content.string // Comparing the text content is not perfect but acceptable in this case because attributes for our post editor are determined purely from text content
)
}
}
class Drafts: ObservableObject {
+1
View File
@@ -145,3 +145,4 @@ func event_is_reply(_ refs: [EventRef]) -> Bool {
return evref.is_reply != nil
}
}
+1 -2
View File
@@ -64,8 +64,7 @@ class EventsModel: ObservableObject {
case .ok:
break
case .eose:
let txn = NdbTxn(ndb: self.state.ndb)
load_profiles(context: "events_model", profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events), damus_state: state, txn: txn)
load_profiles(profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events), damus_state: state)
}
}
}
+3 -4
View File
@@ -53,8 +53,8 @@ class FollowersModel: ObservableObject {
has_contact.insert(ev.pubkey)
}
func load_profiles<Y>(relay_id: String, txn: NdbTxn<Y>) {
let authors = find_profiles_to_fetch_from_keys(profiles: damus_state.profiles, pks: contacts ?? [], txn: txn)
func load_profiles(relay_id: String) {
let authors = find_profiles_to_fetch_from_keys(profiles: damus_state.profiles, pks: contacts ?? [])
if authors.isEmpty {
return
}
@@ -83,8 +83,7 @@ class FollowersModel: ObservableObject {
case .eose(let sub_id):
if sub_id == self.sub_id {
let txn = NdbTxn(ndb: self.damus_state.ndb)
load_profiles(relay_id: relay_id, txn: txn)
load_profiles(relay_id: relay_id)
} else if sub_id == self.profiles_id {
damus_state.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
}
+4 -4
View File
@@ -22,11 +22,11 @@ class FollowingModel {
self.hashtags = hashtags
}
func get_filter<Y>(txn: NdbTxn<Y>) -> NostrFilter {
func get_filter() -> NostrFilter {
var f = NostrFilter(kinds: [.metadata])
f.authors = self.contacts.reduce(into: Array<Pubkey>()) { acc, pk in
// don't fetch profiles we already have
if damus_state.profiles.has_fresh_profile(id: pk, txn: txn) {
if damus_state.profiles.has_fresh_profile(id: pk) {
return
}
acc.append(pk)
@@ -34,8 +34,8 @@ class FollowingModel {
return f
}
func subscribe<Y>(txn: NdbTxn<Y>) {
let filter = get_filter(txn: txn)
func subscribe() {
let filter = get_filter()
if (filter.authors?.count ?? 0) == 0 {
needs_sub = false
return
+4 -13
View File
@@ -430,15 +430,14 @@ class HomeModel {
case .eose(let sub_id):
let txn = NdbTxn(ndb: damus_state.ndb)
if sub_id == dms_subid {
var dms = dms.dms.flatMap { $0.events }
dms.append(contentsOf: incoming_dms)
load_profiles(context: "dms", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(dms), damus_state: damus_state, txn: txn)
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(dms), damus_state: damus_state)
} else if sub_id == notifications_subid {
load_profiles(context: "notifications", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_keys(notifications.uniq_pubkeys()), damus_state: damus_state, txn: txn)
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_keys(notifications.uniq_pubkeys()), damus_state: damus_state)
} else if sub_id == home_subid {
load_profiles(context: "home", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus_state, txn: txn)
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus_state)
}
self.loading = false
@@ -850,7 +849,7 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
d[r] = .rw
}
guard let decoded: [String: RelayInfo] = decode_json_relays(ev.content) else {
guard let decoded = decode_json_relays(ev.content) else {
return
}
@@ -1070,14 +1069,6 @@ func event_has_our_pubkey(_ ev: NostrEvent, our_pubkey: Pubkey) -> Bool {
return ev.referenced_pubkeys.contains(our_pubkey)
}
func should_show_event(event: NostrEvent, damus_state: DamusState) -> Bool {
return should_show_event(
keypair: damus_state.keypair,
hellthreads: damus_state.muted_threads,
contacts: damus_state.contacts,
ev: event
)
}
func should_show_event(keypair: Keypair, hellthreads: MutedThreadsManager, contacts: Contacts, ev: NostrEvent) -> Bool {
if contacts.is_muted(ev.pubkey) {
+5
View File
@@ -123,6 +123,11 @@ struct LightningInvoice<T> {
}
}
struct Blocks: Equatable {
let words: Int
let blocks: [Block]
}
func maybe_pointee<T>(_ p: UnsafeMutablePointer<T>!) -> T? {
guard p != nil else {
return nil
-19
View File
@@ -1,19 +0,0 @@
//
// NostrFilter+Hashable.swift
// damus
//
// Created by Davide De Rosa on 10/21/23.
//
import Foundation
// FIXME: fine-tune here what's involved in comparing search filters
extension NostrFilter: Hashable {
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.hashtag == rhs.hashtag
}
func hash(into hasher: inout Hasher) {
hasher.combine(hashtag)
}
}
+1 -2
View File
@@ -123,9 +123,8 @@ class ProfileModel: ObservableObject, Equatable {
break
//notify(.notice, notice)
case .eose:
let txn = NdbTxn(ndb: damus.ndb)
if resp.subid == sub_id {
load_profiles(context: "profile", profiles_subid: prof_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus, txn: txn)
load_profiles(profiles_subid: prof_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus)
}
progress += 1
break
+27 -42
View File
@@ -17,7 +17,7 @@ class SearchHomeModel: ObservableObject {
let damus_state: DamusState
let base_subid = UUID().description
let profiles_subid = UUID().description
let limit: UInt32 = 500
let limit: UInt32 = 250
//let multiple_events_per_pubkey: Bool = false
init(damus_state: DamusState) {
@@ -83,38 +83,38 @@ class SearchHomeModel: ObservableObject {
// global events are not realtime
unsubscribe(to: relay_id)
let txn = NdbTxn(ndb: damus_state.ndb)
load_profiles(context: "universe", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.all_events), damus_state: damus_state, txn: txn)
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.all_events), damus_state: damus_state)
}
break
}
}
}
func find_profiles_to_fetch<Y>(profiles: Profiles, load: PubkeysToLoad, cache: EventCache, txn: NdbTxn<Y>) -> [Pubkey] {
func find_profiles_to_fetch(profiles: Profiles, load: PubkeysToLoad, cache: EventCache) -> [Pubkey] {
switch load {
case .from_events(let events):
return find_profiles_to_fetch_from_events(profiles: profiles, events: events, cache: cache, txn: txn)
return find_profiles_to_fetch_from_events(profiles: profiles, events: events, cache: cache)
case .from_keys(let pks):
return find_profiles_to_fetch_from_keys(profiles: profiles, pks: pks, txn: txn)
return find_profiles_to_fetch_from_keys(profiles: profiles, pks: pks)
}
}
func find_profiles_to_fetch_from_keys<Y>(profiles: Profiles, pks: [Pubkey], txn: NdbTxn<Y>) -> [Pubkey] {
Array(Set(pks.filter { pk in !profiles.has_fresh_profile(id: pk, txn: txn) }))
func find_profiles_to_fetch_from_keys(profiles: Profiles, pks: [Pubkey]) -> [Pubkey] {
Array(Set(pks.filter { pk in !profiles.has_fresh_profile(id: pk) }))
}
func find_profiles_to_fetch_from_events<Y>(profiles: Profiles, events: [NostrEvent], cache: EventCache, txn: NdbTxn<Y>) -> [Pubkey] {
func find_profiles_to_fetch_from_events(profiles: Profiles, events: [NostrEvent], cache: EventCache) -> [Pubkey] {
var pubkeys = Set<Pubkey>()
for ev in events {
// lookup profiles from boosted events
if ev.known_kind == .boost, let bev = ev.get_inner_event(cache: cache), !profiles.has_fresh_profile(id: bev.pubkey, txn: txn) {
if ev.known_kind == .boost, let bev = ev.get_inner_event(cache: cache), !profiles.has_fresh_profile(id: bev.pubkey) {
pubkeys.insert(bev.pubkey)
}
if !profiles.has_fresh_profile(id: ev.pubkey, txn: txn) {
if !profiles.has_fresh_profile(id: ev.pubkey) {
pubkeys.insert(ev.pubkey)
}
}
@@ -127,42 +127,27 @@ enum PubkeysToLoad {
case from_keys([Pubkey])
}
func load_profiles<Y>(context: String, profiles_subid: String, relay_id: String, load: PubkeysToLoad, damus_state: DamusState, txn: NdbTxn<Y>) {
let authors = find_profiles_to_fetch(profiles: damus_state.profiles, load: load, cache: damus_state.events, txn: txn)
func load_profiles(profiles_subid: String, relay_id: String, load: PubkeysToLoad, damus_state: DamusState) {
let authors = find_profiles_to_fetch(profiles: damus_state.profiles, load: load, cache: damus_state.events)
guard !authors.isEmpty else {
return
}
print("load_profiles[\(context)]: requesting \(authors.count) profiles from \(relay_id)")
let filter = NostrFilter(kinds: [.metadata], authors: authors)
damus_state.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { rid, conn_ev in
let now = UInt64(Date.now.timeIntervalSince1970)
switch conn_ev {
case .ws_event:
break
case .nostr_event(let ev):
guard ev.subid == profiles_subid, rid == relay_id else { return }
switch ev {
case .event(_, let ev):
if ev.known_kind == .metadata {
damus_state.ndb.write_profile_last_fetched(pubkey: ev.pubkey, fetched_at: now)
}
case .eose:
print("load_profiles[\(context)]: done loading \(authors.count) profiles from \(relay_id)")
damus_state.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id])
case .ok:
break
case .notice:
break
}
print("loading \(authors.count) profiles from \(relay_id)")
let filter = NostrFilter(kinds: [.metadata],
authors: authors)
damus_state.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { sub_id, conn_ev in
guard case .nostr_event(let ev) = conn_ev,
case .eose = ev,
sub_id == profiles_subid
else {
return
}
print("done loading \(authors.count) profiles from \(relay_id)")
damus_state.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id])
}
}
+1 -2
View File
@@ -80,8 +80,7 @@ class SearchModel: ObservableObject {
self.loading = false
if sub_id == self.sub_id {
let txn = NdbTxn(ndb: state.ndb)
load_profiles(context: "search", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(self.events.all_events), damus_state: state, txn: txn)
load_profiles(profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(self.events.all_events), damus_state: state)
}
}
}
+1 -2
View File
@@ -120,8 +120,7 @@ class ThreadModel: ObservableObject {
}
if sub_id == self.base_subid {
let txn = NdbTxn(ndb: damus_state.ndb)
load_profiles(context: "thread", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(Array(event_map)), damus_state: damus_state, txn: txn)
load_profiles(profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(Array(event_map)), damus_state: damus_state)
}
}
-3
View File
@@ -32,7 +32,6 @@ enum TranslationService: String, CaseIterable, Identifiable, StringCodable {
case libretranslate
case deepl
case nokyctranslate
case winetranslate
var model: Model {
switch self {
@@ -44,8 +43,6 @@ enum TranslationService: String, CaseIterable, Identifiable, StringCodable {
return .init(tag: self.rawValue, displayName: NSLocalizedString("DeepL (Proprietary, Higher Accuracy)", comment: "Dropdown option for selecting DeepL as the translation service."))
case .nokyctranslate:
return .init(tag: self.rawValue, displayName: NSLocalizedString("NoKYCTranslate.com (Prepay with BTC)", comment: "Dropdown option for selecting NoKYCTranslate.com as the translation service."))
case .winetranslate:
return .init(tag: self.rawValue, displayName: NSLocalizedString("translate.nostr.wine (DeepL, Pay with BTC)", comment: "Dropdown option for selecting translate.nostr.wine as the translation service."))
}
}
}
-29
View File
@@ -110,14 +110,8 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "always_show_images", default_value: false)
var always_show_images: Bool
@Setting(key: "media_previews", default_value: true)
var media_previews: Bool
@Setting(key: "hide_nsfw_tagged_content", default_value: false)
var hide_nsfw_tagged_content: Bool
@Setting(key: "show_profile_action_sheet_on_pfp_click", default_value: true)
var show_profile_action_sheet_on_pfp_click: Bool
@Setting(key: "zap_vibration", default_value: true)
var zap_vibration: Bool
@@ -192,15 +186,6 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "developer_mode", default_value: false)
var developer_mode: Bool
@Setting(key: "always_show_onboarding_suggestions", default_value: false)
var always_show_onboarding_suggestions: Bool
@Setting(key: "enable_experimental_push_notifications", default_value: false)
var enable_experimental_push_notifications: Bool
@Setting(key: "send_device_token_to_localhost", default_value: false)
var send_device_token_to_localhost: Bool
@Setting(key: "emoji_reactions", default_value: default_emoji_reactions)
var emoji_reactions: [String]
@@ -259,15 +244,6 @@ class UserSettingsStore: ObservableObject {
internal_nokyctranslate_api_key = newValue == "" ? nil : newValue
}
}
var winetranslate_api_key: String {
get {
return internal_winetranslate_api_key ?? ""
}
set {
internal_winetranslate_api_key = newValue == "" ? nil : newValue
}
}
// These internal keys are necessary because entries in the keychain need to be Optional,
// but the translation view needs non-Optional String in order to use them as Bindings.
@@ -276,9 +252,6 @@ class UserSettingsStore: ObservableObject {
@KeychainStorage(account: "nokyctranslate_apikey")
var internal_nokyctranslate_api_key: String?
@KeychainStorage(account: "winetranslate_apikey")
var internal_winetranslate_api_key: String?
@KeychainStorage(account: "libretranslate_apikey")
var internal_libretranslate_api_key: String?
@@ -296,8 +269,6 @@ class UserSettingsStore: ObservableObject {
return internal_deepl_api_key != nil
case .nokyctranslate:
return internal_nokyctranslate_api_key != nil
case .winetranslate:
return internal_winetranslate_api_key != nil
}
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ enum Wallet: String, CaseIterable, Identifiable, StringCodable {
switch self {
case .system_default_wallet:
return .init(index: -1, tag: "systemdefaultwallet", displayName: NSLocalizedString("Local default", comment: "Dropdown option label for system default for Lightning wallet."),
link: "lightning:", appStoreLink: nil, image: "")
link: "lightning:", appStoreLink: "lightning:", image: "")
case .strike:
return .init(index: 0, tag: "strike", displayName: "Strike", link: "strike:",
appStoreLink: "https://apps.apple.com/us/app/strike-bitcoin-payments/id1488724463", image: "strike")
+1 -2
View File
@@ -55,8 +55,7 @@ class ZapsModel: ObservableObject {
break
case .eose:
let events = state.events.lookup_zaps(target: target).map { $0.request.ev }
let txn = NdbTxn(ndb: state.ndb)
load_profiles(context: "zaps_model", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events), damus_state: state, txn: txn)
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events), damus_state: state)
case .event(_, let ev):
guard ev.kind == 9735,
let zapper = state.profiles.lookup_zapper(pubkey: target.pubkey),
+1 -24
View File
@@ -34,37 +34,14 @@ protocol TagConvertible {
static func from_tag(tag: TagSequence) -> Self?
}
struct QuoteId: IdType, TagKey, TagConvertible {
struct QuoteId: IdType, TagKey {
let id: Data
init(_ data: Data) {
self.id = data
}
/// Refer to this QuoteId as a NoteId
var note_id: NoteId {
NoteId(self.id)
}
var keychar: AsciiCharacter { "q" }
var tag: [String] {
["q", self.hex()]
}
static func from_tag(tag: TagSequence) -> QuoteId? {
var i = tag.makeIterator()
guard tag.count >= 2,
let t0 = i.next(),
let key = t0.single_char,
key == "q",
let t1 = i.next(),
let quote_id = t1.id().map(QuoteId.init)
else { return nil }
return quote_id
}
}
-180
View File
@@ -1,180 +0,0 @@
//
// NostrEvent+.swift
// damus
//
// Created by Daniel DAquino on 2023-11-17.
//
import Foundation
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
var tags = zap_target_to_tags(target)
var relay_tag = ["relays"]
relay_tag.append(contentsOf: relays.map { $0.url.id })
tags.append(relay_tag)
var kp = keypair
let now = UInt32(Date().timeIntervalSince1970)
var privzap_req: PrivateZapRequest?
var message = content
switch zap_type {
case .pub:
break
case .non_zap:
break
case .anon:
tags.append(["anon"])
kp = generate_new_keypair()
case .priv:
guard let priv_kp = generate_private_keypair(our_privkey: keypair.privkey, id: NoteId(target.id), created_at: now) else {
return nil
}
kp = priv_kp
guard let privreq = make_private_zap_request_event(identity: keypair, enc_key: kp, target: target, message: message) else {
return nil
}
tags.append(["anon", privreq.enc])
message = ""
privzap_req = privreq
}
guard let ev = NostrEvent(content: message, keypair: kp.to_keypair(), kind: 9734, tags: tags, createdAt: now) else {
return nil
}
let zapreq = ZapRequest(ev: ev)
if let privzap_req {
return .priv(zapreq, privzap_req)
} else {
return .normal(zapreq)
}
}
func zap_target_to_tags(_ target: ZapTarget) -> [[String]] {
switch target {
case .profile(let pk):
return [["p", pk.hex()]]
case .note(let note_target):
return [["e", note_target.note_id.hex()],
["p", note_target.author.hex()]]
}
}
struct PrivateZapRequest {
let req: ZapRequest
let enc: String
}
func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair, target: ZapTarget, message: String) -> PrivateZapRequest? {
// target tags must be the same as zap request target tags
let tags = zap_target_to_tags(target)
guard let note = NostrEvent(content: message, keypair: identity.to_keypair(), kind: 9733, tags: tags),
let note_json = encode_json(note),
let enc = encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32)
else {
return nil
}
return PrivateZapRequest(req: ZapRequest(ev: note), enc: enc)
}
func decrypt_private_zap(our_privkey: Privkey, zapreq: NostrEvent, target: ZapTarget) -> NostrEvent? {
guard let anon_tag = zapreq.tags.first(where: { t in
t.count >= 2 && t[0].matches_str("anon")
}) else {
return nil
}
let enc_note = anon_tag[1].string()
var note = decrypt_note(our_privkey: our_privkey, their_pubkey: zapreq.pubkey, enc_note: enc_note, encoding: .bech32)
// check to see if the private note was from us
if note == nil {
guard let our_private_keypair = generate_private_keypair(our_privkey: our_privkey, id: NoteId(target.id), created_at: zapreq.created_at) else {
return nil
}
// use our private keypair and their pubkey to get the shared secret
note = decrypt_note(our_privkey: our_private_keypair.privkey, their_pubkey: target.pubkey, enc_note: enc_note, encoding: .bech32)
}
guard let note else {
return nil
}
guard note.kind == 9733 else {
return nil
}
let zr_etag = zapreq.referenced_ids.first
let note_etag = note.referenced_ids.first
guard zr_etag == note_etag else {
return nil
}
let zr_ptag = zapreq.referenced_pubkeys.first
let note_ptag = note.referenced_pubkeys.first
guard let zr_ptag, let note_ptag, zr_ptag == note_ptag else {
return nil
}
guard validate_event(ev: note) == .ok else {
return nil
}
return note
}
enum MakeZapRequest {
case priv(ZapRequest, PrivateZapRequest)
case normal(ZapRequest)
var private_inner_request: ZapRequest {
switch self {
case .priv(_, let pzr):
return pzr.req
case .normal(let zr):
return zr
}
}
var potentially_anon_outer_request: ZapRequest {
switch self {
case .priv(let zr, _):
return zr
case .normal(let zr):
return zr
}
}
}
func make_first_contact_event(keypair: Keypair) -> NostrEvent? {
let bootstrap_relays = load_bootstrap_relays(pubkey: keypair.pubkey)
let rw_relay_info = RelayInfo(read: true, write: true)
var relays: [String: RelayInfo] = [:]
for relay in bootstrap_relays {
relays[relay] = rw_relay_info
}
let relay_json = encode_json(relays)!
let damus_pubkey = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681"
let tags = [
["p", damus_pubkey],
["p", keypair.pubkey.hex()] // you're a friend of yourself!
]
return NostrEvent(content: relay_json, keypair: keypair, kind: NostrKind.contacts.rawValue, tags: tags)
}
func make_metadata_event(keypair: FullKeypair, metadata: Profile) -> NostrEvent? {
guard let metadata_json = encode_json(metadata) else {
return nil
}
return NostrEvent(content: metadata_json, keypair: keypair.to_keypair(), kind: NostrKind.metadata.rawValue, tags: [])
}
+209 -5
View File
@@ -20,6 +20,17 @@ enum ValidationResult: Decodable {
case bad_sig
}
typealias NostrEvent = NdbNote
typealias TagElem = NdbTagElem
typealias Tag = TagSequence
typealias Tags = TagsSequence
//typealias TagElem = String
//typealias Tag = [TagElem]
//typealias Tags = [Tag]
//typealias NostrEvent = NostrEventOld
let MAX_NOTE_SIZE: Int = 2 << 18
/*
class NostrEventOld: Codable, Identifiable, CustomStringConvertible, Equatable, Hashable, Comparable {
// TODO: memory mapped db events
@@ -416,6 +427,19 @@ func hexchar(_ val: UInt8) -> UInt8 {
return 0
}
func hex_encode(_ data: Data) -> String {
var str = ""
for c in data {
let c1 = hexchar(c >> 4)
let c2 = hexchar(c & 0xF)
str.append(Character(Unicode.Scalar(c1)))
str.append(Character(Unicode.Scalar(c2)))
}
return str
}
func random_bytes(count: Int) -> Data {
var bytes = [Int8](repeating: 0, count: count)
guard
@@ -426,6 +450,32 @@ func random_bytes(count: Int) -> Data {
return Data(bytes: bytes, count: count)
}
func make_first_contact_event(keypair: Keypair) -> NostrEvent? {
let bootstrap_relays = load_bootstrap_relays(pubkey: keypair.pubkey)
let rw_relay_info = RelayInfo(read: true, write: true)
var relays: [String: RelayInfo] = [:]
for relay in bootstrap_relays {
relays[relay] = rw_relay_info
}
let relay_json = encode_json(relays)!
let damus_pubkey = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681"
let tags = [
["p", damus_pubkey],
["p", keypair.pubkey.hex()] // you're a friend of yourself!
]
return NostrEvent(content: relay_json, keypair: keypair, kind: NostrKind.contacts.rawValue, tags: tags)
}
func make_metadata_event(keypair: FullKeypair, metadata: Profile) -> NostrEvent? {
guard let metadata_json = encode_json(metadata) else {
return nil
}
return NostrEvent(content: metadata_json, keypair: keypair.to_keypair(), kind: NostrKind.metadata.rawValue, tags: [])
}
func make_boost_event(keypair: FullKeypair, boosted: NostrEvent) -> NostrEvent? {
var tags = Array(boosted.referenced_pubkeys).map({ pk in pk.tag })
@@ -451,6 +501,84 @@ func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String =
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 7, tags: tags)
}
func zap_target_to_tags(_ target: ZapTarget) -> [[String]] {
switch target {
case .profile(let pk):
return [["p", pk.hex()]]
case .note(let note_target):
return [["e", note_target.note_id.hex()],
["p", note_target.author.hex()]]
}
}
struct PrivateZapRequest {
let req: ZapRequest
let enc: String
}
func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair, target: ZapTarget, message: String) -> PrivateZapRequest? {
// target tags must be the same as zap request target tags
let tags = zap_target_to_tags(target)
guard let note = NostrEvent(content: message, keypair: identity.to_keypair(), kind: 9733, tags: tags),
let note_json = encode_json(note),
let enc = encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32)
else {
return nil
}
return PrivateZapRequest(req: ZapRequest(ev: note), enc: enc)
}
func decrypt_private_zap(our_privkey: Privkey, zapreq: NostrEvent, target: ZapTarget) -> NostrEvent? {
guard let anon_tag = zapreq.tags.first(where: { t in
t.count >= 2 && t[0].matches_str("anon")
}) else {
return nil
}
let enc_note = anon_tag[1].string()
var note = decrypt_note(our_privkey: our_privkey, their_pubkey: zapreq.pubkey, enc_note: enc_note, encoding: .bech32)
// check to see if the private note was from us
if note == nil {
guard let our_private_keypair = generate_private_keypair(our_privkey: our_privkey, id: NoteId(target.id), created_at: zapreq.created_at) else {
return nil
}
// use our private keypair and their pubkey to get the shared secret
note = decrypt_note(our_privkey: our_private_keypair.privkey, their_pubkey: target.pubkey, enc_note: enc_note, encoding: .bech32)
}
guard let note else {
return nil
}
guard note.kind == 9733 else {
return nil
}
let zr_etag = zapreq.referenced_ids.first
let note_etag = note.referenced_ids.first
guard zr_etag == note_etag else {
return nil
}
let zr_ptag = zapreq.referenced_pubkeys.first
let note_ptag = note.referenced_pubkeys.first
guard let zr_ptag, let note_ptag, zr_ptag == note_ptag else {
return nil
}
guard validate_event(ev: note) == .ok else {
return nil
}
return note
}
func generate_private_keypair(our_privkey: Privkey, id: NoteId, created_at: UInt32) -> FullKeypair? {
let to_hash = our_privkey.hex() + id.hex() + String(created_at)
guard let dat = to_hash.data(using: .utf8) else {
@@ -463,6 +591,74 @@ func generate_private_keypair(our_privkey: Privkey, id: NoteId, created_at: UInt
return FullKeypair(pubkey: pubkey, privkey: privkey)
}
enum MakeZapRequest {
case priv(ZapRequest, PrivateZapRequest)
case normal(ZapRequest)
var private_inner_request: ZapRequest {
switch self {
case .priv(_, let pzr):
return pzr.req
case .normal(let zr):
return zr
}
}
var potentially_anon_outer_request: ZapRequest {
switch self {
case .priv(let zr, _):
return zr
case .normal(let zr):
return zr
}
}
}
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
var tags = zap_target_to_tags(target)
var relay_tag = ["relays"]
relay_tag.append(contentsOf: relays.map { $0.url.id })
tags.append(relay_tag)
var kp = keypair
let now = UInt32(Date().timeIntervalSince1970)
var privzap_req: PrivateZapRequest?
var message = content
switch zap_type {
case .pub:
break
case .non_zap:
break
case .anon:
tags.append(["anon"])
kp = generate_new_keypair()
case .priv:
guard let priv_kp = generate_private_keypair(our_privkey: keypair.privkey, id: NoteId(target.id), created_at: now) else {
return nil
}
kp = priv_kp
guard let privreq = make_private_zap_request_event(identity: keypair, enc_key: kp, target: target, message: message) else {
return nil
}
tags.append(["anon", privreq.enc])
message = ""
privzap_req = privreq
}
guard let ev = NostrEvent(content: message, keypair: kp.to_keypair(), kind: 9734, tags: tags, createdAt: now) else {
return nil
}
let zapreq = ZapRequest(ev: ev)
if let privzap_req {
return .priv(zapreq, privzap_req)
} else {
return .normal(zapreq)
}
}
func uniq<T: Hashable>(_ xs: [T]) -> [T] {
var s = Set<T>()
var ys: [T] = []
@@ -581,11 +777,6 @@ func get_shared_secret(privkey: Privkey, pubkey: Pubkey) -> [UInt8]? {
return shared_secret
}
enum EncEncoding {
case base64
case bech32
}
struct DirectMessageBase64 {
let content: [UInt8]
let iv: [UInt8]
@@ -758,6 +949,19 @@ func first_eref_mention(ev: NostrEvent, keypair: Keypair) -> Mention<NoteId>? {
return nil
}
func separate_images(ev: NostrEvent, keypair: Keypair) -> [MediaUrl]? {
let urlBlocks: [URL] = ev.blocks(keypair).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, keypair: Keypair) -> [Invoice]? {
let invoiceBlocks: [Invoice] = ev.blocks(keypair).blocks.reduce(into: []) { invoices, block in
guard case .invoice(let invoice) = block else {
+1 -1
View File
@@ -84,7 +84,7 @@ enum NostrResponse {
return nil
}
let new_note = note_data.assumingMemoryBound(to: ndb_note.self)
let note = NdbNote(note: new_note, size: Int(len), owned: true, key: nil)
let note = NdbNote(note: new_note, owned_size: Int(len), key: nil)
guard let subid = sized_cstr(cstr: tce.subid, len: tce.subid_len) else {
free(data)
+4 -19
View File
@@ -30,7 +30,7 @@ class ProfileData {
class Profiles {
private var ndb: Ndb
static let db_freshness_threshold: TimeInterval = 24 * 60 * 8
static let db_freshness_threshold: TimeInterval = 24 * 60 * 60
@MainActor
private var profiles: [Pubkey: ProfileData] = [:]
@@ -93,24 +93,9 @@ class Profiles {
return ndb.lookup_profile_key(pubkey)
}
func has_fresh_profile<Y>(id: Pubkey, txn: NdbTxn<Y>) -> Bool {
guard let fetched_at = ndb.read_profile_last_fetched(txn: txn, pubkey: id)
else {
return false
}
// In situations where a batch of profiles was fetched all at once,
// this will reduce the herding of the profile requests
let fuzz = Double.random(in: -60...60)
let threshold = Profiles.db_freshness_threshold + fuzz
let fetch_date = Date(timeIntervalSince1970: Double(fetched_at))
let since = Date.now.timeIntervalSince(fetch_date)
let fresh = since < threshold
//print("fresh = \(fresh): fetch_date \(since) < threshold \(threshold) \(id)")
return fresh
func has_fresh_profile(id: Pubkey) -> Bool {
guard let recv = lookup_with_timestamp(id).unsafeUnownedValue?.receivedAt else { return false }
return Date.now.timeIntervalSince(Date(timeIntervalSince1970: Double(recv))) < Profiles.db_freshness_threshold
}
}
+31
View File
@@ -13,6 +13,37 @@ enum NostrConnectionEvent {
case nostr_event(NostrResponse)
}
public struct RelayURL: Hashable {
private(set) var url: URL
var id: String {
return url.absoluteString
}
init?(_ str: String) {
guard let last = str.last else { return nil }
var urlstr = str
if last == "/" {
urlstr = String(str.dropLast(1))
}
guard let url = URL(string: urlstr) else {
return nil
}
guard let scheme = url.scheme else {
return nil
}
guard scheme == "ws" || scheme == "wss" else {
return nil
}
self.url = url
}
}
final class RelayConnection: ObservableObject {
@Published private(set) var isConnected = false
@Published private(set) var isConnecting = false
+4 -4
View File
@@ -120,7 +120,7 @@ class RelayPool {
case .string(let str) = msg
else { return }
let _ = self.ndb.process_event(str)
self.ndb.process_event(str)
})
let relay = Relay(descriptor: desc, connection: conn)
self.relays.append(relay)
@@ -219,11 +219,11 @@ class RelayPool {
// send to local relay (nostrdb)
switch req {
case .typical(let r):
if case .event = r, let rstr = make_nostr_req(r) {
let _ = ndb.process_client_event(rstr)
if let rstr = make_nostr_req(r) {
ndb.process_client_event(rstr)
}
case .custom(let string):
let _ = ndb.process_client_event(string)
ndb.process_client_event(string)
}
for relay in relays {
-80
View File
@@ -1,80 +0,0 @@
//
// RelayURL.swift
// damus
//
// Created by Daniel DAquino on 2023-09-29.
//
import Foundation
public struct RelayURL: Hashable, Equatable, Codable, CodingKeyRepresentable {
private(set) var url: URL
var id: String {
return url.absoluteString
}
init?(_ str: String) {
guard let url = URL(string: str) else {
return nil
}
guard let scheme = url.scheme else {
return nil
}
guard scheme == "ws" || scheme == "wss" else {
return nil
}
self.url = url
}
// MARK: - Codable
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let urlString = try container.decode(String.self)
guard let instance = RelayURL(urlString) else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid URL string.")
}
self = instance
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(url.absoluteString)
}
// MARK: - CodingKeyRepresentable
// CodingKeyRepresentable conformance is necessary to ensure that
// a dictionary with type "[RelayURL: T] where T: Codable" can be encoded into a keyed container
// e.g. `{<URL>: <VALUE>, <URL>: <VALUE>}` instead of `[<URL>, <VALUE>, <URL>, <VALUE>]`, which is Swift's default for non-string-keyed dictionaries
public var codingKey: CodingKey {
return StringKey(stringValue: self.url.absoluteString)
}
public init?<T>(codingKey: T) where T : CodingKey {
self.init(codingKey.stringValue)
}
// MARK: - Equatable
public static func == (lhs: RelayURL, rhs: RelayURL) -> Bool {
return lhs.url == rhs.url
}
// MARK: - Hashable
public func hash(into hasher: inout Hasher) {
hasher.combine(self.url)
}
}
private struct StringKey: CodingKey {
var stringValue: String
init(stringValue: String) {
self.stringValue = stringValue
}
var intValue: Int? { return nil }
init?(intValue: Int) { return nil }
}
+1 -5
View File
@@ -58,7 +58,7 @@ var test_damus_state: DamusState = ({
let fileManager = FileManager.default
let temp = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fileManager.createDirectory(at: temp, withIntermediateDirectories: true, attributes: nil)
tempDir = temp.path(percentEncoded: false)
tempDir = temp.absoluteString
} catch {
tempDir = "."
}
@@ -105,12 +105,8 @@ var test_damus_state: DamusState = ({
let test_wire_events = """
["EVENT","s",{"id":"d12c17bde3094ad32f4ab862a6cc6f5c289cfe7d5802270bdf34904df585f349","pubkey":"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245","created_at":1650049978,"kind":0,"tags":[],"content":"{\\"name\\\":\\"jb55\\",\\"picture\\":\\\"http://cdn.jb55.com/img/red-me.jpg\\",\\"about\\":\\"bitcoin, lightning and nostr dev\\",\\"nip05\\":\\"jb55.com\\"}","sig":"1315045da793c4825de1517149172bf35a6da39d91b7787afb3263721e07bc816cb898996ed8d69af05d6efcd1c926a089bd66cad870bcc361405c11ba302c51"}]
["EVENT","s",{"id":"b2e03951843b191b5d9d1969f48db0156b83cc7dbd841f543f109362e24c4a9c","pubkey":"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245","created_at":1650050002,"kind":1,"tags":[],"content":"hello, this is my new key","sig":"4342eff1d78a82b42522cd26ec66a5293eca997f81d4b80efd02230d3d27317fb63d42656e8f32383562f075a2b6d999b60dcf70e2df18cf5e8b3801faeb0bd6"}]
["EVENT","s",{"id":"8f68cdc0c72dcf5c37868428cb477f28b13b1561e717f92053921b3b3c4ab712","pubkey":"ba4b26df771a0839d5a26550ada6ac19547e164136994951d2d5c5815993a28e","created_at":1701187327,"kind":1,"tags":[],"content":"a quick brown fox jumped over the lazy dog","sig":"3cca4157e92df32f42e3df5a63e2438676557e53d9136fedc6f3a23f6fad083748af19dfb3fa5949654c4b5623342e70cb74d56cf6e6e8a347a3ab2c3adf1654"}]
["EVENT","s",{"id":"b17a540710fe8495b16bfbaf31c6962c4ba8387f3284a7973ad523988095417e","pubkey":"df51637b1a19115d6c532081461a3e24f19b02f15815771dd26de2617fe2ea90","created_at":1701187337,"kind":1,"tags":[],"content":"a quick brown fox barked at the lazy dog","sig":"e171df376d6efd3f8dc072715d14387c3446785d1b8c5809b4871a9dc925c39a7b475fcebc8c98bbd5363ba1ed0a7d6420ad796fcda24f3d6f820b433af92ee2"}]
["EVENT","s",{"id":"35c717f1d905b05e16868107f78ec013399b01e9dcdd40fcaf8112b3d1f63ad4","pubkey":"381eac026b7d3053236eef30c1a3cd0674809d050b1ba9f05c694efb5ea002d4","created_at":1701188103,"kind":1,"tags":[],"content":"a quick brown fox jumped over the lazy cat","sig":"fed2e4c1d34ad79004468f676310d366c0104d2d01f1778fc7f308fca52892ad3b8f83d6e0219fcfbcc2d3123d4ad0a733b69123414ce4b117e44041600045a0"}]
"""
let test_failing_nostr_report = """
{
"id": "99198ecb6a34372b7e39b88280bab3394654a00f5f8504466fac2d6acb569663",
-6
View File
@@ -45,12 +45,6 @@ enum Block: Equatable {
case invoice(Invoice)
case relay(String)
}
struct Blocks: Equatable {
let words: Int
let blocks: [Block]
}
extension Block {
/// Failable initializer for the C-backed type `block_t`. This initializer will inspect
/// the underlying block type and build the appropriate enum value as needed.
-11
View File
@@ -1,11 +0,0 @@
//
// MigratedTypes.swift
// damus
//
// Created by Daniel DAquino on 2023-11-17.
//
typealias NostrEvent = NdbNote
typealias TagElem = NdbTagElem
typealias Tag = TagSequence
typealias Tags = TagsSequence
-4
View File
@@ -57,7 +57,3 @@ func parse_display_name(profile: Profile?, pubkey: Pubkey) -> DisplayName {
func abbrev_bech32_pubkey(pubkey: Pubkey) -> String {
return abbrev_pubkey(String(pubkey.npub.dropFirst(4)))
}
func abbrev_pubkey(_ pubkey: String, amount: Int = 8) -> String {
return pubkey.prefix(amount) + ":" + pubkey.suffix(amount)
}
@@ -28,18 +28,6 @@ extension KFOptionSetter {
options.scaleFactor = UIScreen.main.scale
options.onlyLoadFirstFrame = disable_animation
switch imageContext {
case .pfp:
options.diskCacheExpiration = .days(60)
break
case .banner:
options.diskCacheExpiration = .days(5)
break
case .note:
options.diskCacheExpiration = .days(1)
break
}
return self
}
+2 -2
View File
@@ -29,10 +29,10 @@ class LNUrls {
guard tries < 5 else { return nil }
self.endpoints[pubkey] = .failed(tries: tries + 1)
case .fetched(let pr):
//print("lnurls.lookup_or_fetch fetched \(lnurl)")
print("lnurls.lookup_or_fetch fetched \(lnurl)")
return pr
case .fetching(let task):
//print("lnurls.lookup_or_fetch already fetching \(lnurl)")
print("lnurls.lookup_or_fetch already fetching \(lnurl)")
return await task.value
case .not_fetched:
print("lnurls.lookup_or_fetch not fetched \(lnurl)")
-35
View File
@@ -19,9 +19,6 @@ struct LossyLocalNotification {
}
static func from_user_info(user_info: [AnyHashable: Any]) -> LossyLocalNotification? {
if let encoded_nostr_event_push_data = user_info["nostr_event_info"] as? String {
return self.from(encoded_nostr_event_push_data: encoded_nostr_event_push_data)
}
guard let id = user_info["id"] as? String,
let target_id = MentionRef.from_bech32(str: id) else {
return nil
@@ -31,21 +28,6 @@ struct LossyLocalNotification {
return LossyLocalNotification(type: type, mention: target_id)
}
static func from(encoded_nostr_event_push_data: String) -> LossyLocalNotification? {
guard let json_data = encoded_nostr_event_push_data.data(using: .utf8),
let nostr_event_push_data = try? JSONDecoder().decode(NostrEventInfoFromPushNotification.self, from: json_data) else {
return nil
}
return self.from(nostr_event_push_data: nostr_event_push_data)
}
static func from(nostr_event_push_data: NostrEventInfoFromPushNotification) -> LossyLocalNotification? {
guard let type = LocalNotificationType.from(nostr_kind: nostr_event_push_data.kind) else { return nil }
guard let note_id: NoteId = NoteId.init(hex: nostr_event_push_data.id) else { return nil }
let target: MentionRef = .note(note_id)
return LossyLocalNotification(type: type, mention: target)
}
}
struct LocalNotification {
@@ -66,21 +48,4 @@ enum LocalNotificationType: String {
case repost
case zap
case profile_zap
static func from(nostr_kind: NostrKind) -> Self? {
switch nostr_kind {
case .text:
return .mention
case .dm:
return .dm
case .like:
return .like
case .longform:
return .mention
case .zap:
return .zap
default:
return nil
}
}
}
-10
View File
@@ -12,8 +12,6 @@ import os.log
enum LogCategory: String {
case nav
case render
case storage
case push_notifications
}
/// Damus structured logger
@@ -46,12 +44,4 @@ class Log {
static func info(_ msg: StaticString, for logcat: LogCategory, _ args: CVarArg...) {
Log.log(msg, for: logger(for: logcat), type: OSLogType.info, args)
}
static func debug(_ msg: StaticString, for logcat: LogCategory, _ args: CVarArg...) {
Log.log(msg, for: logger(for: logcat), type: OSLogType.debug, args)
}
static func error(_ msg: StaticString, for logcat: LogCategory, _ args: CVarArg...) {
Log.log(msg, for: logger(for: logcat), type: OSLogType.error, args)
}
}
+3 -30
View File
@@ -14,24 +14,6 @@ let BOOTSTRAP_RELAYS = [
"wss://nos.lol",
]
let REGION_SPECIFIC_BOOTSTRAP_RELAYS: [Locale.Region: [String]] = [
Locale.Region.japan: [
"wss://relay-jp.nostr.wirednet.jp",
"wss://yabu.me",
"wss://r.kojira.io",
],
Locale.Region.thailand: [
"wss://relay.siamstr.com",
"wss://relay.zerosatoshi.xyz",
"wss://th2.nostr.earnkrub.xyz",
],
Locale.Region.germany: [
"wss://nostr.einundzwanzig.space",
"wss://nostr.cercatrova.me",
"wss://nostr.bitcoinplebs.de",
]
]
func bootstrap_relays_setting_key(pubkey: Pubkey) -> String {
return pk_setting_key(pubkey, key: "bootstrap_relays")
}
@@ -47,25 +29,16 @@ func load_bootstrap_relays(pubkey: Pubkey) -> [String] {
guard let relays = UserDefaults.standard.stringArray(forKey: key) else {
print("loading default bootstrap relays")
return get_default_bootstrap_relays().map { $0 }
return BOOTSTRAP_RELAYS.map { $0 }
}
if relays.count == 0 {
print("loading default bootstrap relays")
return get_default_bootstrap_relays().map { $0 }
return BOOTSTRAP_RELAYS.map { $0 }
}
let loaded_relays = Array(Set(relays + get_default_bootstrap_relays()))
let loaded_relays = Array(Set(relays + BOOTSTRAP_RELAYS))
print("Loading custom bootstrap relays: \(loaded_relays)")
return loaded_relays
}
func get_default_bootstrap_relays() -> [String] {
var default_bootstrap_relays = BOOTSTRAP_RELAYS
if let user_region = Locale.current.region, let regional_bootstrap_relays = REGION_SPECIFIC_BOOTSTRAP_RELAYS[user_region] {
default_bootstrap_relays.append(contentsOf: regional_bootstrap_relays)
}
return default_bootstrap_relays
}
+2 -8
View File
@@ -188,7 +188,8 @@ enum Route: Hashable {
hasher.combine(reactions.target)
case .Search(let search):
hasher.combine("search")
hasher.combine(search.search)
hasher.combine(search.sub_id)
hasher.combine(search.profiles_subid)
case .EULA:
hasher.combine("eula")
case .Login:
@@ -217,15 +218,8 @@ class NavigationCoordinator: ObservableObject {
@Published var path = [Route]()
func push(route: Route) {
guard route != path.last else {
return
}
path.append(route)
}
func isAtRoot() -> Bool {
return path.count == 0
}
func popToRoot() {
path = []
-25
View File
@@ -26,8 +26,6 @@ public struct Translator {
return try await translateWithLibreTranslate(text, from: sourceLanguage, to: targetLanguage)
case .nokyctranslate:
return try await translateWithNoKYCTranslate(text, from: sourceLanguage, to: targetLanguage)
case .winetranslate:
return try await translateWithWineTranslate(text, from: sourceLanguage, to: targetLanguage)
case .deepl:
return try await translateWithDeepL(text, from: sourceLanguage, to: targetLanguage)
case .none:
@@ -113,29 +111,6 @@ public struct Translator {
return response.translatedText
}
private func translateWithWineTranslate(_ text: String, from sourceLanguage: String, to targetLanguage: String) async throws -> String? {
let url = try makeURL("https://translate.nostr.wine", path: "/translate")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
struct RequestBody: Encodable {
let q: String
let source: String
let target: String
let api_key: String?
}
let body = RequestBody(q: text, source: sourceLanguage, target: targetLanguage, api_key: userSettingsStore.winetranslate_api_key)
request.httpBody = try encoder.encode(body)
struct Response: Decodable {
let translatedText: String
}
let response: Response = try await decodedData(for: request)
return response.translatedText
}
private func makeURL(_ baseUrl: String, path: String) throws -> URL {
guard var components = URLComponents(string: baseUrl) else {
throw URLError(.badURL)
+1 -1
View File
@@ -84,7 +84,7 @@ struct EventActionBar: View {
if let lnurl = self.lnurl {
Spacer()
NoteZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: self.damus_state.events.get_cache_data(self.event.id).zaps_model)
ZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: self.damus_state.events.get_cache_data(self.event.id).zaps_model)
}
Spacer()
+5 -4
View File
@@ -82,6 +82,10 @@ struct AddRelayView: View {
new_relay = "wss://" + new_relay
}
if new_relay.hasSuffix("/") {
new_relay.removeLast();
}
guard let url = RelayURL(new_relay),
let ev = state.contacts.event,
let keypair = state.keypair.to_full() else {
@@ -105,7 +109,7 @@ struct AddRelayView: View {
state.pool.connect(to: [new_relay])
guard let new_ev = add_relay(ev: ev, keypair: keypair, current_relays: state.pool.our_descriptors, relay: url, info: info) else {
guard let new_ev = add_relay(ev: ev, keypair: keypair, current_relays: state.pool.our_descriptors, relay: new_relay, info: info) else {
return
}
@@ -113,9 +117,6 @@ struct AddRelayView: View {
state.pool.send(.event(new_ev))
if let relay_metadata = make_relay_metadata(relays: state.pool.our_descriptors, keypair: keypair) {
state.postbox.send(relay_metadata)
}
new_relay = ""
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
-95
View File
@@ -1,95 +0,0 @@
//
// CameraPreview.swift
// damus
//
// Created by Suhail Saqan on 8/5/23.
//
import UIKit
import AVFoundation
import SwiftUI
public struct CameraPreview: UIViewRepresentable {
public class VideoPreviewView: UIView {
public override class var layerClass: AnyClass {
AVCaptureVideoPreviewLayer.self
}
var videoPreviewLayer: AVCaptureVideoPreviewLayer {
return layer as! AVCaptureVideoPreviewLayer
}
let focusView: UIView = {
let focusView = UIView(frame: CGRect(x: 0, y: 0, width: 30, height: 30))
focusView.layer.borderColor = UIColor.white.cgColor
focusView.layer.borderWidth = 1.5
focusView.layer.cornerRadius = 15
focusView.layer.opacity = 0
focusView.backgroundColor = .clear
return focusView
}()
@objc func focusAndExposeTap(gestureRecognizer: UITapGestureRecognizer) {
let layerPoint = gestureRecognizer.location(in: gestureRecognizer.view)
guard layerPoint.x >= 0 && layerPoint.x <= bounds.width &&
layerPoint.y >= 0 && layerPoint.y <= bounds.height else {
return
}
let devicePoint = videoPreviewLayer.captureDevicePointConverted(fromLayerPoint: layerPoint)
self.focusView.layer.frame = CGRect(origin: layerPoint, size: CGSize(width: 30, height: 30))
NotificationCenter.default.post(.init(name: .init("UserDidRequestNewFocusPoint"), object: nil, userInfo: ["devicePoint": devicePoint] as [AnyHashable: Any]))
UIView.animate(withDuration: 0.3, animations: {
self.focusView.layer.opacity = 1
}) { (completed) in
if completed {
UIView.animate(withDuration: 0.3) {
self.focusView.layer.opacity = 0
}
}
}
}
public override func layoutSubviews() {
super.layoutSubviews()
videoPreviewLayer.videoGravity = .resizeAspectFill
self.layer.addSublayer(focusView.layer)
let gRecognizer = UITapGestureRecognizer(target: self, action: #selector(VideoPreviewView.focusAndExposeTap(gestureRecognizer:)))
self.addGestureRecognizer(gRecognizer)
}
}
public let session: AVCaptureSession
public init(session: AVCaptureSession) {
self.session = session
}
public func makeUIView(context: Context) -> VideoPreviewView {
let viewFinder = VideoPreviewView()
viewFinder.backgroundColor = .black
viewFinder.videoPreviewLayer.cornerRadius = 20
viewFinder.videoPreviewLayer.session = session
viewFinder.videoPreviewLayer.connection?.videoOrientation = .portrait
return viewFinder
}
public func updateUIView(_ uiView: VideoPreviewView, context: Context) {
}
}
struct CameraPreview_Previews: PreviewProvider {
static var previews: some View {
CameraPreview(session: AVCaptureSession())
.frame(height: 300)
}
}
+6
View File
@@ -174,3 +174,9 @@ func handle_string_amount(new_value: String) -> Int? {
return amt
}
func clear_kingfisher_cache() -> Void {
KingfisherManager.shared.cache.clearMemoryCache()
KingfisherManager.shared.cache.clearDiskCache()
KingfisherManager.shared.cache.cleanExpiredDiskCache()
}
+5
View File
@@ -186,6 +186,11 @@ struct DMChatView_Previews: PreviewProvider {
}
}
enum EncEncoding {
case base64
case bech32
}
func encrypt_message(message: String, privkey: Privkey, to_pk: Pubkey, encoding: EncEncoding = .base64) -> String? {
let iv = random_bytes(count: 16).bytes
guard let shared_sec = get_shared_secret(privkey: privkey, pubkey: to_pk) else {
+9 -18
View File
@@ -18,17 +18,15 @@ struct DirectMessagesView: View {
@State var dm_type: DMType = .friend
@ObservedObject var model: DirectMessagesModel
@ObservedObject var settings: UserSettingsStore
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home
func MainContent(requests: Bool) -> some View {
ScrollView {
LazyVStack(spacing: 0) {
let dms = requests ? model.message_requests : model.friend_dms
let filtered_dms = filter_dms(dms: dms)
if filtered_dms.isEmpty, !model.loading {
if model.dms.isEmpty, !model.loading {
EmptyTimelineView()
} else {
ForEach(filtered_dms, id: \.pubkey) { dm in
let dms = requests ? model.message_requests : model.friend_dms
ForEach(dms, id: \.pubkey) { dm in
MaybeEvent(dm)
.padding(.top, 10)
}
@@ -38,12 +36,6 @@ struct DirectMessagesView: View {
}
}
func filter_dms(dms: [DirectMessageModel]) -> [DirectMessageModel] {
return dms.filter({ dm in
return damus_state.settings.friend_filter.filter(contacts: damus_state.contacts, pubkey: dm.pubkey) && !damus_state.contacts.is_muted(dm.pubkey)
})
}
var options: EventViewOptions {
if self.damus_state.settings.translate_dms {
return [.truncate_content, .no_action_bar]
@@ -54,7 +46,8 @@ struct DirectMessagesView: View {
func MaybeEvent(_ model: DirectMessageModel) -> some View {
Group {
if let ev = model.events.last {
let ok = damus_state.settings.friend_filter.filter(contacts: damus_state.contacts, pubkey: model.pubkey)
if ok, let ev = model.events.last {
EventView(damus: damus_state, event: ev, pubkey: model.pubkey, options: options)
.onTapGesture {
self.model.set_active_dm_model(model)
@@ -91,12 +84,10 @@ struct DirectMessagesView: View {
.tabViewStyle(.page(indexDisplayMode: .never))
}
.toolbar {
if selected_timeline == .dms {
ToolbarItem(placement: .navigationBarTrailing) {
if would_filter_non_friends_from_dms(contacts: damus_state.contacts, dms: self.model.dms) {
FriendsButton(filter: $settings.friend_filter)
}
ToolbarItem(placement: .navigationBarTrailing) {
if would_filter_non_friends_from_dms(contacts: damus_state.contacts, dms: self.model.dms) {
FriendsButton(filter: $settings.friend_filter)
}
}
}
+54 -15
View File
@@ -10,38 +10,71 @@ import SwiftUI
struct BuilderEventView: View {
let damus: DamusState
let event_id: NoteId
let event: NostrEvent?
@State var event: NostrEvent?
@State var subscription_uuid: String = UUID().description
init(damus: DamusState, event: NostrEvent) {
self.event = event
_event = State(initialValue: event)
self.damus = damus
self.event_id = event.id
}
init(damus: DamusState, event_id: NoteId) {
let event = damus.events.lookup(event_id)
self.event_id = event_id
self.damus = damus
self.event = nil
_event = State(initialValue: event)
}
func Event(event: NostrEvent) -> some View {
return EventView(damus: damus, event: event, options: .embedded)
.padding([.top, .bottom], 8)
.onTapGesture {
let ev = event.get_inner_event(cache: damus.events) ?? event
let thread = ThreadModel(event: ev, damus_state: damus)
damus.nav.push(route: .Thread(thread: thread))
}
func unsubscribe() {
damus.pool.unsubscribe(sub_id: subscription_uuid)
}
func subscribe(filters: [NostrFilter]) {
damus.pool.register_handler(sub_id: subscription_uuid, handler: handle_event)
damus.pool.send(.subscribe(.init(filters: filters, sub_id: subscription_uuid)))
}
func handle_event(relay_id: String, ev: NostrConnectionEvent) {
guard case .nostr_event(let nostr_response) = ev else {
return
}
guard case .event(let id, let nostr_event) = nostr_response else {
return
}
guard id == subscription_uuid else {
return
}
if event != nil {
return
}
event = nostr_event
unsubscribe()
}
func load() {
subscribe(filters: [
NostrFilter(ids: [self.event_id], limit: 1)
])
}
var body: some View {
VStack {
if let event {
self.Event(event: event)
EventView(damus: damus, event: event, options: .embedded)
.padding([.top, .bottom], 8)
.onTapGesture {
let ev = event.get_inner_event(cache: damus.events) ?? event
let thread = ThreadModel(event: ev, damus_state: damus)
damus.nav.push(route: .Thread(thread: thread))
}
} else {
EventLoaderView(damus_state: damus, event_id: self.event_id) { loaded_event in
self.Event(event: loaded_event)
}
ProgressView().padding()
}
}
.frame(minWidth: 0, maxWidth: .infinity)
@@ -49,6 +82,12 @@ struct BuilderEventView: View {
RoundedRectangle(cornerRadius: 10)
.stroke(Color.gray.opacity(0.2), lineWidth: 1.0)
)
.onAppear {
guard event == nil else {
return
}
self.load()
}
}
}
-87
View File
@@ -1,87 +0,0 @@
//
// EventLoaderView.swift
// damus
//
// Created by Daniel DAquino on 2023-09-27.
//
import SwiftUI
/// This view handles the loading logic for Nostr events, so that you can easily use views that require `NostrEvent`, even if you only have a `NoteId`
struct EventLoaderView<Content: View>: View {
let damus_state: DamusState
let event_id: NoteId
@State var event: NostrEvent?
@State var subscription_uuid: String = UUID().description
let content: (NostrEvent) -> Content
init(damus_state: DamusState, event_id: NoteId, @ViewBuilder content: @escaping (NostrEvent) -> Content) {
self.damus_state = damus_state
self.event_id = event_id
self.content = content
let event = damus_state.events.lookup(event_id)
_event = State(initialValue: event)
}
func unsubscribe() {
damus_state.pool.unsubscribe(sub_id: subscription_uuid)
}
func subscribe(filters: [NostrFilter]) {
damus_state.pool.register_handler(sub_id: subscription_uuid, handler: handle_event)
damus_state.pool.send(.subscribe(.init(filters: filters, sub_id: subscription_uuid)))
}
func handle_event(relay_id: String, ev: NostrConnectionEvent) {
guard case .nostr_event(let nostr_response) = ev else {
return
}
guard case .event(let id, let nostr_event) = nostr_response else {
return
}
guard id == subscription_uuid else {
return
}
if event != nil {
return
}
event = nostr_event
unsubscribe()
}
func load() {
subscribe(filters: [
NostrFilter(ids: [self.event_id], limit: 1)
])
}
var body: some View {
VStack {
if let event {
self.content(event)
} else {
ProgressView().padding()
}
}
.onAppear {
guard event == nil else {
return
}
self.load()
}
}
}
struct EventLoaderView_Previews: PreviewProvider {
static var previews: some View {
EventLoaderView(damus_state: test_damus_state, event_id: test_note.id) { event in
EventView(damus: test_damus_state, event: event)
}
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ struct EventMenuContext: View {
Color.clear
}
// Hitbox frame size
.frame(width: 50, height: 35)
.frame(width: 100, height: 70)
)
}
.padding([.bottom], 4)
+2 -6
View File
@@ -37,13 +37,9 @@ struct EventProfile: View {
var body: some View {
HStack(alignment: .center, spacing: 10) {
ProfilePicView(pubkey: pubkey, size: pfp_size, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation, show_zappability: true)
ProfilePicView(pubkey: pubkey, size: pfp_size, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation)
.onTapGesture {
show_profile_action_sheet_if_enabled(damus_state: damus_state, pubkey: pubkey)
}
.onLongPressGesture(minimumDuration: 0.1) {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
damus_state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
damus_state.nav.push(route: .ProfileByKey(pubkey: pubkey))
}
VStack(alignment: .leading, spacing: 0) {
+7 -2
View File
@@ -42,6 +42,10 @@ struct EventShell<Content: View>: View {
return first_eref_mention(ev: event, keypair: state.keypair)
}
func Mention(_ mention: Mention<NoteId>) -> some View {
return BuilderEventView(damus: state, event_id: mention.ref)
}
var ActionBar: some View {
return EventActionBar(damus_state: state, event: event)
@@ -74,7 +78,7 @@ struct EventShell<Content: View>: View {
content
if let mention = get_mention() {
MentionView(damus_state: state, mention: mention)
Mention(mention)
}
if has_action_bar {
@@ -103,7 +107,7 @@ struct EventShell<Content: View>: View {
content
if !options.contains(.no_mentions), let mention = get_mention() {
MentionView(damus_state: state, mention: mention)
Mention(mention)
.padding(.horizontal)
}
@@ -124,6 +128,7 @@ struct EventShell<Content: View>: View {
}
.contentShape(Rectangle())
.id(event.id)
.frame(maxWidth: .infinity, minHeight: PFP_SIZE)
.padding([.bottom], 2)
}
}
-32
View File
@@ -1,32 +0,0 @@
//
// MentionView.swift
// damus
//
// Created by Daniel DAquino on 2023-09-27.
//
import SwiftUI
struct MentionView: View {
let damus_state: DamusState
let mention: Mention<NoteId>
init(damus_state: DamusState, mention: Mention<NoteId>) {
self.damus_state = damus_state
self.mention = mention
}
var body: some View {
EventLoaderView(damus_state: damus_state, event_id: mention.ref) { event in
EventMutingContainerView(damus_state: damus_state, event: event) {
BuilderEventView(damus: damus_state, event_id: mention.ref)
}
}
}
}
struct MentionView_Previews: PreviewProvider {
static var previews: some View {
MentionView(damus_state: test_damus_state, mention: .note(test_note.id))
}
}
@@ -1,5 +1,5 @@
//
// EventMutingContainerView.swift
// MutedEventView.swift
// damus
//
// Created by William Casarin on 2023-01-27.
@@ -7,45 +7,57 @@
import SwiftUI
/// A container view that shows or hides provided content based on whether the given event should be muted or not, with built-in user controls to show or hide content, and an option to customize the muted box
struct EventMutingContainerView<Content: View>: View {
typealias MuteBoxViewClosure = ((_ shown: Binding<Bool>) -> AnyView)
struct MutedEventView: View {
let damus_state: DamusState
let event: NostrEvent
let content: Content
var customMuteBox: MuteBoxViewClosure?
let selected: Bool
@State var shown: Bool
init(damus_state: DamusState, event: NostrEvent, @ViewBuilder content: () -> Content) {
init(damus_state: DamusState, event: NostrEvent, selected: Bool) {
self.damus_state = damus_state
self.event = event
self.content = content()
self.selected = selected
self._shown = State(initialValue: should_show_event(keypair: damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: event))
}
init(damus_state: DamusState, event: NostrEvent, muteBox: @escaping MuteBoxViewClosure, @ViewBuilder content: () -> Content) {
self.init(damus_state: damus_state, event: event, content: content)
self.customMuteBox = muteBox
}
var should_mute: Bool {
return !should_show_event(keypair: damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: event)
}
var MutedBox: some View {
ZStack {
RoundedRectangle(cornerRadius: 20)
.foregroundColor(DamusColors.adaptableGrey)
HStack {
Text("Note from a user you've muted", comment: "Text to indicate that what is being shown is a note from a user who has been muted.")
Spacer()
Button(shown ? NSLocalizedString("Hide", comment: "Button to hide a note from a user who has been muted.") : NSLocalizedString("Show", comment: "Button to show a note from a user who has been muted.")) {
shown.toggle()
}
}
.padding(10)
}
}
var Event: some View {
Group {
if selected {
SelectedEventView(damus: damus_state, event: event, size: .selected)
} else {
EventView(damus: damus_state, event: event)
}
}
}
var body: some View {
Group {
if should_mute {
if let customMuteBox {
customMuteBox($shown)
}
else {
EventMutedBoxView(shown: $shown)
}
MutedBox
}
if shown {
self.content
Event
}
}
.onReceive(handle_notify(.new_mutes)) { mutes in
@@ -61,34 +73,11 @@ struct EventMutingContainerView<Content: View>: View {
}
}
/// A box that instructs the user about a content that has been muted.
struct EventMutedBoxView: View {
@Binding var shown: Bool
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 20)
.foregroundColor(DamusColors.adaptableGrey)
HStack {
Text("Note from a user you've muted", comment: "Text to indicate that what is being shown is a note from a user who has been muted.")
Spacer()
Button(shown ? NSLocalizedString("Hide", comment: "Button to hide a note from a user who has been muted.") : NSLocalizedString("Show", comment: "Button to show a note from a user who has been muted.")) {
shown.toggle()
}
}
.padding(10)
}
}
}
struct MutedEventView_Previews: PreviewProvider {
static var previews: some View {
EventMutingContainerView(damus_state: test_damus_state, event: test_note) {
EventView(damus: test_damus_state, event: test_note)
}
MutedEventView(damus_state: test_damus_state, event: test_note, selected: false)
.frame(width: .infinity, height: 50)
}
}
+4 -10
View File
@@ -55,7 +55,10 @@ struct SelectedEventView: View {
EventBody(damus_state: damus, event: event, size: size, options: [.wide])
Mention
if let mention = first_eref_mention(ev: event, keypair: damus.keypair) {
BuilderEventView(damus: damus, event_id: mention.ref)
.padding(.horizontal)
}
Text(verbatim: "\(format_date(event.created_at))")
.padding([.top, .leading, .trailing])
@@ -85,15 +88,6 @@ struct SelectedEventView: View {
.compositingGroup()
}
}
var Mention: some View {
Group {
if let mention = first_eref_mention(ev: event, keypair: damus.keypair) {
MentionView(damus_state: damus, mention: mention)
.padding(.horizontal)
}
}
}
}
struct SelectedEventView_Previews: PreviewProvider {
+1 -2
View File
@@ -151,8 +151,7 @@ struct FollowingView: View {
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onAppear {
let txn = NdbTxn(ndb: self.damus_state.ndb)
following.subscribe(txn: txn)
following.subscribe()
}
.onDisappear {
following.unsubscribe()
+5 -4
View File
@@ -12,11 +12,12 @@ import Kingfisher
struct ImageContainerView: View {
let video_controller: VideoController
let url: MediaUrl
let settings: UserSettingsStore
@State private var image: UIImage?
@State private var showShareSheet = false
let disable_animation: Bool
private struct ImageHandler: ImageModifier {
@Binding var handler: UIImage?
@@ -28,13 +29,13 @@ struct ImageContainerView: View {
func Img(url: URL) -> some View {
KFAnimatedImage(url)
.imageContext(.note, disable_animation: settings.disable_animation)
.imageContext(.note, disable_animation: disable_animation)
.configure { view in
view.framePreloadCount = 3
}
.imageModifier(ImageHandler(handler: $image))
.clipped()
.modifier(ImageContextMenuModifier(url: url, image: image, settings: settings, showShareSheet: $showShareSheet))
.modifier(ImageContextMenuModifier(url: url, image: image, showShareSheet: $showShareSheet))
.sheet(isPresented: $showShareSheet) {
ShareSheet(activityItems: [url])
}
@@ -56,6 +57,6 @@ let test_image_url = URL(string: "https://jb55.com/red-me.jpg")!
struct ImageContainerView_Previews: PreviewProvider {
static var previews: some View {
ImageContainerView(video_controller: test_damus_state.video, url: .image(test_image_url), settings: test_damus_state.settings)
ImageContainerView(video_controller: test_damus_state.video, url: .image(test_image_url), disable_animation: false)
}
}
@@ -12,17 +12,8 @@ import UIKit
struct ImageContextMenuModifier: ViewModifier {
let url: URL?
let image: UIImage?
let settings: UserSettingsStore
@State var qrCodeValue: String = ""
@State var open_link_confirm: Bool = false
@State var open_wallet_confirm: Bool = false
@State var not_found: Bool = false
@Binding var showShareSheet: Bool
@Environment(\.openURL) var openURL
func body(content: Content) -> some View {
return content.contextMenu {
Button {
@@ -41,36 +32,6 @@ struct ImageContextMenuModifier: ViewModifier {
} label: {
Label(NSLocalizedString("Save Image", comment: "Context menu option to save an image."), image: "download")
}
Button {
qrCodeValue = ""
guard let detector:CIDetector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy:CIDetectorAccuracyHigh]) else {
return
}
guard let ciImage = CIImage(image:someImage) else {
return
}
let features = detector.features(in: ciImage)
if let qrfeatures = features as? [CIQRCodeFeature] {
for feature in qrfeatures {
if let msgStr = feature.messageString {
qrCodeValue = msgStr
}
}
}
if qrCodeValue == "" {
not_found.toggle()
} else {
if qrCodeValue.localizedCaseInsensitiveContains("lnurl") || qrCodeValue.localizedCaseInsensitiveContains("lnbc") {
open_wallet_confirm.toggle()
open_link_confirm.toggle()
} else if let _ = URL(string: qrCodeValue) {
open_link_confirm.toggle()
}
}
} label: {
Label(NSLocalizedString("Scan for QR Code", comment: "Context menu option to scan image for a QR Code."), image: "qr-code.fill")
}
}
Button {
showShareSheet = true
@@ -78,30 +39,5 @@ struct ImageContextMenuModifier: ViewModifier {
Label(NSLocalizedString("Share", comment: "Button to share an image."), image: "upload")
}
}
.alert(NSLocalizedString("Found\n \(qrCodeValue)", comment: "Alert message asking if the user wants to open the link.").truncate(maxLength: 50), isPresented: $open_link_confirm) {
if open_wallet_confirm {
Button(NSLocalizedString("Open in wallet", comment: "Button to open the value found in browser."), role: .none) {
do {
try open_with_wallet(wallet: settings.default_wallet.model, invoice: qrCodeValue)
}
catch {
present_sheet(.select_wallet(invoice: qrCodeValue))
}
}
} else {
Button(NSLocalizedString("Open in browser", comment: "Button to open the value found in browser."), role: .none) {
if let url = URL(string: qrCodeValue) {
openURL(url)
}
}
}
Button(NSLocalizedString("Copy", comment: "Button to copy the value found."), role: .none) {
UIPasteboard.general.string = qrCodeValue
}
Button(NSLocalizedString("Cancel", comment: "Button to cancel any interaction with the QRCode link."), role: .cancel) {}
}
.alert(NSLocalizedString("Unable to find a QR Code", comment: "Alert message letting user know a QR Code was not found."), isPresented: $not_found) {
Button(NSLocalizedString("Dismiss", comment: "Button to dismiss alert"), role: .cancel) {}
}
}
}
+3 -3
View File
@@ -16,7 +16,7 @@ struct ImageView: View {
@State private var selectedIndex = 0
@State var showMenu = true
let settings: UserSettingsStore
let disable_animation: Bool
var tabViewIndicator: some View {
HStack(spacing: 10) {
@@ -42,7 +42,7 @@ struct ImageView: View {
TabView(selection: $selectedIndex) {
ForEach(urls.indices, id: \.self) { index in
ZoomableScrollView {
ImageContainerView(video_controller: video_controller, url: urls[index], settings: settings)
ImageContainerView(video_controller: video_controller, url: urls[index], disable_animation: disable_animation)
.aspectRatio(contentMode: .fit)
.padding(.top, Theme.safeAreaInsets?.top)
.padding(.bottom, Theme.safeAreaInsets?.bottom)
@@ -85,6 +85,6 @@ struct ImageView: View {
struct ImageView_Previews: PreviewProvider {
static var previews: some View {
let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!)
ImageView(video_controller: test_damus_state.video, urls: [url], settings: test_damus_state.settings)
ImageView(video_controller: test_damus_state.video, urls: [url], disable_animation: false)
}
}
+7 -6
View File
@@ -9,11 +9,12 @@ import Kingfisher
struct ProfileImageContainerView: View {
let url: URL?
let settings: UserSettingsStore
@State private var image: UIImage?
@State private var showShareSheet = false
let disable_animation: Bool
private struct ImageHandler: ImageModifier {
@Binding var handler: UIImage?
@@ -26,13 +27,13 @@ struct ProfileImageContainerView: View {
var body: some View {
KFAnimatedImage(url)
.imageContext(.pfp, disable_animation: settings.disable_animation)
.imageContext(.pfp, disable_animation: disable_animation)
.configure { view in
view.framePreloadCount = 3
}
.imageModifier(ImageHandler(handler: $image))
.clipShape(Circle())
.modifier(ImageContextMenuModifier(url: url, image: image, settings: settings, showShareSheet: $showShareSheet))
.modifier(ImageContextMenuModifier(url: url, image: image, showShareSheet: $showShareSheet))
.sheet(isPresented: $showShareSheet) {
ShareSheet(activityItems: [url])
}
@@ -63,7 +64,7 @@ struct NavDismissBarView: View {
struct ProfilePicImageView: View {
let pubkey: Pubkey
let profiles: Profiles
let settings: UserSettingsStore
let disable_animation: Bool
@Environment(\.presentationMode) var presentationMode
@@ -73,7 +74,7 @@ struct ProfilePicImageView: View {
.ignoresSafeArea()
ZoomableScrollView {
ProfileImageContainerView(url: get_profile_url(picture: nil, pubkey: pubkey, profiles: profiles), settings: settings)
ProfileImageContainerView(url: get_profile_url(picture: nil, pubkey: pubkey, profiles: profiles), disable_animation: disable_animation)
.aspectRatio(contentMode: .fit)
.padding(.top, Theme.safeAreaInsets?.top)
.padding(.bottom, Theme.safeAreaInsets?.bottom)
@@ -93,7 +94,7 @@ struct ProfileZoomView_Previews: PreviewProvider {
ProfilePicImageView(
pubkey: test_pubkey,
profiles: make_preview_profiles(test_pubkey),
settings: test_damus_state.settings
disable_animation: false
)
}
}
+35 -121
View File
@@ -30,13 +30,6 @@ enum ParsedKey {
}
return false
}
var is_priv: Bool {
if case .priv = self {
return true
}
return false
}
}
struct LoginView: View {
@@ -44,7 +37,6 @@ struct LoginView: View {
@State var is_pubkey: Bool = false
@State var error: String? = nil
@State private var credential_handler = CredentialHandler()
@State private var shouldSaveKey: Bool = true
var nav: NavigationCoordinator
func get_error(parsed_key: ParsedKey?) -> String? {
@@ -65,7 +57,7 @@ struct LoginView: View {
SignInHeader()
.padding(.top, 100)
SignInEntry(key: $key, shouldSaveKey: $shouldSaveKey)
SignInEntry(key: $key)
let parsed = parse_key(key)
@@ -91,7 +83,7 @@ struct LoginView: View {
Button(action: {
Task {
do {
try await process_login(p, is_pubkey: is_pubkey, shouldSaveKey: shouldSaveKey)
try await process_login(p, is_pubkey: is_pubkey)
} catch {
self.error = error.localizedDescription
}
@@ -176,39 +168,37 @@ enum LoginError: LocalizedError {
}
}
func process_login(_ key: ParsedKey, is_pubkey: Bool, shouldSaveKey: Bool = true) async throws {
if shouldSaveKey {
switch key {
case .priv(let priv):
try handle_privkey(priv)
case .pub(let pub):
func process_login(_ key: ParsedKey, is_pubkey: Bool) async throws {
switch key {
case .priv(let priv):
try handle_privkey(priv)
case .pub(let pub):
try clear_saved_privkey()
save_pubkey(pubkey: pub)
case .nip05(let id):
guard let nip05 = await get_nip05_pubkey(id: id) else {
throw LoginError.nip05_failed
}
// this is a weird way to login anyways
/*
var bootstrap_relays = load_bootstrap_relays(pubkey: nip05.pubkey)
for relay in nip05.relays {
if !(bootstrap_relays.contains { $0 == relay }) {
bootstrap_relays.append(relay)
}
}
*/
save_pubkey(pubkey: nip05.pubkey)
case .hex(let hexstr):
if is_pubkey, let pubkey = hex_decode_pubkey(hexstr) {
try clear_saved_privkey()
save_pubkey(pubkey: pub)
case .nip05(let id):
guard let nip05 = await get_nip05_pubkey(id: id) else {
throw LoginError.nip05_failed
}
// this is a weird way to login anyways
/*
var bootstrap_relays = load_bootstrap_relays(pubkey: nip05.pubkey)
for relay in nip05.relays {
if !(bootstrap_relays.contains { $0 == relay }) {
bootstrap_relays.append(relay)
}
}
*/
save_pubkey(pubkey: nip05.pubkey)
case .hex(let hexstr):
if is_pubkey, let pubkey = hex_decode_pubkey(hexstr) {
try clear_saved_privkey()
save_pubkey(pubkey: pubkey)
} else if let privkey = hex_decode_privkey(hexstr) {
try handle_privkey(privkey)
}
save_pubkey(pubkey: pubkey)
} else if let privkey = hex_decode_privkey(hexstr) {
try handle_privkey(privkey)
}
}
@@ -223,16 +213,7 @@ func process_login(_ key: ParsedKey, is_pubkey: Bool, shouldSaveKey: Bool = true
save_pubkey(pubkey: pk)
}
func handle_transient_privkey(_ key: ParsedKey) -> Keypair? {
if case let .priv(priv) = key, let pubkey = privkey_to_pubkey(privkey: priv) {
return Keypair(pubkey: pubkey, privkey: priv)
}
return nil
}
let keypair = shouldSaveKey ? get_saved_keypair() : handle_transient_privkey(key)
guard let keypair = keypair else {
guard let keypair = get_saved_keypair() else {
return
}
@@ -284,15 +265,11 @@ func get_nip05_pubkey(id: String) async -> NIP05User? {
struct KeyInput: View {
let title: String
let key: Binding<String>
let shouldSaveKey: Binding<Bool>
var privKeyFound: Binding<Bool>
@State private var is_secured: Bool = true
init(_ title: String, key: Binding<String>, shouldSaveKey: Binding<Bool>, privKeyFound: Binding<Bool>) {
init(_ title: String, key: Binding<String>) {
self.title = title
self.key = key
self.shouldSaveKey = shouldSaveKey
self.privKeyFound = privKeyFound
}
var body: some View {
@@ -304,8 +281,6 @@ struct KeyInput: View {
self.key.wrappedValue = pastedkey
}
}
SignInScan(shouldSaveKey: shouldSaveKey, loginKey: key, privKeyFound: privKeyFound)
if is_secured {
SecureField("", text: key)
.nsecLoginStyle(key: key.wrappedValue, title: title)
@@ -348,79 +323,18 @@ struct SignInHeader: View {
struct SignInEntry: View {
let key: Binding<String>
let shouldSaveKey: Binding<Bool>
@State private var privKeyFound: Bool = false
var body: some View {
VStack(alignment: .leading) {
Text("Enter your account key", comment: "Prompt for user to enter an account key to login.")
.fontWeight(.medium)
.padding(.top, 30)
KeyInput(NSLocalizedString("nsec1...", comment: "Prompt for user to enter in an account key to login. This text shows the characters the key could start with if it was a private key."),
key: key,
shouldSaveKey: shouldSaveKey,
privKeyFound: $privKeyFound)
if privKeyFound {
Toggle("Save Key in Secure Keychain", isOn: shouldSaveKey)
}
KeyInput(NSLocalizedString("nsec1...", comment: "Prompt for user to enter in an account key to login. This text shows the characters the key could start with if it was a private key."), key: key)
}
}
}
struct SignInScan: View {
@State var showQR: Bool = false
@State var qrkey: ParsedKey?
@Binding var shouldSaveKey: Bool
@Binding var loginKey: String
@Binding var privKeyFound: Bool
let generator = UINotificationFeedbackGenerator()
var body: some View {
VStack {
Button(action: { showQR.toggle() }, label: {
Image(systemName: "qrcode.viewfinder")})
.foregroundColor(.gray)
}
.sheet(isPresented: $showQR, onDismiss: {
if qrkey == nil { resetView() }}
) {
QRScanNSECView(showQR: $showQR,
privKeyFound: $privKeyFound,
codeScannerCompletion: { scannerCompletion($0) })
}
.onChange(of: showQR) { show in
if showQR { resetView() }
}
}
func handleQRString(_ string: String) {
qrkey = parse_key(string)
if let key = qrkey, key.is_priv {
loginKey = string
privKeyFound = true
shouldSaveKey = false
generator.notificationOccurred(.success)
}
}
func scannerCompletion(_ result: Result<ScanResult, ScanError>) {
switch result {
case .success(let success):
handleQRString(success.string)
case .failure:
return
}
}
func resetView() {
loginKey = ""
qrkey = nil
privKeyFound = false
shouldSaveKey = true
}
}
struct CreateAccountPrompt: View {
var nav: NavigationCoordinator
var body: some View {
+15 -71
View File
@@ -27,7 +27,6 @@ struct NoteContentView: View {
let damus_state: DamusState
let event: NostrEvent
@State var show_images: Bool
@State var load_media: Bool = false
let size: EventViewKind
let preview_height: CGFloat?
let options: EventViewOptions
@@ -133,21 +132,18 @@ struct NoteContentView: View {
translateView
}
}
if artifacts.media.count > 0 {
if !damus_state.settings.media_previews && !load_media {
loadMediaButton(artifacts: artifacts)
} else if show_images || (show_images && !damus_state.settings.media_previews && load_media) {
if show_images && artifacts.media.count > 0 {
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media)
} else if !show_images && artifacts.media.count > 0 {
ZStack {
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media)
} else if !show_images || (!show_images && !damus_state.settings.media_previews && load_media) {
ZStack {
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media)
Blur()
.onTapGesture {
show_images = true
}
}
Blur()
.onTapGesture {
show_images = true
}
}
//.cornerRadius(10)
}
if artifacts.invoices.count > 0 {
@@ -159,54 +155,15 @@ struct NoteContentView: View {
}
}
if damus_state.settings.media_previews {
if with_padding {
previewView(links: artifacts.links).padding(.horizontal)
} else {
previewView(links: artifacts.links)
}
if with_padding {
previewView(links: artifacts.links).padding(.horizontal)
} else {
previewView(links: artifacts.links)
}
}
}
func loadMediaButton(artifacts: NoteArtifactsSeparated) -> some View {
Button(action: {
load_media = true
}, label: {
VStack(alignment: .leading) {
HStack {
Image("images")
Text("Load media", comment: "Button to show media in note.")
.fontWeight(.bold)
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
}
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
ForEach(artifacts.media.indices, id: \.self) { index in
Divider()
.frame(height: 1)
switch artifacts.media[index] {
case .image(let url), .video(let url):
Text("\(url)")
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
.foregroundStyle(DamusColors.neutral6)
.multilineTextAlignment(.leading)
.padding(EdgeInsets(top: 0, leading: 10, bottom: 5, trailing: 10))
}
}
}
.background(DamusColors.neutral1)
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(DamusColors.neutral3, lineWidth: 1)
)
})
.padding(.horizontal)
}
func load(force_artifacts: Bool = false) {
// always reload artifacts on load
let plan = get_preload_plan(evcache: damus_state.events, ev: event, our_keypair: damus_state.keypair, settings: damus_state.settings)
@@ -625,7 +582,7 @@ func classify_url(_ url: URL) -> UrlType {
return .media(.image(url))
}
if str.hasSuffix(".mp4") || str.hasSuffix(".mov") || str.hasSuffix(".m3u8") {
if str.hasSuffix(".mp4") || str.hasSuffix(".mov") {
return .media(.video(url))
}
@@ -712,16 +669,3 @@ func count_markdown_words(blocks: [BlockNode]) -> Int {
}
}
func separate_images(ev: NostrEvent, keypair: Keypair) -> [MediaUrl]? {
let urlBlocks: [URL] = ev.blocks(keypair).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
}
@@ -82,8 +82,7 @@ struct NotificationsView: View {
@ObservedObject var notifications: NotificationsModel
@StateObject var filter = NotificationFilter()
@SceneStorage("NotificationsView.filter_state") var filter_state: NotificationFilterState = .all
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home
@Environment(\.colorScheme) var colorScheme
var mystery: some View {
@@ -1,129 +0,0 @@
//
// OnboardingSuggestionsView.swift
// damus
//
// Created by klabo on 7/17/23.
//
import SwiftUI
fileprivate let first_post_example_1: String = NSLocalizedString("Hello everybody!\n\nThis is my first post on Damus, I am happy to meet you all 🤙. Whats up?\n\n#introductions", comment: "First post example given to the user during onboarding, as a suggestion as to what they could post first")
fileprivate let first_post_example_2: String = NSLocalizedString("This is my first post on Nostr 💜. I love drawing and folding Origami!\n\nNice to meet you all! #introductions #plebchain ", comment: "First post example given to the user during onboarding, as a suggestion as to what they could post first")
fileprivate let first_post_example_3: String = NSLocalizedString("For #Introductions! Im a software developer.\n\nMy side interests include languages and I am striving to be a #polyglot - I am a native English speaker and can speak French, German and Japanese.", comment: "First post example given to the user during onboarding, as a suggestion as to what they could post first")
fileprivate let first_post_example_4: String = NSLocalizedString("Howdy! Im a graphic designer during the day and coder at night, but Im also trying to spend more time outdoors.\n\nHope to meet folks who are on their own journeys to a peaceful and free life!", comment: "First post example given to the user during onboarding, as a suggestion as to what they could post first")
struct OnboardingSuggestionsView: View {
@StateObject var model: SuggestedUsersViewModel
@State var current_page: Int = 0
let first_post_examples: [String] = [first_post_example_1, first_post_example_2, first_post_example_3, first_post_example_4]
let initial_text_suffix: String = "\n\n#introductions"
@Environment(\.dismiss) var dismiss
func next_page() {
withAnimation {
current_page += 1
}
}
var body: some View {
NavigationView {
TabView(selection: $current_page) {
SuggestedUsersPageView(model: model, next_page: self.next_page)
.navigationTitle(NSLocalizedString("Who to Follow", comment: "Title for a screen displaying suggestions of who to follow"))
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: {
self.next_page()
}, label: {
Text(NSLocalizedString("Skip", comment: "Button to dismiss the suggested users screen"))
.font(.subheadline.weight(.semibold))
}))
.tag(0)
PostView(
action: .posting(.user(model.damus_state.pubkey)),
damus_state: model.damus_state,
prompt_view: {
AnyView(
HStack {
Image(systemName: "sparkles")
Text(NSLocalizedString("Add your first post", comment: "Prompt given to the user during onboarding, suggesting them to write their first post"))
}
.foregroundColor(.secondary)
.font(.callout)
.padding(.top, 10)
)
},
placeholder_messages: self.first_post_examples,
initial_text_suffix: self.initial_text_suffix
)
.onReceive(handle_notify(.post)) { _ in
// NOTE: Even though PostView already calls `dismiss`, that is not guaranteed to work under deeply nested views.
// Thus, we should also call `dismiss` from here (a direct subview of a sheet), which is explicitly supported by Apple.
// See https://github.com/damus-io/damus/issues/1726 for more context and information
dismiss()
}
.tag(1)
}
.tabViewStyle(.page(indexDisplayMode: .never))
}
}
}
fileprivate struct SuggestedUsersPageView: View {
var model: SuggestedUsersViewModel
var next_page: (() -> Void)
var body: some View {
VStack {
List {
ForEach(model.groups) { group in
Section {
ForEach(group.users, id: \.self) { pk in
if let user = model.suggestedUser(pubkey: pk) {
SuggestedUserView(user: user, damus_state: model.damus_state)
}
}
} header: {
SuggestedUsersSectionHeader(group: group, model: model)
}
}
}
.listStyle(.plain)
Spacer()
Button(action: {
self.next_page()
}) {
Text(NSLocalizedString("Continue", comment: "Button to dismiss suggested users view and continue to the main app"))
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
}
.buttonStyle(GradientButtonStyle())
.padding([.leading, .trailing], 24)
.padding(.bottom, 16)
}
}
}
struct SuggestedUsersSectionHeader: View {
let group: SuggestedUserGroup
let model: SuggestedUsersViewModel
var body: some View {
HStack {
Text(group.title.uppercased())
Spacer()
Button(NSLocalizedString("Follow All", comment: "Button to follow all users in this section")) {
model.follow(pubkeys: group.users)
}
.font(.subheadline.weight(.semibold))
}
}
}
struct SuggestedUsersView_Previews: PreviewProvider {
static var previews: some View {
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: test_damus_state))
}
}
@@ -0,0 +1,77 @@
//
// SuggestedUsersView.swift
// damus
//
// Created by klabo on 7/17/23.
//
import SwiftUI
struct SuggestedUsersView: View {
@StateObject var model: SuggestedUsersViewModel
@Environment(\.presentationMode) private var presentationMode
var body: some View {
NavigationView {
VStack {
List {
ForEach(model.groups) { group in
Section {
ForEach(group.users, id: \.self) { pk in
if let user = model.suggestedUser(pubkey: pk) {
SuggestedUserView(user: user, damus_state: model.damus_state)
}
}
} header: {
SuggestedUsersSectionHeader(group: group, model: model)
}
}
}
.listStyle(.plain)
Spacer()
Button(action: {
presentationMode.wrappedValue.dismiss()
}) {
Text(NSLocalizedString("Continue", comment: "Button to dismiss suggested users view and continue to the main app"))
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
}
.buttonStyle(GradientButtonStyle())
.padding([.leading, .trailing], 24)
.padding(.bottom, 16)
}
.navigationTitle(NSLocalizedString("Who to Follow", comment: "Title for a screen displaying suggestions of who to follow"))
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing: Button(action: {
presentationMode.wrappedValue.dismiss()
}, label: {
Text(NSLocalizedString("Skip", comment: "Button to dismiss the suggested users screen"))
.font(.subheadline.weight(.semibold))
}))
}
}
}
struct SuggestedUsersSectionHeader: View {
let group: SuggestedUserGroup
let model: SuggestedUsersViewModel
var body: some View {
HStack {
Text(group.title.uppercased())
Spacer()
Button(NSLocalizedString("Follow All", comment: "Button to follow all users in this section")) {
model.follow(pubkeys: group.users)
}
.font(.subheadline.weight(.semibold))
}
}
}
struct SuggestedUsersView_Previews: PreviewProvider {
static var previews: some View {
SuggestedUsersView(model: SuggestedUsersViewModel(damus_state: test_damus_state))
}
}
@@ -86,7 +86,7 @@ class SuggestedUsersViewModel: ObservableObject {
}
switch nev {
case .event:
case .event(let sub_id, let ev):
break
case .notice(let msg):
+64 -74
View File
@@ -56,41 +56,27 @@ struct PostView: View {
@State var filtered_pubkeys: Set<Pubkey> = []
@State var focusWordAttributes: (String?, NSRange?) = (nil, nil)
@State var newCursorIndex: Int?
@State var caretRect: CGRect = CGRectNull
@State var textHeight: CGFloat? = nil
@State var mediaToUpload: MediaUpload? = nil
@StateObject var image_upload: ImageUploadModel = ImageUploadModel()
@StateObject var tagModel: TagModel = TagModel()
@State private var current_placeholder_index = 0
let action: PostAction
let damus_state: DamusState
let prompt_view: (() -> AnyView)?
let placeholder_messages: [String]
let initial_text_suffix: String?
init(
action: PostAction,
damus_state: DamusState,
prompt_view: (() -> AnyView)? = nil,
placeholder_messages: [String]? = nil,
initial_text_suffix: String? = nil
) {
self.action = action
self.damus_state = damus_state
self.prompt_view = prompt_view
self.placeholder_messages = placeholder_messages ?? [POST_PLACEHOLDER]
self.initial_text_suffix = initial_text_suffix
}
@Environment(\.dismiss) var dismiss
@Environment(\.presentationMode) var presentationMode
func cancel() {
notify(.post(.cancel))
dismiss()
}
func dismiss() {
self.presentationMode.wrappedValue.dismiss()
}
func send_post() {
let refs = references.filter { ref in
@@ -99,7 +85,7 @@ struct PostView: View {
}
return true
}
let new_post = build_post(state: damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, references: refs)
let new_post = build_post(post: self.post, action: action, uploadedMedias: uploadedMedias, references: refs)
notify(.post(.post(new_post)))
@@ -150,7 +136,7 @@ struct PostView: View {
}
var AttachmentBar: some View {
HStack(alignment: .center, spacing: 15) {
HStack(alignment: .center) {
ImageButton
CameraButton
}
@@ -166,10 +152,12 @@ struct PostView: View {
}
}
.disabled(posting_disabled)
.font(.system(size: 14, weight: .bold))
.frame(width: 80, height: 30)
.foregroundColor(.white)
.background(LINEAR_GRADIENT)
.opacity(posting_disabled ? 0.5 : 1.0)
.bold()
.buttonStyle(GradientButtonStyle(padding: 10))
.clipShape(Capsule())
}
func isEmpty() -> Bool {
@@ -227,19 +215,19 @@ struct PostView: View {
var TextEntry: some View {
ZStack(alignment: .topLeading) {
TextViewWrapper(
attributedText: $post,
textHeight: $textHeight,
initialTextSuffix: initial_text_suffix,
cursorIndex: newCursorIndex,
getFocusWordForMention: { word, range in
focusWordAttributes = (word, range)
self.newCursorIndex = nil
},
updateCursorPosition: { newCursorIndex in
self.newCursorIndex = newCursorIndex
TextViewWrapper(attributedText: $post, textHeight: $textHeight, cursorIndex: newCursorIndex, getFocusWordForMention: { word, range in
focusWordAttributes = (word, range)
self.newCursorIndex = nil
}, updateCursorPosition: { newCursorIndex in
self.newCursorIndex = newCursorIndex
}, onCaretRectChange: { uiView in
// When the caret position changes, we change the `caretRect` in our state, so that our ghost caret will follow our caret
if let selectedStartRange = uiView.selectedTextRange?.start {
DispatchQueue.main.async {
caretRect = uiView.caretRect(for: selectedStartRange)
}
}
)
})
.environmentObject(tagModel)
.focused($focus)
.textInputAutocapitalization(.sentences)
@@ -250,33 +238,22 @@ struct PostView: View {
.frame(height: get_valid_text_height())
if post.string.isEmpty {
Text(self.placeholder_messages[self.current_placeholder_index])
Text(POST_PLACEHOLDER)
.padding(.top, 8)
.padding(.leading, 4)
.foregroundColor(Color(uiColor: .placeholderText))
.allowsHitTesting(false)
}
}
.onAppear {
// Schedule a timer to switch messages every 3 seconds
Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { timer in
withAnimation {
self.current_placeholder_index = (self.current_placeholder_index + 1) % self.placeholder_messages.count
}
}
}
}
var TopBar: some View {
VStack {
HStack(spacing: 5.0) {
Button(action: {
Button(NSLocalizedString("Cancel", comment: "Button to cancel out of posting a note.")) {
self.cancel()
}, label: {
Text(NSLocalizedString("Cancel", comment: "Button to cancel out of posting a note."))
.padding(10)
})
.buttonStyle(NeutralButtonStyle())
}
.foregroundColor(.primary)
if let error {
Text(error)
@@ -292,14 +269,9 @@ struct PostView: View {
ProgressView(value: progress, total: 1.0)
.progressViewStyle(.linear)
}
Divider()
.foregroundColor(DamusColors.neutral3)
.padding(.top, 5)
}
.frame(height: 30)
.padding()
.padding(.top, 15)
}
func handle_upload(media: MediaUpload) {
@@ -344,16 +316,14 @@ struct PostView: View {
func Editor(deviceSize: GeometryProxy) -> some View {
HStack(alignment: .top, spacing: 0) {
if(caretRect != CGRectNull) {
GhostCaret
}
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
ProfilePicView(pubkey: damus_state.pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
VStack(alignment: .leading) {
if let prompt_view {
prompt_view()
}
TextEntry
}
TextEntry
}
.id("post")
@@ -370,6 +340,25 @@ struct PostView: View {
}
}
// The GhostCaret is a vertical projection of the editor's caret that should sit beside the editor.
// The purpose of this view is create a reference point that we can scroll our ScrollView into
// This is necessary as a bridge to communicate between:
// - The UIKit-based UITextView (which has the caret position)
// - and the SwiftUI-based ScrollView/ScrollReader (where scrolling commands can only be done via the SwiftUI "ID" parameter
var GhostCaret: some View {
Rectangle()
.foregroundStyle(DEBUG_SHOW_GHOST_CARET_VIEW ? .cyan : .init(red: 0, green: 0, blue: 0, opacity: 0))
.frame(
width: DEBUG_SHOW_GHOST_CARET_VIEW ? caretRect.width : 0,
height: caretRect.height)
// Use padding to vertically align our ghost caret with our actual text caret.
// Note: Programmatic scrolling cannot be done with the `.position` modifier.
// Experiments revealed that the scroller ignores the position modifier.
.padding(.top, caretRect.origin.y)
.id(GHOST_CARET_VIEW_ID)
.disabled(true)
}
func fill_target_content(target: PostTarget) {
self.post = initialString()
self.tagModel.diff = post.string.count
@@ -389,7 +378,8 @@ struct PostView: View {
GeometryReader { (deviceSize: GeometryProxy) in
VStack(alignment: .leading, spacing: 0) {
let searching = get_searching_string(focusWordAttributes.0)
let searchingIsNil = searching == nil
TopBar
ScrollViewReader { scroller in
@@ -400,13 +390,19 @@ struct PostView: View {
}
Editor(deviceSize: deviceSize)
.padding(.top, 5)
}
}
.frame(maxHeight: searching == nil ? deviceSize.size.height : 70)
.onAppear {
scroll_to_event(scroller: scroller, id: "post", delay: 1.0, animate: true, anchor: .top)
}
// Note: The scroll commands below are specific because there seems to be quirk with ScrollReader where sending it to the exact same position twice resets its scroll position.
.onChange(of: caretRect.origin.y, perform: { newValue in
scroller.scrollTo(GHOST_CARET_VIEW_ID)
})
.onChange(of: searchingIsNil, perform: { newValue in
scroller.scrollTo(GHOST_CARET_VIEW_ID)
})
}
// This if-block observes @ for tagging
@@ -423,7 +419,6 @@ struct PostView: View {
}
}
}
.background(DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all))
.sheet(isPresented: $attach_media) {
ImagePicker(uploader: damus_state.settings.default_media_uploader, sourceType: .photoLibrary, pubkey: damus_state.pubkey, image_upload_confirm: $image_upload_confirm) { img in
self.mediaToUpload = .image(img)
@@ -627,7 +622,7 @@ func load_draft_for_post(drafts: Drafts, action: PostAction) -> DraftArtifacts?
}
func build_post(state: DamusState, post: NSMutableAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId]) -> NostrPost {
func build_post(post: NSMutableAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId]) -> NostrPost {
post.enumerateAttributes(in: NSRange(location: 0, length: post.length), options: []) { attributes, range, stop in
if let link = attributes[.link] as? String {
let normalized_link: String
@@ -650,7 +645,7 @@ func build_post(state: DamusState, post: NSMutableAttributedString, action: Post
let imagesString = uploadedMedias.map { $0.uploadedURL.absoluteString }.joined(separator: " ")
var tags = uploadedMedias.compactMap { $0.metadata?.to_tag() }
let img_meta_tags = uploadedMedias.compactMap { $0.metadata?.to_tag() }
if !imagesString.isEmpty {
content.append(" " + imagesString + " ")
@@ -658,12 +653,7 @@ func build_post(state: DamusState, post: NSMutableAttributedString, action: Post
if case .quoting(let ev) = action {
content.append(" nostr:" + bech32_note_id(ev.id))
if let quoted_ev = state.events.lookup(ev.id) {
tags.append(["p", quoted_ev.pubkey.hex()])
}
}
return NostrPost(content: content, references: references, kind: .text, tags: tags)
return NostrPost(content: content, references: references, kind: .text, tags: img_meta_tags)
}
+2 -10
View File
@@ -10,23 +10,15 @@ import SwiftUI
struct AboutView: View {
let state: DamusState
let about: String
let max_about_length: Int
let text_alignment: NSTextAlignment
let max_about_length = 280
@State var show_full_about: Bool = false
@State private var about_string: AttributedString? = nil
init(state: DamusState, about: String, max_about_length: Int? = nil, text_alignment: NSTextAlignment? = nil) {
self.state = state
self.about = about
self.max_about_length = max_about_length ?? 280
self.text_alignment = text_alignment ?? .natural
}
var body: some View {
Group {
if let about_string {
let truncated_about = show_full_about ? about_string : about_string.truncateOrNil(maxLength: max_about_length)
SelectableText(attributedString: truncated_about ?? about_string, textAlignment: self.text_alignment, size: .subheadline)
SelectableText(attributedString: truncated_about ?? about_string, size: .subheadline)
if truncated_about != nil {
if show_full_about {
+6 -8
View File
@@ -66,14 +66,12 @@ struct EventProfileName: View {
.font(.body.weight(.bold))
case .both(username: let username, displayName: let displayName):
HStack(spacing: 6) {
Text(verbatim: displayName)
.font(.body.weight(.bold))
Text(verbatim: "@\(username)")
.foregroundColor(.gray)
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
}
Text(verbatim: displayName)
.font(.body.weight(.bold))
Text(verbatim: username)
.foregroundColor(.gray)
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
}
/*
+2 -6
View File
@@ -21,19 +21,15 @@ struct MaybeAnonPfpView: View {
}
var body: some View {
ZStack {
Group {
if is_anon {
Image("question")
.resizable()
.font(.largeTitle)
.frame(width: size, height: size)
} else {
ProfilePicView(pubkey: pubkey, size: size, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true)
ProfilePicView(pubkey: pubkey, size: size, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation)
.onTapGesture {
show_profile_action_sheet_if_enabled(damus_state: state, pubkey: pubkey)
}
.onLongPressGesture(minimumDuration: 0.1) {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
}
}
+79 -1
View File
@@ -7,6 +7,84 @@
import SwiftUI
fileprivate struct KeyView: View {
let pubkey: Pubkey
@Environment(\.colorScheme) var colorScheme
@State private var isCopied = false
func keyColor() -> Color {
colorScheme == .light ? DamusColors.black : DamusColors.white
}
private func copyPubkey(_ pubkey: String) {
UIPasteboard.general.string = pubkey
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
withAnimation {
isCopied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation {
isCopied = false
}
}
}
}
func pubkey_context_menu(pubkey: Pubkey) -> some View {
return self.contextMenu {
Button {
UIPasteboard.general.string = pubkey.npub
} label: {
Label(NSLocalizedString("Copy Account ID", comment: "Context menu option for copying the ID of the account that created the note."), image: "copy2")
}
}
}
var body: some View {
let bech32 = pubkey.npub
HStack {
Text(verbatim: "\(abbrev_pubkey(bech32, amount: 16))")
.font(.footnote)
.foregroundColor(keyColor())
.padding(5)
.padding([.leading, .trailing], 5)
.background(RoundedRectangle(cornerRadius: 11).foregroundColor(DamusColors.adaptableGrey))
if isCopied {
HStack {
Image("check-circle")
.resizable()
.frame(width: 20, height: 20)
Text(NSLocalizedString("Copied", comment: "Label indicating that a user's key was copied."))
.font(.footnote)
.layoutPriority(1)
}
.foregroundColor(DamusColors.green)
} else {
HStack {
Button {
copyPubkey(bech32)
} label: {
Label {
Text("Public key", comment: "Label indicating that the text is a user's public account key.")
} icon: {
Image("copy2")
.resizable()
.contentShape(Rectangle())
.foregroundColor(.accentColor)
.frame(width: 20, height: 20)
}
.labelStyle(IconOnlyLabelStyle())
.symbolRenderingMode(.hierarchical)
}
}
}
}
}
}
struct ProfileNameView: View {
let pubkey: Pubkey
let damus: DamusState
@@ -38,7 +116,7 @@ struct ProfileNameView: View {
Spacer()
PubkeyView(pubkey: pubkey)
KeyView(pubkey: pubkey)
.pubkey_context_menu(pubkey: pubkey)
}
}
+18 -39
View File
@@ -69,59 +69,38 @@ struct ProfilePicView: View {
let highlight: Highlight
let profiles: Profiles
let disable_animation: Bool
let zappability_indicator: Bool
@State var picture: String?
init(pubkey: Pubkey, size: CGFloat, highlight: Highlight, profiles: Profiles, disable_animation: Bool, picture: String? = nil, show_zappability: Bool? = nil) {
init(pubkey: Pubkey, size: CGFloat, highlight: Highlight, profiles: Profiles, disable_animation: Bool, picture: String? = nil) {
self.pubkey = pubkey
self.profiles = profiles
self.size = size
self.highlight = highlight
self._picture = State(initialValue: picture)
self.disable_animation = disable_animation
self.zappability_indicator = show_zappability ?? false
}
func get_lnurl() -> String? {
return profiles.lookup_with_timestamp(pubkey).unsafeUnownedValue?.lnurl
}
var body: some View {
ZStack (alignment: Alignment(horizontal: .trailing, vertical: .bottom)) {
InnerProfilePicView(url: get_profile_url(picture: picture, pubkey: pubkey, profiles: profiles), fallbackUrl: URL(string: robohash(pubkey)), pubkey: pubkey, size: size, highlight: highlight, disable_animation: disable_animation)
.onReceive(handle_notify(.profile_updated)) { updated in
guard updated.pubkey == self.pubkey else {
return
InnerProfilePicView(url: get_profile_url(picture: picture, pubkey: pubkey, profiles: profiles), fallbackUrl: URL(string: robohash(pubkey)), pubkey: pubkey, size: size, highlight: highlight, disable_animation: disable_animation)
.onReceive(handle_notify(.profile_updated)) { updated in
guard updated.pubkey == self.pubkey else {
return
}
switch updated {
case .manual(_, let profile):
if let pic = profile.picture {
self.picture = pic
}
switch updated {
case .manual(_, let profile):
if let pic = profile.picture {
self.picture = pic
}
case .remote(pubkey: let pk):
let profile_txn = profiles.lookup(id: pk)
let profile = profile_txn.unsafeUnownedValue
if let pic = profile?.picture {
self.picture = pic
}
case .remote(pubkey: let pk):
let profile_txn = profiles.lookup(id: pk)
let profile = profile_txn.unsafeUnownedValue
if let pic = profile?.picture {
self.picture = pic
}
}
if self.zappability_indicator, let lnurl = self.get_lnurl(), lnurl != "" {
Image("zap.fill")
.resizable()
.frame(
width: size * 0.24,
height: size * 0.24
)
.padding(size * 0.04)
.foregroundColor(.white)
.background(Color.orange)
.clipShape(Circle())
}
}
}
}
@@ -135,8 +114,8 @@ func get_profile_url(picture: String?, pubkey: Pubkey, profiles: Profiles) -> UR
func make_preview_profiles(_ pubkey: Pubkey) -> Profiles {
let profiles = Profiles(ndb: test_damus_state.ndb)
//let picture = "http://cdn.jb55.com/img/red-me.jpg"
//let profile = Profile(name: "jb55", display_name: "William Casarin", about: "It's me", picture: picture, banner: "", website: "https://jb55.com", lud06: nil, lud16: nil, nip05: "jb55.com", damus_donation: nil)
let picture = "http://cdn.jb55.com/img/red-me.jpg"
let profile = Profile(name: "jb55", display_name: "William Casarin", about: "It's me", picture: picture, banner: "", website: "https://jb55.com", lud06: nil, lud16: nil, nip05: "jb55.com", damus_donation: nil)
//let ts_profile = TimestampedProfile(profile: profile, timestamp: 0, event: test_note)
//profiles.add(id: pubkey, profile: ts_profile)
return profiles
+35 -15
View File
@@ -108,12 +108,6 @@ struct ProfileView: View {
let progress = -(yOffset + navbarHeight) / 100
return Double(-yOffset > navbarHeight ? progress : 0)
}
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: damus_state)
filters.append(fstate.filter)
return ContentFilters(filters: filters).filter
}
var bannerSection: some View {
GeometryReader { proxy -> AnyView in
@@ -221,13 +215,39 @@ struct ProfileView: View {
.accentColor(DamusColors.white)
}
func lnButton(unownedProfile: Profile?, record: ProfileRecord?) -> some View {
return ProfileZapLinkView(unownedProfileRecord: record, profileModel: self.profile) { reactions_enabled, lud16, lnurl in
Image(reactions_enabled ? "zap.fill" : "zap")
.foregroundColor(reactions_enabled ? .orange : Color.primary)
func lnButton(lnurl: String, unownedProfile: Profile?, pubkey: Pubkey) -> some View {
let reactions = unownedProfile?.reactions ?? true
let button_img = reactions ? "zap.fill" : "zap"
let lud16 = unownedProfile?.lud16
return Button(action: { [lnurl] in
present_sheet(.zap(target: .profile(self.profile.pubkey), lnurl: lnurl))
}) {
Image(button_img)
.foregroundColor(button_img == "zap.fill" ? .orange : Color.primary)
.profile_button_style(scheme: colorScheme)
.cornerRadius(24)
.contextMenu { [lud16, reactions, lnurl] in
if reactions == false {
Text("OnlyZaps Enabled", comment: "Non-tappable text in context menu that shows up when the zap button on profile is long pressed to indicate that the user has enabled OnlyZaps, meaning that they would like to be only zapped and not accept reactions to their notes.")
}
if let lud16 {
Button {
UIPasteboard.general.string = lud16
} label: {
Label(lud16, image: "copy2")
}
} else {
Button {
UIPasteboard.general.string = lnurl
} label: {
Label(NSLocalizedString("Copy LNURL", comment: "Context menu option for copying a user's Lightning URL."), image: "copy")
}
}
}
}
.cornerRadius(24)
}
var dmButton: some View {
@@ -257,7 +277,7 @@ struct ProfileView: View {
let lnurl = record.lnurl,
lnurl != ""
{
lnButton(unownedProfile: profile, record: record)
lnButton(lnurl: lnurl, unownedProfile: profile, pubkey: pubkey)
}
dmButton
@@ -302,7 +322,7 @@ struct ProfileView: View {
is_zoomed.toggle()
}
.fullScreenCover(isPresented: $is_zoomed) {
ProfilePicImageView(pubkey: profile.pubkey, profiles: damus_state.profiles, settings: damus_state.settings)
ProfilePicImageView(pubkey: profile.pubkey, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
}
Spacer()
@@ -438,10 +458,10 @@ struct ProfileView: View {
.background(colorScheme == .dark ? Color.black : Color.white)
if filter_state == FilterState.posts {
InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.posts))
InnerTimelineView(events: profile.events, damus: damus_state, filter: FilterState.posts.filter)
}
if filter_state == FilterState.posts_and_replies {
InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.posts_and_replies))
InnerTimelineView(events: profile.events, damus: damus_state, filter: FilterState.posts_and_replies.filter)
}
}
.padding(.horizontal, Theme.safeAreaInsets?.left)
-348
View File
@@ -1,348 +0,0 @@
//
// ProfileActionSheetView.swift
// damus
//
// Created by Daniel DAquino on 2023-10-20.
//
import SwiftUI
struct ProfileActionSheetView: View {
let damus_state: DamusState
let pfp_size: CGFloat = 90.0
@StateObject var profile: ProfileModel
@StateObject var zap_button_model: ZapButtonModel = ZapButtonModel()
@State private var sheetHeight: CGFloat = .zero
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@Environment(\.presentationMode) var presentationMode
init(damus_state: DamusState, pubkey: Pubkey) {
self.damus_state = damus_state
self._profile = StateObject(wrappedValue: ProfileModel(pubkey: pubkey, damus: damus_state))
}
func imageBorderColor() -> Color {
colorScheme == .light ? DamusColors.white : DamusColors.black
}
func profile_data() -> ProfileRecord? {
let profile_txn = damus_state.profiles.lookup_with_timestamp(profile.pubkey)
return profile_txn.unsafeUnownedValue
}
func get_profile() -> Profile? {
return self.profile_data()?.profile
}
var followButton: some View {
return ProfileActionSheetFollowButton(
target: .pubkey(self.profile.pubkey),
follows_you: self.profile.follows(pubkey: damus_state.pubkey),
follow_state: damus_state.contacts.follow_state(profile.pubkey)
)
}
var dmButton: some View {
let dm_model = damus_state.dms.lookup_or_create(profile.pubkey)
return VStack(alignment: .center, spacing: 10) {
Button(
action: {
damus_state.nav.push(route: Route.DMChat(dms: dm_model))
dismiss()
},
label: {
Image("messages")
.profile_button_style(scheme: colorScheme)
}
)
.buttonStyle(NeutralCircleButtonStyle())
Text(NSLocalizedString("Message", comment: "Button label that allows the user to start a direct message conversation with the user shown on-screen"))
.foregroundStyle(.secondary)
.font(.caption)
}
}
var zapButton: some View {
if let lnurl = self.profile_data()?.lnurl, lnurl != "" {
return AnyView(ProfileActionSheetZapButton(damus_state: damus_state, profile: profile, lnurl: lnurl))
}
else {
return AnyView(EmptyView())
}
}
var profileName: some View {
let display_name = Profile.displayName(profile: self.get_profile(), pubkey: self.profile.pubkey).displayName
return HStack(alignment: .center, spacing: 10) {
Text(display_name)
.font(.title)
}
}
var body: some View {
VStack(alignment: .center) {
ProfilePicView(pubkey: profile.pubkey, size: pfp_size, highlight: .custom(imageBorderColor(), 4.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
if let url = self.profile_data()?.profile?.website_url {
WebsiteLink(url: url, style: .accent)
.padding(.top, -15)
}
profileName
PubkeyView(pubkey: profile.pubkey)
if let about = self.profile_data()?.profile?.about {
AboutView(state: damus_state, about: about, max_about_length: 140, text_alignment: .center)
.padding(.top)
}
HStack(spacing: 20) {
self.followButton
self.zapButton
self.dmButton
}
.padding()
Button(
action: {
damus_state.nav.push(route: Route.ProfileByKey(pubkey: profile.pubkey))
dismiss()
},
label: {
HStack {
Spacer()
Text(NSLocalizedString("View full profile", comment: "A button label that allows the user to see the full profile of the profile they are previewing"))
Image(systemName: "arrow.up.right")
Spacer()
}
}
)
.buttonStyle(NeutralCircleButtonStyle())
}
.padding()
.padding(.top, 20)
.overlay {
GeometryReader { geometry in
Color.clear.preference(key: InnerHeightPreferenceKey.self, value: geometry.size.height)
}
}
.onPreferenceChange(InnerHeightPreferenceKey.self) { newHeight in
sheetHeight = newHeight
}
.presentationDetents([.height(sheetHeight)])
}
}
fileprivate struct ProfileActionSheetFollowButton: View {
@Environment(\.colorScheme) var colorScheme
let target: FollowTarget
let follows_you: Bool
@State var follow_state: FollowState
var body: some View {
VStack(alignment: .center, spacing: 10) {
Button(
action: {
follow_state = perform_follow_btn_action(follow_state, target: target)
},
label: {
switch follow_state {
case .unfollows:
Image("user-add-down")
.foregroundColor(Color.primary)
.profile_button_style(scheme: colorScheme)
default:
Image("user-added")
.foregroundColor(Color.green)
.profile_button_style(scheme: colorScheme)
}
}
)
.buttonStyle(NeutralCircleButtonStyle())
Text(verbatim: "\(follow_btn_txt(follow_state, follows_you: follows_you))")
.foregroundStyle(.secondary)
.font(.caption)
}
.onReceive(handle_notify(.followed)) { follow in
guard case .pubkey(let pk) = follow,
pk == target.pubkey else { return }
self.follow_state = .follows
}
.onReceive(handle_notify(.unfollowed)) { unfollow in
guard case .pubkey(let pk) = unfollow,
pk == target.pubkey else { return }
self.follow_state = .unfollows
}
}
}
fileprivate struct ProfileActionSheetZapButton: View {
enum ZappingState: Equatable {
case not_zapped
case zapping
case zap_success
case zap_failure(error: ZappingError)
func error_message() -> String? {
switch self {
case .zap_failure(let error):
return error.humanReadableMessage()
default:
return nil
}
}
}
let damus_state: DamusState
@StateObject var profile: ProfileModel
let lnurl: String
@State var zap_state: ZappingState = .not_zapped
@State var show_error_alert: Bool = false
@Environment(\.colorScheme) var colorScheme
func receive_zap(zap_ev: ZappingEvent) {
print("Received zap event")
guard zap_ev.target == ZapTarget.profile(self.profile.pubkey) else {
return
}
switch zap_ev.type {
case .failed(let err):
zap_state = .zap_failure(error: err)
show_error_alert = true
break
case .got_zap_invoice(let inv):
if damus_state.settings.show_wallet_selector {
present_sheet(.select_wallet(invoice: inv))
} else {
let wallet = damus_state.settings.default_wallet.model
do {
try open_with_wallet(wallet: wallet, invoice: inv)
}
catch {
present_sheet(.select_wallet(invoice: inv))
}
}
break
case .sent_from_nwc:
zap_state = .zap_success
break
}
}
var button_label: String {
switch zap_state {
case .not_zapped:
return NSLocalizedString("Zap", comment: "Button label that allows the user to zap (i.e. send a Bitcoin tip via the lightning network) the user shown on-screen")
case .zapping:
return NSLocalizedString("Zapping", comment: "Button label indicating that a zap action is in progress (i.e. the user is currently sending a Bitcoin tip via the lightning network to the user shown on-screen) ")
case .zap_success:
return NSLocalizedString("Zapped!", comment: "Button label indicating that a zap action was successful (i.e. the user is successfully sent a Bitcoin tip via the lightning network to the user shown on-screen) ")
case .zap_failure(_):
return NSLocalizedString("Zap failed", comment: "Button label indicating that a zap action was unsuccessful (i.e. the user was unable to send a Bitcoin tip via the lightning network to the user shown on-screen) ")
}
}
var body: some View {
VStack(alignment: .center, spacing: 10) {
Button(
action: {
send_zap(damus_state: damus_state, target: .profile(self.profile.pubkey), lnurl: lnurl, is_custom: false, comment: nil, amount_sats: nil, zap_type: damus_state.settings.default_zap_type)
zap_state = .zapping
},
label: {
switch zap_state {
case .not_zapped:
Image("zap")
.foregroundColor(Color.primary)
.profile_button_style(scheme: colorScheme)
case .zapping:
ProgressView()
.foregroundColor(Color.primary)
.profile_button_style(scheme: colorScheme)
case .zap_success:
Image("checkmark")
.foregroundColor(Color.green)
.profile_button_style(scheme: colorScheme)
case .zap_failure:
Image("close")
.foregroundColor(Color.red)
.profile_button_style(scheme: colorScheme)
}
}
)
.disabled({
switch zap_state {
case .not_zapped:
return false
default:
return true
}
}())
.buttonStyle(NeutralCircleButtonStyle())
Text(button_label)
.foregroundStyle(.secondary)
.font(.caption)
}
.onReceive(handle_notify(.zapping)) { zap_ev in
receive_zap(zap_ev: zap_ev)
}
.simultaneousGesture(LongPressGesture().onEnded {_ in
present_sheet(.zap(target: .profile(self.profile.pubkey), lnurl: lnurl))
})
.alert(isPresented: $show_error_alert) {
Alert(
title: Text(NSLocalizedString("Zap failed", comment: "Title of an alert indicating that a zap action failed")),
message: Text(zap_state.error_message() ?? ""),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Button label to dismiss an error dialog")))
)
}
.onChange(of: zap_state) { new_zap_state in
switch new_zap_state {
case .zap_success, .zap_failure:
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
withAnimation {
zap_state = .not_zapped
}
}
break
default:
break
}
}
}
}
struct InnerHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
func show_profile_action_sheet_if_enabled(damus_state: DamusState, pubkey: Pubkey) {
if damus_state.settings.show_profile_action_sheet_on_pfp_click {
notify(.present_sheet(Sheets.profile_action(pubkey)))
}
else {
damus_state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
}
}
#Preview {
ProfileActionSheetView(damus_state: test_damus_state, pubkey: test_pubkey)
}
+2 -81
View File
@@ -7,85 +7,6 @@
import SwiftUI
struct PubkeyView: View {
let pubkey: Pubkey
@Environment(\.colorScheme) var colorScheme
@State private var isCopied = false
func keyColor() -> Color {
colorScheme == .light ? DamusColors.black : DamusColors.white
}
private func copyPubkey(_ pubkey: String) {
UIPasteboard.general.string = pubkey
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
withAnimation {
isCopied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation {
isCopied = false
}
}
}
}
func pubkey_context_menu(pubkey: Pubkey) -> some View {
return self.contextMenu {
Button {
UIPasteboard.general.string = pubkey.npub
} label: {
Label(NSLocalizedString("Copy Account ID", comment: "Context menu option for copying the ID of the account that created the note."), image: "copy2")
}
}
}
var body: some View {
let bech32 = pubkey.npub
HStack {
Text(verbatim: "\(abbrev_pubkey(bech32, amount: 16))")
.font(.footnote)
.foregroundColor(keyColor())
.padding(5)
.padding([.leading], 5)
HStack {
if isCopied {
Image("check-circle")
.resizable()
.foregroundColor(DamusColors.green)
.frame(width: 20, height: 20)
Text(NSLocalizedString("Copied", comment: "Label indicating that a user's key was copied."))
.font(.footnote)
.layoutPriority(1)
.foregroundColor(DamusColors.green)
} else {
Button {
copyPubkey(bech32)
} label: {
Label {
Text("Public key", comment: "Label indicating that the text is a user's public account key.")
} icon: {
Image("copy2")
.resizable()
.contentShape(Rectangle())
.foregroundColor(colorScheme == .light ? DamusColors.darkGrey : DamusColors.lightGrey)
.frame(width: 20, height: 20)
}
.labelStyle(IconOnlyLabelStyle())
.symbolRenderingMode(.hierarchical)
}
}
}
.padding([.trailing], 10)
}
.background(RoundedRectangle(cornerRadius: 11).foregroundColor(colorScheme == .light ? DamusColors.adaptableGrey : DamusColors.neutral1))
}
}
#Preview {
PubkeyView(pubkey: test_pubkey)
func abbrev_pubkey(_ pubkey: String, amount: Int = 8) -> String {
return pubkey.prefix(amount) + ":" + pubkey.suffix(amount)
}

Some files were not shown because too many files have changed in this diff Show More