Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6858d7e72f
|
@@ -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
@@ -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 D’Aquino)
|
||||
- Add follow button to profile action sheet (Daniel D’Aquino)
|
||||
- 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 D’Aquino)
|
||||
- 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 D’Aquino)
|
||||
|
||||
|
||||
### 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 D’Aquino)
|
||||
|
||||
|
||||
[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 D’Aquino)
|
||||
- Add suggested hashtags to universe view (Daniel D’Aquino)
|
||||
- Suggest first post during onboarding (Daniel D’Aquino)
|
||||
- Add expiry date for images in cache to be auto-deleted after a preset time to save space on storage (Daniel D’Aquino)
|
||||
- Add QR scan nsec logins. (Jericho Hasselbush)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved status view design (ericholguin)
|
||||
- Improve clear cache functionality (Daniel D’Aquino)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reduce size of event menu hitbox (William Casarin)
|
||||
- Do not show DMs from muted users (Daniel D’Aquino)
|
||||
- Add more spacing between display name and username, and prefix username with `@` character (Daniel D’Aquino)
|
||||
- Broadcast quoted notes when posting a note with quotes (Daniel D’Aquino)
|
||||
|
||||
|
||||
[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 D’Aquino)
|
||||
|
||||
### Changed
|
||||
|
||||
- Damus icon now opens sidebar (Daniel D’Aquino)
|
||||
|
||||
### 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 D’Aquino)
|
||||
- Fix profiles not updating (William Casarin)
|
||||
- Fix issue where relays with trailing slashes cannot be removed (#1531) (Daniel D’Aquino)
|
||||
|
||||
|
||||
[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 D’Aquino)
|
||||
- Show muted thread replies at the bottom of the thread view (#1522) (Daniel D’Aquino)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix situations where the note composer cursor gets stuck in one place after tagging a user (Daniel D’Aquino)
|
||||
- Fix some note composer issues, such as when copying/pasting larger text, and make the post composer more robust. (Daniel D’Aquino)
|
||||
- Apply filters to hashtag search timeline view (Daniel D’Aquino)
|
||||
- Hide quoted or reposted notes from people whom the user has muted. (#1216) (Daniel D’Aquino)
|
||||
- Fix profile not updating (William Casarin)
|
||||
- Fix small graphical toolbar bug when scrolling profiles (Daniel D’Aquino)
|
||||
- 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>
|
||||
@@ -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 D’Aquino 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 D’Aquino 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 D’Aquino 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
+19
-495
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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
//
|
||||
// DamusCacheManager.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -145,3 +145,4 @@ func event_is_reply(_ refs: [EventRef]) -> Bool {
|
||||
return evref.is_reply != nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
//
|
||||
// NostrEvent+.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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: [])
|
||||
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
//
|
||||
// RelayURL.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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 }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
//
|
||||
// MigratedTypes.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2023-11-17.
|
||||
//
|
||||
|
||||
typealias NostrEvent = NdbNote
|
||||
typealias TagElem = NdbTagElem
|
||||
typealias Tag = TagSequence
|
||||
typealias Tags = TagsSequence
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
//
|
||||
// EventLoaderView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
//
|
||||
// MentionView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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))
|
||||
}
|
||||
}
|
||||
+34
-45
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 🤙. What’s 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! I’m 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! I’m a graphic designer during the day and coder at night, but I’m 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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
//
|
||||
// ProfileActionSheetView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user