Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e456ac864d
|
@@ -1,36 +0,0 @@
|
||||
## Summary
|
||||
|
||||
_[Please provide a summary of the changes in this PR.]_
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read (or I am familiar with) the [Contribution Guidelines](../docs/CONTRIBUTING.md)
|
||||
- [ ] I have tested the changes in this PR
|
||||
- [ ] My PR is either small, or I have split it into smaller logical commits that are easier to review
|
||||
- [ ] I have added the signoff line to all my commits. See [Signing off your work](../docs/CONTRIBUTING.md#sign-your-work---the-developers-certificate-of-origin)
|
||||
- [ ] I have added appropriate changelog entries for the changes in this PR. See [Adding changelog entries](../docs/CONTRIBUTING.md#add-changelog-changed-changelog-fixed-etc)
|
||||
- [ ] I do not need to add a changelog entry. Reason: _[Please provide a reason]_
|
||||
- [ ] I have added appropriate `Closes:` or `Fixes:` tags in the commit messages wherever applicable, or made sure those are not needed. See [Submitting patches](https://github.com/damus-io/damus/blob/master/docs/CONTRIBUTING.md#submitting-patches)
|
||||
|
||||
## Test report
|
||||
|
||||
_Please provide a test report for the changes in this PR. You can use the template below, but feel free to modify it as needed._
|
||||
|
||||
**Device:** _[Please specify the device you used for testing]_
|
||||
|
||||
**iOS:** _[Please specify the iOS version you used for testing]_
|
||||
|
||||
**Damus:** _[Please specify the Damus version or commit hash you used for testing]_
|
||||
|
||||
**Setup:** _[Please provide a brief description of the setup you used for testing, if applicable]_
|
||||
|
||||
**Steps:** _[Please provide a list of steps you took to test the changes in this PR]_
|
||||
|
||||
**Results:**
|
||||
- [ ] PASS
|
||||
- [ ] Partial PASS
|
||||
- Details: _[Please provide details of the partial pass]_
|
||||
|
||||
## Other notes
|
||||
|
||||
_[Please provide any other information that you think is relevant to this PR.]_
|
||||
-104
@@ -1,107 +1,3 @@
|
||||
## [v1.11(10)](https://github.com/damus-io/damus/releases/tag/v1.11-10) - 2024-11-18
|
||||
|
||||
### Added
|
||||
|
||||
- Add Damus Share Feature (Swift)
|
||||
- Added new easy to use video controls for full screen video (Daniel D’Aquino)
|
||||
- Add Edit, Share, and Tap-gesture in Profile pic image viewer (Swift Coder)
|
||||
- Disappearing header, tabbar, and post button on scroll (ericholguin)
|
||||
- Add Apple translation popovers for notes for iOS 17.4+ and macOS 14.4+ (Terry Yiu)
|
||||
- Added NDB search functionality to the universe view (ericholguin)
|
||||
- Added mute button to ProfileActionSheet (chungwwei)
|
||||
- Added mute action to selected text menu (ericholguin)
|
||||
- Added support for pasting images from the clipboard to the post composer (Swift Coder)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved image carousel image fill behavior (Daniel D’Aquino)
|
||||
- Improved video syncing and bandwidth usage when switching between timeline video and full screen mode (Daniel D’Aquino)
|
||||
- Swipe to dismiss on full screen carousel now shows an opacity effect for improved UX (Daniel D’Aquino)
|
||||
- Removed event contents from full screen media carousel for cleaner view (Daniel D’Aquino)
|
||||
- Add share button for images on full screen image carousel view (Swift)
|
||||
- Changed boldness of font in side menu labels. (ericholguin)
|
||||
- Changed search notes button with searched keyword (ericholguin)
|
||||
- Changed opacity of tabbar and post button (ericholguin)
|
||||
- Allow multiple images to be uploaded at the same time (swiftcoder) (William Casarin)
|
||||
- Changed side menu design (ericholguin)
|
||||
- Truncate fulltext search results (William Casarin)
|
||||
- Expanded profile search results to 128 (William Casarin)
|
||||
- Expand nostrdb text search results to 128 items (William Casarin)
|
||||
- Use LazyVStack in text search results (William Casarin)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed missing tab bar on navigation (Swift Coder)
|
||||
- Fixed some issues where QR code would not work, and improved UX (Daniel D’Aquino)
|
||||
- Fixed iOS 18 gesture issues that would take user to the thread view when clicking on a video or unmuting it (Daniel D’Aquino)
|
||||
- Fixed several issues that would cause video to automatically play or pause incorrectly (Daniel D’Aquino)
|
||||
- Fixed issue where full screen video would disappear when going to landscape mode (Daniel D’Aquino)
|
||||
- Fixed portrait video size on full screen carousel (Daniel D’Aquino)
|
||||
- Fix avatar image on qrcode view (Swift Coder)
|
||||
- Fix banner image upload (Swift Coder)
|
||||
- Fix dismiss button visibility (Swift Coder)
|
||||
- Fix quote repost counting (William Casarin)
|
||||
- Fixed overlapping text in Universe View (ericholguin)
|
||||
- Fixed localization issues and exported strings (Terry Yiu)
|
||||
- Fix sensitive long-press gesture on event chat bubble in iOS 18 (Daniel D’Aquino)
|
||||
- Fixed bottom padding for tabbar (ericholguin)
|
||||
- Fixed localization build failures (Terry Yiu)
|
||||
- Fixed back nav button placement in profile edit view (ericholguin)
|
||||
- Friend profiles will now more likely show up in profile search (William Casarin)
|
||||
- Fix broken QR code scanner and fix landscape mode (Terry Yiu)
|
||||
|
||||
[1.11(10)](https://github.com/damus-io/damus/releases/tag/v1.11-10): https://github.com/damus-io/damus/releases/tag/v1.11-10
|
||||
|
||||
## [1.10.1] - 2024-09-22
|
||||
|
||||
### Added
|
||||
|
||||
- Push notification support (Daniel D’Aquino)
|
||||
- Added profile edit safe guards (Eric Holguin)
|
||||
- Tor relay icon (ericholguin)
|
||||
- Add highlighter for web pages (Daniel D’Aquino)
|
||||
- Add support for adding comments when creating a highlight (Daniel D’Aquino)
|
||||
- Add support for rendering highlights with comments (Daniel D’Aquino)
|
||||
- Ability to create highlights (ericholguin)
|
||||
- Highlights (NIP-84) (ericholguin)
|
||||
- Revamp emoji picker to be less error-prone and add search, frequently used, and multiple skin tone support capabilities (Terry Yiu)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Improve notification view filtering UX (Daniel D’Aquino)
|
||||
- Improve visibility of friends filter button (Daniel D’Aquino)
|
||||
- Changed the default banner from ostriches to damoose (Eric Holguin)
|
||||
- Changed image and banner url text fields to new sheet view (Eric Holguin)
|
||||
- Onboarding design (ericholguin)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix items that became unclickable on iOS 18 (Daniel D’Aquino)
|
||||
- Fix many reconnection issues (William Casarin)
|
||||
- Fixed issue where theme would be changed to black and can't be switched back on iOS 18 (cr0bar)
|
||||
- Fixed some scenarios where the contact list would never be saved locally and cause issues when switching relays. (Daniel D’Aquino)
|
||||
- Fix albyhub zaps not appearing (William Casarin)
|
||||
- Fix inadvertent escape from mention suggestion menu when typing a space character (Daniel D’Aquino)
|
||||
- Fix profile view toolbar alignment bug in iOS 18 (Terry Yiu)
|
||||
- Create Account model now uses correct metadata (ericholguin)
|
||||
- Restore localization for custom tabs (William Casarin)
|
||||
- Fix iOS 18 reflection runtime error for custom picker (William Casarin)
|
||||
|
||||
|
||||
[1.10.1]: https://github.com/damus-io/damus/releases/tag/v1.10.1
|
||||
|
||||
|
||||
## [1.9.1 (4)] - 2024-08-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix crash when viewing notes with invalid image dimension metadata (Daniel D’Aquino)
|
||||
|
||||
[1.9.1 (4)]: https://github.com/damus-io/damus/releases/tag/v1.9.1-4
|
||||
|
||||
|
||||
## [1.9 (14)] - 2024-07-14
|
||||
|
||||
### Added
|
||||
|
||||
+64
-1250
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,6 @@
|
||||
{
|
||||
"originHash" : "534c8e58993919d5ead25ceb4788c8e492c86bc2cf5833dc651ae60a0f30169c",
|
||||
"originHash" : "babaf4d5748afecf49bbb702530d8e9576460692f478b0a50ee43195dd4440e2",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "codescanner",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/twostraws/CodeScanner.git",
|
||||
"state" : {
|
||||
"revision" : "9fa582f4b36c69c2a55bff5fb3377eb170ae273c"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "emojikit",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -100,9 +92,10 @@
|
||||
{
|
||||
"identity" : "swipeactions",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/damus-io/SwipeActions.git",
|
||||
"location" : "https://github.com/aheze/SwipeActions",
|
||||
"state" : {
|
||||
"revision" : "33d99756c3112e1a07c1732e3cddc5ad5bd0c5f4"
|
||||
"revision" : "41e6f6dce02d8cfa164f8c5461a41340850ca3ab",
|
||||
"version" : "1.1.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
skipped = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4CE6DEFC27F7A08200C66700"
|
||||
|
||||
@@ -46,6 +46,7 @@ struct CustomPicker<SelectionValue: Hashable>: View {
|
||||
.accentColor(tag == selection ? textColor() : .gray)
|
||||
}
|
||||
}
|
||||
.background(Color(UIColor.systemBackground))
|
||||
}
|
||||
|
||||
func textColor() -> Color {
|
||||
|
||||
@@ -20,7 +20,6 @@ struct DamusBackground: View {
|
||||
.resizable()
|
||||
.frame(maxWidth: .infinity, maxHeight: maxHeight, alignment: .center)
|
||||
.ignoresSafeArea()
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
import Combine
|
||||
|
||||
// TODO: all this ShareSheet complexity can be replaced with ShareLink once we update to iOS 16
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
@@ -96,203 +95,64 @@ enum ImageShape {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `CarouselModel` helps `ImageCarousel` with some state management logic, keeping track of media sizes, and the ideal display size
|
||||
///
|
||||
/// This model is necessary because the state management logic required to keep track of media sizes for each one of the carousel items,
|
||||
/// and the ideal display size at each moment is not a trivial task.
|
||||
///
|
||||
/// The rules for the media fill are as follows:
|
||||
/// 1. The media item should generally have a width that completely fills the width of its parent view
|
||||
/// 2. The height of the carousel should be adjusted accordingly
|
||||
/// 3. The only exception to rules 1 and 2 is when the total height would be 20% larger than the height of the device
|
||||
/// 4. If none of the above can be computed (e.g. due to missing information), default to a reasonable height, where the media item will fit into.
|
||||
///
|
||||
/// ## Usage notes
|
||||
///
|
||||
/// The view is has the following state management responsibilities:
|
||||
/// 1. Watching the size of the images (via the `.observe_image_size` modifier)
|
||||
/// 2. Notifying this class of geometry reader changes, by setting `geo_size`
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// This class is organized in a way to reduce stateful behavior and the transiency bugs it can cause.
|
||||
///
|
||||
/// This is accomplished through the following pattern:
|
||||
/// 1. The `current_item_fill` is a published property so that any updates instantly re-render the view
|
||||
/// 2. However, `current_item_fill` has a mathematical dependency on other members of this class
|
||||
/// 3. Therefore, the members on which the fill property depends on all have `didSet` observers that will cause the `current_item_fill` to be recalculated and published.
|
||||
///
|
||||
/// This pattern helps ensure that the state is always consistent and that the view is always up-to-date.
|
||||
///
|
||||
/// This class is marked as `@MainActor` since most of its properties are published and should be accessed from the main thread to avoid inconsistent SwiftUI state during renders
|
||||
@MainActor
|
||||
class CarouselModel: ObservableObject {
|
||||
// MARK: Immutable object attributes
|
||||
// These are some attributes that are not expected to change throughout the lifecycle of this object
|
||||
// These should not be modified after initialization to avoid state inconsistency
|
||||
|
||||
/// The state of the app
|
||||
let damus_state: DamusState
|
||||
/// All urls in the carousel
|
||||
let urls: [MediaUrl]
|
||||
/// The default fill height for the carousel, if we cannot calculate a more appropriate height
|
||||
/// **Usage note:** Default to this when `current_item_fill` is nil
|
||||
let default_fill_height: CGFloat
|
||||
/// The maximum height for any carousel item
|
||||
let max_height: CGFloat
|
||||
|
||||
|
||||
// MARK: Miscellaneous
|
||||
|
||||
/// Holds items that allows us to cancel video size observers during de-initialization
|
||||
private var all_cancellables: [AnyCancellable] = []
|
||||
|
||||
|
||||
// MARK: State management properties
|
||||
/// Properties relevant to state management.
|
||||
/// These should be made into computed/functional properties when possible to avoid stateful behavior
|
||||
/// When that is not possible (e.g. when dealing with an observed published property), establish its mathematical dependencies,
|
||||
/// and use `didSet` observers to ensure that the state is always re-computed when necessary.
|
||||
var current_url: URL?
|
||||
var fillHeight: CGFloat
|
||||
var maxHeight: CGFloat
|
||||
var firstImageHeight: CGFloat?
|
||||
|
||||
/// Stores information about the size of each media item in `urls`.
|
||||
/// **Usage note:** The view is responsible for setting the size of image urls
|
||||
var media_size_information: [URL: CGSize] {
|
||||
didSet {
|
||||
guard let current_url else { return }
|
||||
// Upon updating information, update the carousel fill size if the size for the current url has changed
|
||||
if oldValue[current_url] != media_size_information[current_url] {
|
||||
self.refresh_current_item_fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Stores information about the geometry reader
|
||||
/// **Usage note:** The view is responsible for setting this value
|
||||
var geo_size: CGSize? {
|
||||
didSet { self.refresh_current_item_fill() }
|
||||
}
|
||||
/// The index of the currently selected item
|
||||
/// **Usage note:** The view is responsible for setting this value
|
||||
@Published var selectedIndex: Int {
|
||||
didSet { self.refresh_current_item_fill() }
|
||||
}
|
||||
/// The current fill for the media item.
|
||||
/// **Usage note:** This property is read-only and should not be set directly. Update `selectedIndex` to update the current item being viewed.
|
||||
var current_url: URL? {
|
||||
return urls[safe: selectedIndex]?.url
|
||||
}
|
||||
/// Holds the ideal fill dimensions for the current item.
|
||||
/// **Usage note:** This property is automatically updated when other properties are set, and should not be set directly
|
||||
/// **Implementation note:** This property is mathematically dependent on geo_size, media_size_information, and `selectedIndex`,
|
||||
/// and is automatically updated upon changes to these properties.
|
||||
@Published private(set) var current_item_fill: ImageFill?
|
||||
|
||||
|
||||
// MARK: Initialization and de-initialization
|
||||
@Published var open_sheet: Bool
|
||||
@Published var selectedIndex: Int
|
||||
@Published var video_size: CGSize?
|
||||
@Published var image_fill: ImageFill?
|
||||
|
||||
/// Initializes the `CarouselModel` with the given `DamusState` and `MediaUrl` array
|
||||
init(damus_state: DamusState, urls: [MediaUrl]) {
|
||||
// Immutable object attributes
|
||||
self.damus_state = damus_state
|
||||
self.urls = urls
|
||||
self.default_fill_height = 350
|
||||
self.max_height = UIScreen.main.bounds.height * 1.2 // 1.2
|
||||
|
||||
// State management properties
|
||||
init(image_fill: ImageFill?) {
|
||||
self.current_url = nil
|
||||
self.fillHeight = 350
|
||||
self.maxHeight = UIScreen.main.bounds.height * 1.2 // 1.2
|
||||
self.firstImageHeight = nil
|
||||
self.open_sheet = false
|
||||
self.selectedIndex = 0
|
||||
self.current_item_fill = nil
|
||||
self.geo_size = nil
|
||||
self.media_size_information = [:]
|
||||
|
||||
// Setup the rest of the state management logic
|
||||
self.observe_video_sizes()
|
||||
Task {
|
||||
self.refresh_current_item_fill()
|
||||
}
|
||||
}
|
||||
|
||||
/// This private function observes the video sizes for all videos
|
||||
private func observe_video_sizes() {
|
||||
for media_url in urls {
|
||||
switch media_url {
|
||||
case .video(let url):
|
||||
let video_player = damus_state.video.get_player(for: url)
|
||||
if let video_size = video_player.video_size {
|
||||
self.media_size_information[url] = video_size // Set the initial size if available
|
||||
}
|
||||
let observer_cancellable = video_player.$video_size.sink(receiveValue: { new_size in
|
||||
self.media_size_information[url] = new_size // Update the size when it changes
|
||||
})
|
||||
all_cancellables.append(observer_cancellable) // Store the cancellable to cancel it later
|
||||
case .image(_):
|
||||
break; // Observing an image size needs to be done on the view directly, through the `.observe_image_size` modifier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
for cancellable_item in all_cancellables {
|
||||
cancellable_item.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: State management and logic
|
||||
|
||||
/// This function refreshes the current item fill based on the current state of the model
|
||||
/// **Usage note:** This is private, do not call this directly from outside the class.
|
||||
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the fill
|
||||
private func refresh_current_item_fill() {
|
||||
if let current_url,
|
||||
let item_size = self.media_size_information[current_url],
|
||||
let geo_size {
|
||||
self.current_item_fill = ImageFill.calculate_image_fill(
|
||||
geo_size: geo_size,
|
||||
img_size: item_size,
|
||||
maxHeight: self.max_height,
|
||||
fillHeight: self.default_fill_height
|
||||
)
|
||||
}
|
||||
else {
|
||||
self.current_item_fill = nil // Not enough information to compute the proper fill. Default to nil
|
||||
}
|
||||
self.video_size = nil
|
||||
self.image_fill = image_fill
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Carousel
|
||||
|
||||
/// A carousel that displays images and videos
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - State management logic is mostly handled by `CarouselModel`, as it is complex, and becomes difficult to manage in a view
|
||||
///
|
||||
@MainActor
|
||||
struct ImageCarousel<Content: View>: View {
|
||||
/// The event id of the note that this carousel is displaying
|
||||
var urls: [MediaUrl]
|
||||
|
||||
let evid: NoteId
|
||||
/// The model that holds information and state of this carousel
|
||||
/// This is observed to update the view when the model changes
|
||||
|
||||
let state: DamusState
|
||||
@ObservedObject var model: CarouselModel
|
||||
let content: ((_ dismiss: @escaping (() -> Void)) -> Content)?
|
||||
|
||||
init(state: DamusState, evid: NoteId, urls: [MediaUrl]) {
|
||||
self.urls = urls
|
||||
self.evid = evid
|
||||
self._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls))
|
||||
self.state = state
|
||||
let media_model = state.events.get_cache_data(evid).media_metadata_model
|
||||
self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill))
|
||||
self.content = nil
|
||||
}
|
||||
|
||||
init(state: DamusState, evid: NoteId, urls: [MediaUrl], @ViewBuilder content: @escaping (_ dismiss: @escaping (() -> Void)) -> Content) {
|
||||
self.urls = urls
|
||||
self.evid = evid
|
||||
self._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls))
|
||||
self.state = state
|
||||
let media_model = state.events.get_cache_data(evid).media_metadata_model
|
||||
self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill))
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var filling: Bool {
|
||||
model.current_item_fill?.filling == true
|
||||
model.image_fill?.filling == true
|
||||
}
|
||||
|
||||
var height: CGFloat {
|
||||
// Use the calculated fill height if available, otherwise use the default fill height
|
||||
model.current_item_fill?.height ?? model.default_fill_height
|
||||
model.firstImageHeight ?? model.image_fill?.height ?? model.fillHeight
|
||||
}
|
||||
|
||||
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
|
||||
@@ -300,7 +160,7 @@ struct ImageCarousel<Content: View>: View {
|
||||
if num_urls > 1 {
|
||||
// jb55: quick hack since carousel with multiple images looks horrible with blurhash background
|
||||
Color.clear
|
||||
} else if let meta = model.damus_state.events.lookup_img_metadata(url: url),
|
||||
} else if let meta = state.events.lookup_img_metadata(url: url),
|
||||
case .processed(let blurhash) = meta.state {
|
||||
Image(uiImage: blurhash)
|
||||
.resizable()
|
||||
@@ -309,6 +169,12 @@ struct ImageCarousel<Content: View>: View {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if self.model.image_fill == nil, let size = state.video.size_for_url(url) {
|
||||
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: size, maxHeight: model.maxHeight, fillHeight: model.fillHeight)
|
||||
self.model.image_fill = fill
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Media(geo: GeometryProxy, url: MediaUrl, index: Int) -> some View {
|
||||
@@ -317,17 +183,24 @@ struct ImageCarousel<Content: View>: View {
|
||||
case .image(let url):
|
||||
Img(geo: geo, url: url, index: index)
|
||||
.onTapGesture {
|
||||
present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex))
|
||||
model.open_sheet = true
|
||||
}
|
||||
case .video(let url):
|
||||
let video_model = model.damus_state.video.get_player(for: url)
|
||||
DamusVideoPlayerView(
|
||||
model: video_model,
|
||||
coordinator: model.damus_state.video,
|
||||
style: .preview(on_tap: {
|
||||
present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex))
|
||||
})
|
||||
)
|
||||
DamusVideoPlayer(url: url, video_size: $model.video_size, controller: state.video, style: .preview(on_tap: { model.open_sheet = true }))
|
||||
.onChange(of: model.video_size) { size in
|
||||
guard let size else { return }
|
||||
|
||||
let fill = ImageFill.calculate_image_fill(geo_size: geo.size, img_size: size, maxHeight: model.maxHeight, fillHeight: model.fillHeight)
|
||||
|
||||
print("video_size changed \(size)")
|
||||
if self.model.image_fill == nil {
|
||||
print("video_size firstImageHeight \(fill.height)")
|
||||
self.model.firstImageHeight = fill.height
|
||||
state.events.get_cache_data(evid).media_metadata_model.fill = fill
|
||||
}
|
||||
|
||||
self.model.image_fill = fill
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,18 +209,31 @@ struct ImageCarousel<Content: View>: View {
|
||||
KFAnimatedImage(url)
|
||||
.callbackQueue(.dispatch(.global(qos:.background)))
|
||||
.backgroundDecode(true)
|
||||
.imageContext(.note, disable_animation: model.damus_state.settings.disable_animation)
|
||||
.imageContext(.note, disable_animation: state.settings.disable_animation)
|
||||
.image_fade(duration: 0.25)
|
||||
.cancelOnDisappear(true)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
.observe_image_size(size_changed: { size in
|
||||
// Observe the image size to update the model when the size changes, so we can calculate the fill
|
||||
model.media_size_information[url] = size
|
||||
})
|
||||
.imageFill(for: geo.size, max: model.maxHeight, fill: model.fillHeight) { fill in
|
||||
state.events.get_cache_data(evid).media_metadata_model.fill = fill
|
||||
// blur hash can be discarded when we have the url
|
||||
// NOTE: this is the wrong place for this... we need to remove
|
||||
// it when the image is loaded in memory. This may happen
|
||||
// earlier than this (by the preloader, etc)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
state.events.lookup_img_metadata(url: url)?.state = .not_needed
|
||||
}
|
||||
self.model.image_fill = fill
|
||||
if index == 0 {
|
||||
self.model.firstImageHeight = fill.height
|
||||
//maxHeight = firstImageHeight ?? maxHeight
|
||||
} else {
|
||||
//maxHeight = firstImageHeight ?? fill.height
|
||||
}
|
||||
}
|
||||
.background {
|
||||
Placeholder(url: url, geo_size: geo.size, num_urls: model.urls.count)
|
||||
Placeholder(url: url, geo_size: geo.size, num_urls: urls.count)
|
||||
}
|
||||
.aspectRatio(contentMode: filling ? .fill : .fit)
|
||||
.kfClickable()
|
||||
@@ -362,19 +248,25 @@ struct ImageCarousel<Content: View>: View {
|
||||
|
||||
var Medias: some View {
|
||||
TabView(selection: $model.selectedIndex) {
|
||||
ForEach(model.urls.indices, id: \.self) { index in
|
||||
ForEach(urls.indices, id: \.self) { index in
|
||||
GeometryReader { geo in
|
||||
Media(geo: geo, url: model.urls[index], index: index)
|
||||
.onChange(of: geo.size, perform: { new_size in
|
||||
model.geo_size = new_size
|
||||
})
|
||||
.onAppear {
|
||||
model.geo_size = geo.size
|
||||
}
|
||||
Media(geo: geo, url: urls[index], index: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
|
||||
.fullScreenCover(isPresented: $model.open_sheet) {
|
||||
if let content {
|
||||
FullScreenCarouselView<Content>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex) {
|
||||
content({ // Dismiss closure
|
||||
model.open_sheet = false
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
FullScreenCarouselView<AnyView>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex)
|
||||
}
|
||||
}
|
||||
.frame(height: height)
|
||||
.onChange(of: model.selectedIndex) { value in
|
||||
model.selectedIndex = value
|
||||
@@ -392,8 +284,8 @@ struct ImageCarousel<Content: View>: View {
|
||||
}
|
||||
|
||||
|
||||
if model.urls.count > 1 {
|
||||
PageControlView(currentPage: $model.selectedIndex, numberOfPages: model.urls.count)
|
||||
if urls.count > 1 {
|
||||
PageControlView(currentPage: $model.selectedIndex, numberOfPages: urls.count)
|
||||
.frame(maxWidth: 0, maxHeight: 0)
|
||||
.padding(.top, 5)
|
||||
}
|
||||
@@ -401,6 +293,27 @@ struct ImageCarousel<Content: View>: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Modifier
|
||||
extension KFOptionSetter {
|
||||
/// Sets a block to get image size
|
||||
///
|
||||
/// - Parameter block: The block which is used to read the image object.
|
||||
/// - Returns: `Self` value after read size
|
||||
public func imageFill(for size: CGSize, max: CGFloat, fill: CGFloat, block: @escaping (ImageFill) throws -> Void) -> Self {
|
||||
let modifier = AnyImageModifier { image -> KFCrossPlatformImage in
|
||||
let img_size = image.size
|
||||
let geo_size = size
|
||||
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: img_size, maxHeight: max, fillHeight: fill)
|
||||
DispatchQueue.main.async { [block, fill] in
|
||||
try? block(fill)
|
||||
}
|
||||
return image
|
||||
}
|
||||
options.imageModifier = modifier
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public struct ImageFill {
|
||||
let filling: Bool?
|
||||
@@ -437,3 +350,4 @@ struct ImageCarousel_Previews: PreviewProvider {
|
||||
.environmentObject(OrientationTracker())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+33
-95
@@ -57,47 +57,10 @@ enum Sheets: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// An item to be presented full screen in a mechanism that is more robust for timeline views.
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// This is part of the `present(full_screen_item: FullScreenItem)` interface that allows views in a timeline to show something full-screen without the lazy stack issues
|
||||
/// Full screen cover modifiers are not suitable in those cases because device orientation changes or programmatic scroll commands will cause the view to be unloaded along with the cover,
|
||||
/// causing the user to lose the full screen view randomly.
|
||||
///
|
||||
/// The `ContentView` is responsible for handling these objects
|
||||
///
|
||||
/// New items can be added as needed.
|
||||
///
|
||||
enum FullScreenItem: Identifiable, Equatable {
|
||||
/// A full screen media carousel for images and videos.
|
||||
case full_screen_carousel(urls: [MediaUrl], selectedIndex: Binding<Int>)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .full_screen_carousel(let urls, _): return "full_screen_carousel:\(urls.map(\.url))"
|
||||
}
|
||||
}
|
||||
|
||||
static func == (lhs: FullScreenItem, rhs: FullScreenItem) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
|
||||
/// The view to display the item
|
||||
func view(damus_state: DamusState) -> some View {
|
||||
switch self {
|
||||
case .full_screen_carousel(let urls, let selectedIndex):
|
||||
return FullScreenCarouselView<AnyView>(video_coordinator: damus_state.video, urls: urls, settings: damus_state.settings, selectedIndex: selectedIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func present_sheet(_ sheet: Sheets) {
|
||||
notify(.present_sheet(sheet))
|
||||
}
|
||||
|
||||
var tabHeight: CGFloat = 0.0
|
||||
|
||||
struct ContentView: View {
|
||||
let keypair: Keypair
|
||||
let appDelegate: AppDelegate?
|
||||
@@ -113,7 +76,6 @@ struct ContentView: View {
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
|
||||
@State var active_sheet: Sheets? = nil
|
||||
@State var active_full_screen_item: FullScreenItem? = nil
|
||||
@State var damus_state: DamusState!
|
||||
@State var menu_subtitle: String? = nil
|
||||
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home {
|
||||
@@ -127,7 +89,6 @@ struct ContentView: View {
|
||||
@State var user_muted_confirm: Bool = false
|
||||
@State var confirm_overwrite_mutelist: Bool = false
|
||||
@State private var isSideBarOpened = false
|
||||
@State var headerOffset: CGFloat = 0.0
|
||||
var home: HomeModel = HomeModel()
|
||||
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
|
||||
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
|
||||
@@ -170,7 +131,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
case .home:
|
||||
PostingTimelineView(damus_state: damus_state!, home: home, isSideBarOpened: $isSideBarOpened, active_sheet: $active_sheet, headerOffset: $headerOffset)
|
||||
PostingTimelineView(damus_state: damus_state!, home: home, active_sheet: $active_sheet)
|
||||
|
||||
case .notifications:
|
||||
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
||||
@@ -179,16 +140,25 @@ struct ContentView: View {
|
||||
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
|
||||
}
|
||||
}
|
||||
.background(DamusColors.adaptableWhite)
|
||||
.edgesIgnoringSafeArea(selected_timeline != .home ? [] : [.top, .bottom])
|
||||
.navigationBarTitle(timeline_name(selected_timeline), displayMode: .inline)
|
||||
.toolbar(selected_timeline != .home ? .visible : .hidden)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
VStack {
|
||||
timelineNavItem
|
||||
.opacity(isSideBarOpened ? 0 : 1)
|
||||
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||
if selected_timeline == .home {
|
||||
Image("damus-home")
|
||||
.resizable()
|
||||
.frame(width:30,height:30)
|
||||
.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)
|
||||
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,7 +209,14 @@ struct ContentView: View {
|
||||
MainContent(damus: damus)
|
||||
.toolbar() {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
TopbarSideMenuButton(damus_state: damus, isSideBarOpened: $isSideBarOpened)
|
||||
Button {
|
||||
isSideBarOpened.toggle()
|
||||
} label: {
|
||||
ProfilePicView(pubkey: damus_state!.pubkey, size: 32, highlight: .none, profiles: damus_state!.profiles, disable_animation: damus_state!.settings.disable_animation)
|
||||
.opacity(isSideBarOpened ? 0 : 1)
|
||||
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||
}
|
||||
.disabled(isSideBarOpened)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
@@ -260,11 +237,9 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(DamusColors.adaptableWhite)
|
||||
.edgesIgnoringSafeArea(selected_timeline != .home ? [] : [.top, .bottom])
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.overlay(
|
||||
SideMenuView(damus_state: damus_state!, isSidebarVisible: $isSideBarOpened.animation(), selected: $selected_timeline)
|
||||
SideMenuView(damus_state: damus_state!, isSidebarVisible: $isSideBarOpened.animation())
|
||||
)
|
||||
.navigationDestination(for: Route.self) { route in
|
||||
route.view(navigationCoordinator: navigationCoordinator, damusState: damus_state!)
|
||||
@@ -274,28 +249,13 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.damus_full_screen_cover($active_full_screen_item, damus_state: damus, content: { item in
|
||||
return item.view(damus_state: damus)
|
||||
})
|
||||
.overlay(alignment: .bottom) {
|
||||
if !hide_bar {
|
||||
if !isSideBarOpened {
|
||||
TabBar(nstatus: home.notification_status, navIsAtRoot: navIsAtRoot(), selected: $selected_timeline, headerOffset: $headerOffset, settings: damus.settings, action: switch_timeline)
|
||||
.padding([.bottom], 8)
|
||||
.background(selected_timeline != .home || (selected_timeline == .home && !self.navIsAtRoot()) ? DamusColors.adaptableWhite : DamusColors.adaptableWhite.opacity(abs(1.25 - (abs(headerOffset/100.0)))))
|
||||
.anchorPreference(key: HeaderBoundsKey.self, value: .bounds){$0}
|
||||
.overlayPreferenceValue(HeaderBoundsKey.self) { value in
|
||||
GeometryReader{ proxy in
|
||||
if let anchor = value{
|
||||
Color.clear
|
||||
.onAppear {
|
||||
tabHeight = proxy[anchor].height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hide_bar {
|
||||
TabBar(nstatus: home.notification_status, selected: $selected_timeline, settings: damus.settings, action: switch_timeline)
|
||||
.padding([.bottom], 8)
|
||||
.background(Color(uiColor: .systemBackground).ignoresSafeArea())
|
||||
} else {
|
||||
Text("")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,9 +413,6 @@ struct ContentView: View {
|
||||
.onReceive(handle_notify(.present_sheet)) { sheet in
|
||||
self.active_sheet = sheet
|
||||
}
|
||||
.onReceive(handle_notify(.present_full_screen_item)) { item in
|
||||
self.active_full_screen_item = item
|
||||
}
|
||||
.onReceive(handle_notify(.zapping)) { zap_ev in
|
||||
guard !zap_ev.is_custom else {
|
||||
return
|
||||
@@ -721,7 +678,7 @@ struct ContentView: View {
|
||||
wallet: WalletModel(settings: settings),
|
||||
nav: self.navigationCoordinator,
|
||||
music: MusicController(onChange: music_changed),
|
||||
video: DamusVideoCoordinator(),
|
||||
video: VideoController(),
|
||||
ndb: ndb,
|
||||
quote_reposts: .init(our_pubkey: pubkey),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
@@ -785,25 +742,6 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct TopbarSideMenuButton: View {
|
||||
let damus_state: DamusState
|
||||
@Binding var isSideBarOpened: Bool
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
isSideBarOpened.toggle()
|
||||
} label: {
|
||||
ProfilePicView(pubkey: damus_state.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
.opacity(isSideBarOpened ? 0 : 1)
|
||||
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||
.accessibilityHidden(true) // Knowing there is a profile picture here leads to no actionable outcome to VoiceOver users, so it is best not to show it
|
||||
}
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.main_side_menu_button.rawValue)
|
||||
.accessibilityLabel(NSLocalizedString("Side menu", comment: "Accessibility label for the side menu button at the topbar"))
|
||||
.disabled(isSideBarOpened)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContentView(keypair: Keypair(pubkey: test_pubkey, privkey: nil), appDelegate: nil)
|
||||
|
||||
@@ -34,13 +34,13 @@ class DamusState: HeadlessDamusState {
|
||||
let wallet: WalletModel
|
||||
let nav: NavigationCoordinator
|
||||
let music: MusicController?
|
||||
let video: DamusVideoCoordinator
|
||||
let video: VideoController
|
||||
let ndb: Ndb
|
||||
var purple: DamusPurple
|
||||
var push_notification_client: PushNotificationClient
|
||||
let emoji_provider: EmojiProvider
|
||||
|
||||
init(pool: RelayPool, keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, postbox: PostBox, bootstrap_relays: [RelayURL], replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) {
|
||||
init(pool: RelayPool, keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, postbox: PostBox, bootstrap_relays: [RelayURL], replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: VideoController, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) {
|
||||
self.pool = pool
|
||||
self.keypair = keypair
|
||||
self.likes = likes
|
||||
@@ -141,7 +141,7 @@ class DamusState: HeadlessDamusState {
|
||||
wallet: WalletModel(settings: settings),
|
||||
nav: navigationCoordinator,
|
||||
music: MusicController(onChange: { _ in }),
|
||||
video: DamusVideoCoordinator(),
|
||||
video: VideoController(),
|
||||
ndb: ndb,
|
||||
quote_reposts: .init(our_pubkey: pubkey),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
@@ -209,7 +209,7 @@ class DamusState: HeadlessDamusState {
|
||||
wallet: WalletModel(settings: UserSettingsStore()),
|
||||
nav: NavigationCoordinator(),
|
||||
music: nil,
|
||||
video: DamusVideoCoordinator(),
|
||||
video: VideoController(),
|
||||
ndb: .empty,
|
||||
quote_reposts: .init(our_pubkey: empty_pub),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
|
||||
@@ -213,27 +213,3 @@ enum HighlightSource: Hashable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ShareContent {
|
||||
let title: String
|
||||
let content: ContentType
|
||||
|
||||
enum ContentType {
|
||||
case link(URL)
|
||||
case media([PreUploadedMedia])
|
||||
}
|
||||
|
||||
func getLinkURL() -> URL? {
|
||||
if case let .link(url) = content {
|
||||
return url
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getMediaArray() -> [PreUploadedMedia] {
|
||||
if case let .media(mediaArray) = content {
|
||||
return mediaArray
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,14 +59,10 @@ enum TranslationService: String, CaseIterable, Identifiable, StringCodable {
|
||||
}
|
||||
|
||||
static var isAppleTranslationPopoverSupported: Bool {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
return false
|
||||
#else
|
||||
if #available(iOS 17.4, macOS 14.4, *) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,11 @@ struct SwipeToDismissModifier: ViewModifier {
|
||||
var onDismiss: () -> Void
|
||||
@State private var offset: CGSize = .zero
|
||||
@GestureState private var viewOffset: CGSize = .zero
|
||||
|
||||
let threshold_offset: CGFloat = 100.0
|
||||
let minimum_opacity: CGFloat = 0.1
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.offset(y: viewOffset.height)
|
||||
.animation(.interactiveSpring(), value: viewOffset)
|
||||
.opacity(max(min(1.0 - (abs(offset.height) / threshold_offset), 1.0), minimum_opacity))
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: minDistance ?? 10)
|
||||
.updating($viewOffset, body: { value, gestureState, transaction in
|
||||
@@ -32,7 +28,7 @@ struct SwipeToDismissModifier: ViewModifier {
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
if abs(offset.height) > threshold_offset {
|
||||
if abs(offset.height) > 100 {
|
||||
onDismiss()
|
||||
} else {
|
||||
offset = .zero
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
//
|
||||
// PresentFullScreenItemNotify.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-11-01.
|
||||
//
|
||||
|
||||
struct PresentFullScreenItemNotify: Notify {
|
||||
typealias Payload = FullScreenItem
|
||||
var payload: Payload
|
||||
}
|
||||
|
||||
extension NotifyHandler {
|
||||
static var present_full_screen_item: NotifyHandler<PresentFullScreenItemNotify> {
|
||||
.init()
|
||||
}
|
||||
}
|
||||
|
||||
extension Notifications {
|
||||
static func present_full_screen_item(_ item: FullScreenItem) -> Notifications<PresentFullScreenItemNotify> {
|
||||
.init(.init(payload: item))
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the app to present an item in full screen. Use this when presenting items coming from a timeline or any lazy stack.
|
||||
///
|
||||
/// ## Usage notes
|
||||
///
|
||||
/// Use this instead of `.damus_full_screen_cover` when the source view is on a lazy stack or timeline.
|
||||
///
|
||||
/// The reason is that when using a full screen modifier in those scenarios, the full screen view may abruptly disappear.
|
||||
/// One example is when showing videos from the timeline in full screen, where changing the orientation of the device (landscape/portrait)
|
||||
/// can cause the source view to be unloaded by the lazy stack, making your full screen overlay to simply disappear, causing a feeling of flakiness to the app
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// The requests from this function will be received and handled at the top level app view (`ContentView`), which contains a `.damus_full_screen_cover`.
|
||||
///
|
||||
func present(full_screen_item: FullScreenItem) {
|
||||
notify(.present_full_screen_item(full_screen_item))
|
||||
}
|
||||
|
||||
@@ -31,7 +31,4 @@ class Constants {
|
||||
static let DAMUS_WEBSITE_LOCAL_TEST_URL: URL = URL(string: "http://localhost:3000")!
|
||||
static let DAMUS_WEBSITE_STAGING_URL: URL = URL(string: "https://staging.damus.io")!
|
||||
static let DAMUS_WEBSITE_PRODUCTION_URL: URL = URL(string: "https://damus.io")!
|
||||
|
||||
// MARK: General constants
|
||||
static let GIF_IMAGE_TYPE: String = "com.compuserve.gif"
|
||||
}
|
||||
|
||||
@@ -58,22 +58,6 @@ extension KFOptionSetter {
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
/// This allows you to observe the size of the image, and get a callback when the size changes
|
||||
/// This is useful for when you need to layout views based on the size of the image
|
||||
/// - Parameter size_changed: A callback that will be called when the size of the image changes
|
||||
/// - Returns: The same KFOptionSetter instance
|
||||
func observe_image_size(size_changed: @escaping (CGSize) -> Void) -> Self {
|
||||
let modifier = AnyImageModifier { image -> KFCrossPlatformImage in
|
||||
let image_size = image.size
|
||||
DispatchQueue.main.async { [size_changed, image_size] in
|
||||
size_changed(image_size)
|
||||
}
|
||||
return image
|
||||
}
|
||||
options.imageModifier = modifier
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
let MAX_FILE_SIZE = 20_971_520 // 20MiB
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
//
|
||||
// OffsetExtension.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 9/6/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum SwipeDirection {
|
||||
case up
|
||||
case down
|
||||
case none
|
||||
}
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func offsetY(completion: @escaping (CGFloat, CGFloat)->())->some View {
|
||||
self
|
||||
.modifier(OffsetHelper(onChange: completion))
|
||||
}
|
||||
|
||||
func safeArea() -> UIEdgeInsets {
|
||||
guard let scene = this_app.connectedScenes.first as? UIWindowScene else{return .zero}
|
||||
guard let safeArea = scene.windows.first?.safeAreaInsets else{return .zero}
|
||||
return safeArea
|
||||
}
|
||||
}
|
||||
|
||||
struct OffsetHelper: ViewModifier{
|
||||
var onChange: (CGFloat,CGFloat)->()
|
||||
@State var currentOffset: CGFloat = 0
|
||||
@State var previousOffset: CGFloat = 0
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
GeometryReader{proxy in
|
||||
let minY = proxy.frame(in: .named("scroll")).minY
|
||||
Color.clear
|
||||
.preference(key: OffsetKey.self, value: minY)
|
||||
.onPreferenceChange(OffsetKey.self) { value in
|
||||
previousOffset = currentOffset
|
||||
currentOffset = value
|
||||
onChange(previousOffset,currentOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OffsetKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
struct HeaderBoundsKey: PreferenceKey{
|
||||
static var defaultValue: Anchor<CGRect>?
|
||||
|
||||
static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
func getSafeAreaTop()->CGFloat{
|
||||
guard let scene = this_app.connectedScenes.first as? UIWindowScene else{return .zero}
|
||||
guard let topSafeArea = scene.windows.first?.safeAreaInsets.top else{return .zero}
|
||||
return topSafeArea
|
||||
}
|
||||
|
||||
func getSafeAreaBottom()->CGFloat{
|
||||
guard let scene = this_app.connectedScenes.first as? UIWindowScene else{return .zero}
|
||||
guard let bottomSafeArea = scene.windows.first?.safeAreaInsets.bottom else{return .zero}
|
||||
return bottomSafeArea
|
||||
}
|
||||
@@ -7,12 +7,16 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
func bundleForLocale(locale: Locale) -> Bundle {
|
||||
let path = Bundle.main.path(forResource: locale.identifier, ofType: "lproj")
|
||||
func bundleForLocale(locale: Locale?) -> Bundle {
|
||||
if locale == nil {
|
||||
return Bundle.main
|
||||
}
|
||||
|
||||
let path = Bundle.main.path(forResource: locale!.identifier, ofType: "lproj")
|
||||
return path != nil ? (Bundle(path: path!) ?? Bundle.main) : Bundle.main
|
||||
}
|
||||
|
||||
func localizedStringFormat(key: String, locale: Locale) -> String {
|
||||
func localizedStringFormat(key: String, locale: Locale?) -> String {
|
||||
let bundle = bundleForLocale(locale: locale)
|
||||
let fallback = bundleForLocale(locale: Locale(identifier: "en-US")).localizedString(forKey: key, value: nil, table: nil)
|
||||
return bundle.localizedString(forKey: key, value: fallback, table: nil)
|
||||
|
||||
@@ -17,7 +17,6 @@ enum LogCategory: String {
|
||||
case push_notifications
|
||||
case damus_purple
|
||||
case image_uploading
|
||||
case video_coordination
|
||||
}
|
||||
|
||||
/// Damus structured logger
|
||||
|
||||
@@ -37,7 +37,6 @@ enum Route: Hashable {
|
||||
case Reactions(reactions: EventsModel)
|
||||
case Zaps(target: ZapTarget)
|
||||
case Search(search: SearchModel)
|
||||
case NDBSearch(results: Binding<[NostrEvent]>)
|
||||
case EULA
|
||||
case Login
|
||||
case CreateAccount
|
||||
@@ -106,8 +105,6 @@ enum Route: Hashable {
|
||||
ZapsView(state: damusState, target: target)
|
||||
case .Search(let search):
|
||||
SearchView(appstate: damusState, search: search)
|
||||
case .NDBSearch(let results):
|
||||
NDBSearchView(damus_state: damusState, results: results)
|
||||
case .EULA:
|
||||
EULAView(nav: navigationCoordinator)
|
||||
case .Login:
|
||||
@@ -203,8 +200,6 @@ enum Route: Hashable {
|
||||
case .Search(let search):
|
||||
hasher.combine("search")
|
||||
hasher.combine(search.search)
|
||||
case .NDBSearch(let results):
|
||||
hasher.combine("results")
|
||||
case .EULA:
|
||||
hasher.combine("eula")
|
||||
case .Login:
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
//
|
||||
// AppAccessibilityIdentifiers.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-11-18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A collection of app-wide identifier constants used to facilitate UI tests to find the element they are looking for.
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - This is not an exhaustive list. Add more identifiers as needed.
|
||||
/// - Organize this by separating each category with `MARK` comment markers and a unique prefix, each category separated by 2 empty lines
|
||||
enum AppAccessibilityIdentifiers: String {
|
||||
// MARK: Login
|
||||
// Prefix: `sign_in`
|
||||
|
||||
/// Sign in button at the very start of the app
|
||||
case sign_in_option_button
|
||||
/// A secure text entry field where the user can put their private key when logging in
|
||||
case sign_in_nsec_key_entry_field
|
||||
/// Button to sign in after entering private key
|
||||
case sign_in_confirm_button
|
||||
|
||||
|
||||
// MARK: Onboarding
|
||||
// Prefix: `onboarding`
|
||||
|
||||
/// The skip button on the onboarding sheet
|
||||
case onboarding_sheet_skip_button
|
||||
|
||||
|
||||
// MARK: Post composer
|
||||
// Prefix: `post_composer`
|
||||
|
||||
/// The cancel post button
|
||||
case post_composer_cancel_button
|
||||
|
||||
// MARK: Main interface layout
|
||||
// Prefix: `main`
|
||||
|
||||
/// Profile picture item on the top toolbar, used to open the side menu
|
||||
case main_side_menu_button
|
||||
|
||||
|
||||
// MARK: Side menu
|
||||
// Prefix: `side_menu`
|
||||
|
||||
/// The profile option in the side menu
|
||||
case side_menu_profile_button
|
||||
|
||||
|
||||
// MARK: Items specific to the user's own profile
|
||||
// Prefix: `own_profile`
|
||||
|
||||
/// The edit profile button
|
||||
case own_profile_edit_button
|
||||
|
||||
/// The button to edit the banner image on the profile
|
||||
case own_profile_banner_image_edit_button
|
||||
|
||||
/// The button to pick the new banner image from URL
|
||||
case own_profile_banner_image_edit_from_url
|
||||
}
|
||||
@@ -14,7 +14,6 @@ struct EditBannerImageView: View {
|
||||
@ObservedObject var viewModel: ImageUploadingObserver
|
||||
let callback: (URL?) -> Void
|
||||
let defaultImage = UIImage(named: "damoose") ?? UIImage()
|
||||
let safeAreaInsets: EdgeInsets
|
||||
|
||||
@State var banner_image: URL? = nil
|
||||
|
||||
@@ -32,21 +31,7 @@ struct EditBannerImageView: View {
|
||||
.onFailureImage(defaultImage)
|
||||
.kfClickable()
|
||||
|
||||
EditPictureControl(uploader: damus_state.settings.default_media_uploader, keypair: damus_state.keypair, pubkey: damus_state.pubkey, image_url: $banner_image, uploadObserver: viewModel, callback: callback)
|
||||
.padding(10)
|
||||
.backwardsCompatibleSafeAreaPadding(self.safeAreaInsets)
|
||||
.accessibilityLabel(NSLocalizedString("Edit banner image", comment: "Accessibility label for edit banner image button"))
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.own_profile_banner_image_edit_button.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
fileprivate func backwardsCompatibleSafeAreaPadding(_ insets: EdgeInsets) -> some View {
|
||||
if #available(iOS 17.0, *) {
|
||||
return self.safeAreaPadding(insets)
|
||||
} else {
|
||||
return self.padding(.top, insets.top)
|
||||
EditPictureControl(uploader: damus_state.settings.default_media_uploader, pubkey: damus_state.pubkey, image_url: $banner_image, uploadObserver: viewModel, callback: callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ struct BookmarksView: View {
|
||||
ScrollView {
|
||||
InnerTimelineView(events: EventHolder(events: bookmarks, incoming: []), damus: state, filter: noneFilter)
|
||||
}
|
||||
.padding(.bottom, 10 + tabHeight + getSafeAreaBottom())
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.switched_timeline)) { _ in
|
||||
|
||||
@@ -29,18 +29,13 @@ struct GradientFollowButton: View {
|
||||
.fontWeight(.medium)
|
||||
.padding([.top, .bottom], 10)
|
||||
.padding([.leading, .trailing], 12)
|
||||
.background(follow_state == .unfollows ? PinkGradient : GrayGradient)
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(grayBorder, lineWidth: follow_state == .unfollows ? 0 : 1)
|
||||
.frame(width: 100)
|
||||
)
|
||||
.frame(width: 100)
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.background(follow_state == .unfollows ? PinkGradient : GrayGradient)
|
||||
.cornerRadius(12)
|
||||
.frame(width: 100)
|
||||
.onReceive(handle_notify(.followed)) { ref in
|
||||
guard target.follow_ref == ref else { return }
|
||||
self.follow_state = .follows
|
||||
|
||||
@@ -27,6 +27,8 @@ struct ChatEventView: View {
|
||||
// MARK: long-press reaction control objects
|
||||
/// Whether the user is actively pressing the view
|
||||
@State var is_pressing = false
|
||||
/// The dispatched work item scheduled by a timer to bounce the event bubble and show the emoji selector
|
||||
@State var long_press_bounce_work_item: DispatchWorkItem?
|
||||
@State var popover_state: PopoverState = .closed {
|
||||
didSet {
|
||||
let generator = UIImpactFeedbackGenerator(style: popover_state.some_sheet_open() ? .heavy : .light)
|
||||
@@ -37,7 +39,6 @@ struct ChatEventView: View {
|
||||
|
||||
@State private var isOnTopHalfOfScreen: Bool = false
|
||||
@ObservedObject var bar: ActionBarModel
|
||||
@Environment(\.swipeViewGroupSelection) var swipeViewGroupSelection
|
||||
|
||||
enum PopoverState: String {
|
||||
case closed
|
||||
@@ -205,18 +206,28 @@ struct ChatEventView: View {
|
||||
.scaleEffect(self.popover_state.some_sheet_open() ? 1.08 : is_pressing ? 1.02 : 1)
|
||||
.shadow(color: (is_pressing || self.popover_state.some_sheet_open()) ? .black.opacity(0.1) : .black.opacity(0.3), radius: (is_pressing || self.popover_state.some_sheet_open()) ? 8 : 0, y: (is_pressing || self.popover_state.some_sheet_open()) ? 15 : 0)
|
||||
.onLongPressGesture(minimumDuration: 0.5, maximumDistance: 10, perform: {
|
||||
withAnimation(.bouncy(duration: 0.2, extraBounce: 0.35)) {
|
||||
let should_show_zap_sheet = !damus_state.settings.nozaps && damus_state.settings.onlyzaps_mode
|
||||
popover_state = should_show_zap_sheet ? .open_zap_sheet : .open_emoji_selector
|
||||
}
|
||||
long_press_bounce_work_item?.cancel()
|
||||
}, onPressingChanged: { is_pressing in
|
||||
withAnimation(is_pressing ? .easeIn(duration: 0.5) : .easeOut(duration: 0.1)) {
|
||||
self.is_pressing = is_pressing
|
||||
if popover_state != .closed {
|
||||
return
|
||||
}
|
||||
if self.is_pressing {
|
||||
let item = DispatchWorkItem {
|
||||
// Ensure the action is performed only if the condition is still valid
|
||||
if self.is_pressing {
|
||||
withAnimation(.bouncy(duration: 0.2, extraBounce: 0.35)) {
|
||||
let should_show_zap_sheet = !damus_state.settings.nozaps && damus_state.settings.onlyzaps_mode
|
||||
popover_state = should_show_zap_sheet ? .open_zap_sheet : .open_emoji_selector
|
||||
}
|
||||
}
|
||||
}
|
||||
long_press_bounce_work_item = item
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: item)
|
||||
}
|
||||
}
|
||||
})
|
||||
.onChange(of: swipeViewGroupSelection.wrappedValue) { newValue in
|
||||
self.is_pressing = false
|
||||
}
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
EmptyView()
|
||||
@@ -299,7 +310,6 @@ struct ChatEventView: View {
|
||||
.swipeSpacing(-20)
|
||||
.swipeActionsStyle(.mask)
|
||||
.swipeMinimumDistance(20)
|
||||
.swipeDragGesturePriority(.normal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,9 +135,6 @@ struct ChatroomThreadView: View {
|
||||
}
|
||||
.padding(.top)
|
||||
EndBlock()
|
||||
|
||||
HStack {}
|
||||
.frame(height: tabHeight + getSafeAreaBottom())
|
||||
}
|
||||
.onReceive(handle_notify(.post), perform: { notify in
|
||||
switch notify {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//
|
||||
// CodeScanner.swift
|
||||
// https://github.com/twostraws/CodeScanner
|
||||
//
|
||||
// Created by Paul Hudson on 14/12/2021.
|
||||
// Copyright © 2021 Paul Hudson. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
/// An enum describing the ways CodeScannerView can hit scanning problems.
|
||||
public enum ScanError: Error {
|
||||
/// The camera could not be accessed.
|
||||
case badInput
|
||||
|
||||
/// The camera was not capable of scanning the requested codes.
|
||||
case badOutput
|
||||
|
||||
/// Initialization failed.
|
||||
case initError(_ error: Error)
|
||||
}
|
||||
|
||||
/// The result from a successful scan: the string that was scanned, and also the type of data that was found.
|
||||
/// The type is useful for times when you've asked to scan several different code types at the same time, because
|
||||
/// it will report the exact code type that was found.
|
||||
public struct ScanResult {
|
||||
/// The contents of the code.
|
||||
public let string: String
|
||||
|
||||
/// The type of code that was matched.
|
||||
public let type: AVMetadataObject.ObjectType
|
||||
}
|
||||
|
||||
/// The operating mode for CodeScannerView.
|
||||
public enum ScanMode {
|
||||
/// Scan exactly one code, then stop.
|
||||
case once
|
||||
|
||||
/// Scan each code no more than once.
|
||||
case oncePerCode
|
||||
|
||||
/// Keep scanning all codes until dismissed.
|
||||
case continuous
|
||||
}
|
||||
|
||||
/// A SwiftUI view that is able to scan barcodes, QR codes, and more, and send back what was found.
|
||||
/// To use, set `codeTypes` to be an array of things to scan for, e.g. `[.qr]`, and set `completion` to
|
||||
/// a closure that will be called when scanning has finished. This will be sent the string that was detected or a `ScanError`.
|
||||
/// For testing inside the simulator, set the `simulatedData` property to some test data you want to send back.
|
||||
public struct CodeScannerView: UIViewControllerRepresentable {
|
||||
|
||||
public let codeTypes: [AVMetadataObject.ObjectType]
|
||||
public let scanMode: ScanMode
|
||||
public let scanInterval: Double
|
||||
public let showViewfinder: Bool
|
||||
public var simulatedData = ""
|
||||
public var shouldVibrateOnSuccess: Bool
|
||||
public var isTorchOn: Bool
|
||||
public var isGalleryPresented: Binding<Bool>
|
||||
public var videoCaptureDevice: AVCaptureDevice?
|
||||
public var completion: (Result<ScanResult, ScanError>) -> Void
|
||||
|
||||
public init(
|
||||
codeTypes: [AVMetadataObject.ObjectType],
|
||||
scanMode: ScanMode = .once,
|
||||
scanInterval: Double = 2.0,
|
||||
showViewfinder: Bool = false,
|
||||
simulatedData: String = "",
|
||||
shouldVibrateOnSuccess: Bool = true,
|
||||
isTorchOn: Bool = false,
|
||||
isGalleryPresented: Binding<Bool> = .constant(false),
|
||||
videoCaptureDevice: AVCaptureDevice? = AVCaptureDevice.default(for: .video),
|
||||
completion: @escaping (Result<ScanResult, ScanError>) -> Void
|
||||
) {
|
||||
self.codeTypes = codeTypes
|
||||
self.scanMode = scanMode
|
||||
self.showViewfinder = showViewfinder
|
||||
self.scanInterval = scanInterval
|
||||
self.simulatedData = simulatedData
|
||||
self.shouldVibrateOnSuccess = shouldVibrateOnSuccess
|
||||
self.isTorchOn = isTorchOn
|
||||
self.isGalleryPresented = isGalleryPresented
|
||||
self.videoCaptureDevice = videoCaptureDevice
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
public func makeCoordinator() -> ScannerCoordinator {
|
||||
ScannerCoordinator(parent: self)
|
||||
}
|
||||
|
||||
public func makeUIViewController(context: Context) -> ScannerViewController {
|
||||
let viewController = ScannerViewController(showViewfinder: showViewfinder)
|
||||
viewController.delegate = context.coordinator
|
||||
return viewController
|
||||
}
|
||||
|
||||
public func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {
|
||||
uiViewController.updateViewController(
|
||||
isTorchOn: isTorchOn,
|
||||
isGalleryPresented: isGalleryPresented.wrappedValue
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@available(macCatalyst 14.0, *)
|
||||
struct CodeScannerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CodeScannerView(codeTypes: [.qr]) { result in
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// CodeScanner.swift
|
||||
// https://github.com/twostraws/CodeScanner
|
||||
//
|
||||
// Created by Paul Hudson on 14/12/2021.
|
||||
// Copyright © 2021 Paul Hudson. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
extension CodeScannerView {
|
||||
@available(macCatalyst 14.0, *)
|
||||
public class ScannerCoordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||
var parent: CodeScannerView
|
||||
var codesFound = Set<String>()
|
||||
var didFinishScanning = false
|
||||
var lastTime = Date(timeIntervalSince1970: 0)
|
||||
|
||||
init(parent: CodeScannerView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
public func reset() {
|
||||
codesFound.removeAll()
|
||||
didFinishScanning = false
|
||||
lastTime = Date(timeIntervalSince1970: 0)
|
||||
}
|
||||
|
||||
public func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||
if let metadataObject = metadataObjects.first {
|
||||
guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return }
|
||||
guard let stringValue = readableObject.stringValue else { return }
|
||||
guard didFinishScanning == false else { return }
|
||||
let result = ScanResult(string: stringValue, type: readableObject.type)
|
||||
|
||||
switch parent.scanMode {
|
||||
case .once:
|
||||
found(result)
|
||||
// make sure we only trigger scan once per use
|
||||
didFinishScanning = true
|
||||
|
||||
case .oncePerCode:
|
||||
if !codesFound.contains(stringValue) {
|
||||
codesFound.insert(stringValue)
|
||||
found(result)
|
||||
}
|
||||
|
||||
case .continuous:
|
||||
if isPastScanInterval() {
|
||||
found(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isPastScanInterval() -> Bool {
|
||||
Date().timeIntervalSince(lastTime) >= parent.scanInterval
|
||||
}
|
||||
|
||||
func found(_ result: ScanResult) {
|
||||
lastTime = Date()
|
||||
|
||||
if parent.shouldVibrateOnSuccess {
|
||||
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
|
||||
}
|
||||
|
||||
parent.completion(.success(result))
|
||||
}
|
||||
|
||||
func didFail(reason: ScanError) {
|
||||
parent.completion(.failure(reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//
|
||||
// CodeScanner.swift
|
||||
// https://github.com/twostraws/CodeScanner
|
||||
//
|
||||
// Created by Paul Hudson on 14/12/2021.
|
||||
// Copyright © 2021 Paul Hudson. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
extension CodeScannerView {
|
||||
|
||||
@available(macCatalyst 14.0, *)
|
||||
public class ScannerViewController: UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
|
||||
var delegate: ScannerCoordinator?
|
||||
private let showViewfinder: Bool
|
||||
|
||||
private var isGalleryShowing: Bool = false {
|
||||
didSet {
|
||||
// Update binding
|
||||
if delegate?.parent.isGalleryPresented.wrappedValue != isGalleryShowing {
|
||||
delegate?.parent.isGalleryPresented.wrappedValue = isGalleryShowing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public init(showViewfinder: Bool = false) {
|
||||
self.showViewfinder = showViewfinder
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
self.showViewfinder = false
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
func openGallery() {
|
||||
isGalleryShowing = true
|
||||
let imagePicker = UIImagePickerController()
|
||||
imagePicker.delegate = self
|
||||
present(imagePicker, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc func openGalleryFromButton(_ sender: UIButton) {
|
||||
openGallery()
|
||||
}
|
||||
|
||||
public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
isGalleryShowing = false
|
||||
|
||||
if let qrcodeImg = info[.originalImage] as? UIImage {
|
||||
let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])!
|
||||
let ciImage = CIImage(image:qrcodeImg)!
|
||||
var qrCodeLink = ""
|
||||
|
||||
let features = detector.features(in: ciImage)
|
||||
|
||||
for feature in features as! [CIQRCodeFeature] {
|
||||
qrCodeLink += feature.messageString!
|
||||
}
|
||||
|
||||
if qrCodeLink == "" {
|
||||
delegate?.didFail(reason: .badOutput)
|
||||
} else {
|
||||
let result = ScanResult(string: qrCodeLink, type: .qr)
|
||||
delegate?.found(result)
|
||||
}
|
||||
} else {
|
||||
print("Something went wrong")
|
||||
}
|
||||
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
isGalleryShowing = false
|
||||
}
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
override public func loadView() {
|
||||
view = UIView()
|
||||
view.isUserInteractionEnabled = true
|
||||
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.numberOfLines = 0
|
||||
label.text = "You're running in the simulator, which means the camera isn't available. Tap anywhere to send back some simulated data."
|
||||
label.textAlignment = .center
|
||||
|
||||
let button = UIButton()
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.setTitle("Select a custom image", for: .normal)
|
||||
button.setTitleColor(UIColor.systemBlue, for: .normal)
|
||||
button.setTitleColor(UIColor.gray, for: .highlighted)
|
||||
button.addTarget(self, action: #selector(openGalleryFromButton), for: .touchUpInside)
|
||||
|
||||
let stackView = UIStackView()
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = 50
|
||||
stackView.addArrangedSubview(label)
|
||||
stackView.addArrangedSubview(button)
|
||||
|
||||
view.addSubview(stackView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
button.heightAnchor.constraint(equalToConstant: 50),
|
||||
stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
|
||||
stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
|
||||
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let simulatedData = delegate?.parent.simulatedData else {
|
||||
print("Simulated Data Not Provided!")
|
||||
return
|
||||
}
|
||||
|
||||
// Send back their simulated data, as if it was one of the types they were scanning for
|
||||
let result = ScanResult(string: simulatedData, type: delegate?.parent.codeTypes.first ?? .qr)
|
||||
delegate?.found(result)
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
var captureSession: AVCaptureSession!
|
||||
var previewLayer: AVCaptureVideoPreviewLayer!
|
||||
let fallbackVideoCaptureDevice = AVCaptureDevice.default(for: .video)
|
||||
|
||||
private lazy var viewFinder: UIImageView? = {
|
||||
guard let image = UIImage(named: "viewfinder", in: .main, with: nil) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let imageView = UIImageView(image: image)
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
return imageView
|
||||
}()
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(updateOrientation),
|
||||
name: Notification.Name("UIDeviceOrientationDidChangeNotification"),
|
||||
object: nil)
|
||||
|
||||
view.backgroundColor = UIColor.black
|
||||
captureSession = AVCaptureSession()
|
||||
|
||||
guard let videoCaptureDevice = delegate?.parent.videoCaptureDevice ?? fallbackVideoCaptureDevice else {
|
||||
return
|
||||
}
|
||||
|
||||
let videoInput: AVCaptureDeviceInput
|
||||
|
||||
do {
|
||||
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
|
||||
} catch {
|
||||
delegate?.didFail(reason: .initError(error))
|
||||
return
|
||||
}
|
||||
|
||||
if (captureSession.canAddInput(videoInput)) {
|
||||
captureSession.addInput(videoInput)
|
||||
} else {
|
||||
delegate?.didFail(reason: .badInput)
|
||||
return
|
||||
}
|
||||
|
||||
let metadataOutput = AVCaptureMetadataOutput()
|
||||
|
||||
if (captureSession.canAddOutput(metadataOutput)) {
|
||||
captureSession.addOutput(metadataOutput)
|
||||
|
||||
metadataOutput.setMetadataObjectsDelegate(delegate, queue: DispatchQueue.main)
|
||||
metadataOutput.metadataObjectTypes = delegate?.parent.codeTypes
|
||||
} else {
|
||||
delegate?.didFail(reason: .badOutput)
|
||||
return
|
||||
}
|
||||
|
||||
if previewLayer == nil {
|
||||
previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
|
||||
}
|
||||
|
||||
previewLayer.frame = view.layer.bounds
|
||||
previewLayer.videoGravity = .resizeAspectFill
|
||||
view.layer.addSublayer(previewLayer)
|
||||
addviewfinder()
|
||||
|
||||
delegate?.reset()
|
||||
|
||||
if (captureSession?.isRunning == false) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
self.captureSession.startRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override public func viewWillLayoutSubviews() {
|
||||
previewLayer?.frame = view.layer.bounds
|
||||
}
|
||||
|
||||
@objc func updateOrientation() {
|
||||
guard let orientation = view.window?.windowScene?.interfaceOrientation else { return }
|
||||
guard let connection = captureSession.connections.last, connection.isVideoOrientationSupported else { return }
|
||||
connection.videoOrientation = AVCaptureVideoOrientation(rawValue: orientation.rawValue) ?? .portrait
|
||||
}
|
||||
|
||||
override public func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
updateOrientation()
|
||||
}
|
||||
|
||||
private func addviewfinder() {
|
||||
guard showViewfinder, let imageView = viewFinder else { return }
|
||||
|
||||
view.addSubview(imageView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
imageView.widthAnchor.constraint(equalToConstant: 200),
|
||||
imageView.heightAnchor.constraint(equalToConstant: 200),
|
||||
])
|
||||
}
|
||||
|
||||
override public func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
if (captureSession?.isRunning == true) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
self.captureSession.stopRunning()
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
override public var prefersStatusBarHidden: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
.all
|
||||
}
|
||||
|
||||
/** Touch the screen for autofocus */
|
||||
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard touches.first?.view == view,
|
||||
let touchPoint = touches.first,
|
||||
let device = delegate?.parent.videoCaptureDevice ?? fallbackVideoCaptureDevice
|
||||
else { return }
|
||||
|
||||
let videoView = view
|
||||
let screenSize = videoView!.bounds.size
|
||||
let xPoint = touchPoint.location(in: videoView).y / screenSize.height
|
||||
let yPoint = 1.0 - touchPoint.location(in: videoView).x / screenSize.width
|
||||
let focusPoint = CGPoint(x: xPoint, y: yPoint)
|
||||
|
||||
do {
|
||||
try device.lockForConfiguration()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// Focus to the correct point, make continiuous focus and exposure so the point stays sharp when moving the device closer
|
||||
device.focusPointOfInterest = focusPoint
|
||||
device.focusMode = .continuousAutoFocus
|
||||
device.exposurePointOfInterest = focusPoint
|
||||
device.exposureMode = AVCaptureDevice.ExposureMode.continuousAutoExposure
|
||||
device.unlockForConfiguration()
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
func updateViewController(isTorchOn: Bool, isGalleryPresented: Bool) {
|
||||
if let backCamera = AVCaptureDevice.default(for: AVMediaType.video),
|
||||
backCamera.hasTorch
|
||||
{
|
||||
try? backCamera.lockForConfiguration()
|
||||
backCamera.torchMode = isTorchOn ? .on : .off
|
||||
backCamera.unlockForConfiguration()
|
||||
}
|
||||
|
||||
if isGalleryPresented && !isGalleryShowing {
|
||||
openGallery()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -99,10 +99,7 @@ struct ConfigView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section(
|
||||
header: Text(NSLocalizedString("Version", comment: "Section title for displaying the version number of the Damus app.")),
|
||||
footer: Text("").padding(.bottom, tabHeight + getSafeAreaBottom())
|
||||
) {
|
||||
Section(NSLocalizedString("Version", comment: "Section title for displaying the version number of the Damus app.")) {
|
||||
Text(verbatim: VersionInfo.version)
|
||||
.contextMenu {
|
||||
Button {
|
||||
|
||||
@@ -28,7 +28,7 @@ struct CreateAccountView: View {
|
||||
Spacer()
|
||||
VStack(alignment: .center) {
|
||||
|
||||
EditPictureControl(uploader: .nostrBuild, keypair: account.keypair, pubkey: account.pubkey, size: 75, setup: true, image_url: $account.profile_image , uploadObserver: profileUploadObserver, callback: uploadedProfilePicture)
|
||||
EditPictureControl(uploader: .nostrBuild, pubkey: account.pubkey, size: 75, setup: true, image_url: $account.profile_image , uploadObserver: profileUploadObserver, callback: uploadedProfilePicture)
|
||||
.shadow(radius: 2)
|
||||
.padding(.top, 100)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import Combine
|
||||
|
||||
struct DMChatView: View, KeyboardReadable {
|
||||
let damus_state: DamusState
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
@ObservedObject var dms: DirectMessageModel
|
||||
|
||||
var pubkey: Pubkey {
|
||||
@@ -47,7 +46,6 @@ struct DMChatView: View, KeyboardReadable {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, isTextFieldFocused ? 0 : tabHeight)
|
||||
}
|
||||
|
||||
func scroll_to_end(_ scroller: ScrollViewProxy, animated: Bool = false) {
|
||||
@@ -76,7 +74,6 @@ struct DMChatView: View, KeyboardReadable {
|
||||
.textEditorBackground {
|
||||
InputBackground()
|
||||
}
|
||||
.focused($isTextFieldFocused)
|
||||
.cornerRadius(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
|
||||
@@ -35,7 +35,6 @@ struct DirectMessagesView: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.bottom, tabHeight)
|
||||
}
|
||||
|
||||
func filter_dms(dms: [DirectMessageModel]) -> [DirectMessageModel] {
|
||||
|
||||
@@ -59,7 +59,7 @@ struct HighlightEventRef: View {
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(longform_event.title ?? NSLocalizedString("Untitled", comment: "Title of longform event if it is untitled."))
|
||||
Text(longform_event.title ?? "Untitled")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.lineLimit(1)
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ struct LongformPreviewBody: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text(event.title ?? NSLocalizedString("Untitled", comment: "Title of longform event if it is untitled."))
|
||||
Text(event.title ?? "Untitled")
|
||||
.font(header ? .title : .headline)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 5)
|
||||
|
||||
@@ -24,7 +24,7 @@ struct LongformView: View {
|
||||
|
||||
var body: some View {
|
||||
EventShell(state: state, event: event.event, options: options) {
|
||||
SelectableText(damus_state: state, event: event.event, attributedString: AttributedString(stringLiteral: event.title ?? NSLocalizedString("Untitled", comment: "Title of longform event if it is untitled.")), size: .title)
|
||||
SelectableText(damus_state: state, event: event.event, attributedString: AttributedString(stringLiteral: event.title ?? "Untitled"), size: .title)
|
||||
|
||||
NoteContentView(damus_state: state, event: event.event, blur_images: false, size: .selected, options: options)
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
//
|
||||
// DamusFullScreenCover.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-10-25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
// MARK: - Private view modifier implementations of DamusFullScreenCover
|
||||
|
||||
/// This implements a full screen cover made for use in Damus.
|
||||
/// This was created as a way to facilitate video coordination throughout the app, by handling the necessary logic — without requiring any special handling in the usages of video player views.
|
||||
///
|
||||
/// In the future this could be used to faciliate other full screen logic as well.
|
||||
///
|
||||
/// # Features
|
||||
///
|
||||
/// This has the following features:
|
||||
/// 1. It automatically tells the video coordinator about full screen mode changes, so that the video coordinator always knows if the app is in normal mode or in full screen mode for video coordination
|
||||
/// 2. It automatically sets the `view_layer_context`, which is consumed by video player views, allowing those views to communicate about their layer position to the video coordinator
|
||||
fileprivate struct DamusFullScreenCover<FullScreenContent: View, T: Identifiable & Equatable>: ViewModifier {
|
||||
/// The `damus_state`, where we can access the video coordinator
|
||||
let damus_state: DamusState
|
||||
/// The item to be presented full screen
|
||||
@Binding var item: T?
|
||||
/// The view to be presented full screen
|
||||
let full_screen_content: (T) -> FullScreenContent
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.environment(\.view_layer_context, .normal_layer) // Let the views under content know they are NOT in a full screen environment
|
||||
.onChange(of: item, perform: { newValue in
|
||||
// Inform the video coordinator whether we are in full screen mode or not.
|
||||
damus_state.video.set_full_screen_mode(newValue != nil)
|
||||
})
|
||||
.fullScreenCover(item: $item, content: { item in
|
||||
full_screen_content(item)
|
||||
.environment(\.view_layer_context, .full_screen_layer) // Let the views under full screen content know they are in a full screen environment
|
||||
// Another observer for full screen presentation is needed here because in some cases the underlying view (`body::content`) may have been deinitialized and no longer listen to changes
|
||||
// One such example is when the underlying navigation stack navigates away from a source view at the same time it opens the full screen view
|
||||
// Therefore, when the full screen view is dismissed, this content will disappear, and we should notify the video coordinator.
|
||||
.onDisappear {
|
||||
damus_state.video.set_full_screen_mode(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A convenience view modifier that provides a different interface than `DamusFullScreenCover`, but is otherwise identical to it.
|
||||
fileprivate struct DamusFullScreenCoverWithoutItem<FullScreenContent: View>: ViewModifier {
|
||||
let damus_state: DamusState
|
||||
@Binding var is_presented: Bool
|
||||
let full_screen_content: () -> FullScreenContent
|
||||
private let fake_item: FakeItem = FakeItem()
|
||||
private var binding_item: Binding<FakeItem?> {
|
||||
return Binding(
|
||||
get: { is_presented ? self.fake_item : nil },
|
||||
set: { is_presented = $0 != nil ? true : false }
|
||||
)
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.damus_full_screen_cover(self.binding_item, damus_state: damus_state, content: { _ in full_screen_content() })
|
||||
}
|
||||
|
||||
private struct FakeItem: Identifiable, Equatable {
|
||||
let id: Int = 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Environment variable definitions
|
||||
|
||||
extension EnvironmentValues {
|
||||
@Entry var view_layer_context: ViewLayerContext? = nil
|
||||
}
|
||||
|
||||
|
||||
/// Context about the layer a view finds itself in
|
||||
/// This communicates to a view (e.g. a video player) context about whether it is being displayed inside a full screen layer, or a normal layer
|
||||
enum ViewLayerContext {
|
||||
/// This is used for items placed in a scroll view, such as on a timeline or a thread view.
|
||||
case normal_layer
|
||||
/// This is used for video players being displayed full screen
|
||||
case full_screen_layer
|
||||
}
|
||||
|
||||
|
||||
// MARK: - View extension interfaces to access Damus' full screen cover
|
||||
|
||||
extension View {
|
||||
|
||||
/// A full screen cover to be used throughout Damus, containing extra functionality that helps with app coordination, and is meant to replace `.fullScreenCover`
|
||||
///
|
||||
/// ## Usage notes
|
||||
///
|
||||
/// This is the preferred method of doing a full screen cover. This is preferred over `.fullScreenCover` because it helps with certain coordination elements:
|
||||
///
|
||||
/// 1. It automatically informs the video coordinator if the app is in full screen or not
|
||||
/// 2. It provides contextual information that any child view can pickup to introspect whether or not they are in a full screen layer. This can be picked up via the `\.view_layer_context` environment variable
|
||||
///
|
||||
/// **CAUTION:**
|
||||
/// If you are planning to use this from a view that is presented on a timeline or lazy stack, please use `present(full_screen_item: FullScreenItem)` instead to avoid your full screen view to abruptly disappear.
|
||||
/// Please read the documentation for `present(full_screen_item: FullScreenItem)` for more details.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - is_presented: whether to show the full screen cover
|
||||
/// - damus_state: The state of the app
|
||||
/// - content: The view to show full screen
|
||||
/// - Returns: the modified view
|
||||
func damus_full_screen_cover<Content: View>(_ is_presented: Binding<Bool>, damus_state: DamusState, @ViewBuilder content: @escaping () -> Content) -> some View {
|
||||
return self.modifier(DamusFullScreenCoverWithoutItem(damus_state: damus_state, is_presented: is_presented, full_screen_content: content))
|
||||
}
|
||||
|
||||
/// A full screen cover to be used throughout Damus, containing extra functionality that helps with app coordination, and is meant to replace `.fullScreenCover`
|
||||
///
|
||||
/// ## Usage notes
|
||||
///
|
||||
/// This is the preferred method of doing a full screen cover. This is preferred over `.fullScreenCover` because it helps with certain coordination elements:
|
||||
///
|
||||
/// 1. It automatically informs the video coordinator if the app is in full screen or not
|
||||
/// 2. It provides contextual information that any child view can pickup to introspect whether or not they are in a full screen layer. This can be picked up via the `\.view_layer_context` environment variable
|
||||
///
|
||||
/// **CAUTION:**
|
||||
/// If you are planning to use this from a view that is presented on a timeline or lazy stack, please use `present(full_screen_item: FullScreenItem)` instead to avoid your full screen view to abruptly disappear.
|
||||
/// Please read the documentation for `present(full_screen_item: FullScreenItem)` for more details.
|
||||
///
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - item: The item to be displayed full screen, or `nil` if full screen should be dismissed.
|
||||
/// - damus_state: The state of the app
|
||||
/// - content: The view to render `item`
|
||||
/// - Returns: the modified view
|
||||
func damus_full_screen_cover<Content: View, T: Identifiable & Equatable>(_ item: Binding<T?>, damus_state: DamusState, @ViewBuilder content: @escaping (T) -> Content) -> some View {
|
||||
return self.modifier(DamusFullScreenCover(damus_state: damus_state, item: item, full_screen_content: content))
|
||||
}
|
||||
}
|
||||
@@ -10,119 +10,27 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
/// Watches for visibility changes. Does not detect occlusion
|
||||
///
|
||||
/// ## Usage notes
|
||||
///
|
||||
/// 1. Detection mechanisms are not perfect, parameters may need fine tuning. Please refer to `VisibilityTracker` documentation for more details.
|
||||
/// 2. This does **not** detect if the view has been occluded. There are currently no known mechanisms to do that.
|
||||
/// If occlusion tracking is needed for your usage, consider using layout knowledge/introspection of the different layers that make up the view, and using that information for your logic.
|
||||
/// For example, when dealing with items on a normal view, and a full screen cover, write your logic based on explicit information about which views are in the full screen layer.
|
||||
/// Read about `present(full_screen_item: FullScreenItem)`, `damus_full_screen_cover`, and the `.view_layer_context` environment variable.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - visibility_change_notifier: Function to call once visibility changes
|
||||
/// - edge: Edge for the visibility overlay sensor
|
||||
/// - method: The method to use for visibility tracking. Refer to `VisibilityTracker` documentation for more details.
|
||||
/// - Returns: A modified view.
|
||||
func on_visibility_change(perform visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment = .center, method: VisibilityTracker.Method = .standard) -> some View {
|
||||
self.modifier(VisibilityTracker(visibility_change_notifier: visibility_change_notifier, edge: edge, method: method))
|
||||
func on_visibility_change(perform visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment = .center) -> some View {
|
||||
self.modifier(VisibilityTracker(visibility_change_notifier: visibility_change_notifier, edge: edge))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Tracks visibility of a SwiftUI view.
|
||||
/// Built mostly to track visibility states of video players around the app and help the video coordinator pick a video to focus on, but can be used for basically any other view
|
||||
/// **Caution:** This is not a perfect tracker, please read and fine-tune parameters for your use case, especially `method`
|
||||
struct VisibilityTracker: ViewModifier {
|
||||
let visibility_window: CGFloat = 0.8
|
||||
let visibility_change_notifier: (Bool) -> Void
|
||||
let edge: Alignment
|
||||
let method: Method
|
||||
|
||||
init(visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment, method: Method) {
|
||||
self.visibility_change_notifier = visibility_change_notifier
|
||||
self.edge = edge
|
||||
self.method = method
|
||||
}
|
||||
|
||||
@EnvironmentObject private var orientationTracker: OrientationTracker
|
||||
/// Holds information about whether the view is "generically" visible, meaning whether it would have been loaded on a lazy stack.
|
||||
@State private var generic_visible: Bool = false {
|
||||
didSet {
|
||||
if oldValue == generic_visible { return } // Save up computing resources if there were no changes
|
||||
self.visibility_change_notifier(self.is_visible)
|
||||
}
|
||||
}
|
||||
/// Whether the view is visible by checking if its Y position is within a range of the user's screen
|
||||
@State private var y_scroll_visible: Bool = false {
|
||||
didSet {
|
||||
switch self.method {
|
||||
case .standard:
|
||||
if oldValue == y_scroll_visible { return } // Save up computing resources if there were no changes
|
||||
self.visibility_change_notifier(self.is_visible)
|
||||
case .no_y_scroll_detection:
|
||||
return // Don't cause re-renders if the visibility method does not use this
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether view is "visible"
|
||||
var is_visible: Bool {
|
||||
switch method {
|
||||
case .standard:
|
||||
return generic_visible && y_scroll_visible
|
||||
case .no_y_scroll_detection:
|
||||
return generic_visible
|
||||
}
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay(
|
||||
GeometryReader { geo in
|
||||
let localFrame = geo.frame(in: .local)
|
||||
let centerY = globalCoordinate(localX: 0, localY: localFrame.midY, localGeometry: geo).y
|
||||
LazyVStack {
|
||||
Color.clear
|
||||
// MARK: Detection triggers
|
||||
.onAppear {
|
||||
self.generic_visible = true
|
||||
self.y_scroll_visible = self.compute_y_scroll_visible(centerY: centerY)
|
||||
}
|
||||
.onDisappear {
|
||||
self.generic_visible = false
|
||||
}
|
||||
.onChange(of: centerY) { new_center_y in
|
||||
if generic_visible == false { return } // Don't bother calculating anything if this is not visible generically, to save up computing resources
|
||||
self.y_scroll_visible = self.compute_y_scroll_visible(
|
||||
centerY: new_center_y // Compute the new Y scroll visibility using the newest value to avoid transient issues on device orientation changes
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
alignment: edge)
|
||||
}
|
||||
|
||||
/// Computes whether the view is "visible" in a range of the screen given its Y position
|
||||
private func compute_y_scroll_visible(centerY: CGFloat) -> Bool {
|
||||
let screen_center_y = orientationTracker.deviceMajorAxis / 2
|
||||
let screen_visibility_window_margin = orientationTracker.deviceMajorAxis * visibility_window / 2
|
||||
let isBelowTop = centerY > screen_center_y - screen_visibility_window_margin,
|
||||
isAboveBottom = centerY < screen_center_y + screen_visibility_window_margin
|
||||
return (isBelowTop && isAboveBottom)
|
||||
}
|
||||
|
||||
/// The methods available for visibility detection.
|
||||
/// Unfortunately, there is currently no perfect visibility detection mechanism, so callers of `VisibilityTracker` should select a method that best suits the context of the view.
|
||||
enum Method: Equatable {
|
||||
/// Includes both a generic and Y coordinate based visibility detection.
|
||||
/// When this option is selected, the view is only deemed visible if both lazy view evaluators load it (when close enough to viewport), and the center Y coordinate is sufficiently in the center
|
||||
/// This is best for most view presentations, specially for scroll views.
|
||||
case standard
|
||||
/// Includes only a generic visibility detection based on a lazy view loader
|
||||
/// When this option is selected, the view is only deemed visible if the lazy view evaluators load it (which SwiftUI does when it is close enough to viewport), regardless of Y coordinate
|
||||
/// This is not suitable for scroll views or most presentations because it may trigger too early, leading to false positives. This is more suitable when the standard detection mechanism is triggering too many false negatives, and this is a more "static" view
|
||||
/// For example: when displaying an item in full screen mode where it is visible in a more stable, static form, and device orientation changes may cause transient visibility triggers
|
||||
case no_y_scroll_detection
|
||||
content
|
||||
.overlay(
|
||||
LazyVStack {
|
||||
Color.clear
|
||||
.onAppear {
|
||||
visibility_change_notifier(true)
|
||||
}
|
||||
.onDisappear {
|
||||
visibility_change_notifier(false)
|
||||
}
|
||||
},
|
||||
alignment: edge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// ImageView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by user232838 on 1/5/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ImageView: View {
|
||||
var body: some View {
|
||||
Text(verbatim: /*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ImageView()
|
||||
}
|
||||
}
|
||||
@@ -8,37 +8,37 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FullScreenCarouselView<Content: View>: View {
|
||||
@ObservedObject var video_coordinator: DamusVideoCoordinator
|
||||
let video_controller: VideoController
|
||||
let urls: [MediaUrl]
|
||||
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
@State var showMenu = true
|
||||
@State private var imageDict: [URL: UIImage] = [:]
|
||||
|
||||
let settings: UserSettingsStore
|
||||
@ObservedObject var carouselSelection: CarouselSelection
|
||||
@Binding var selectedIndex: Int
|
||||
let content: (() -> Content)?
|
||||
|
||||
init(video_coordinator: DamusVideoCoordinator, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.video_coordinator = video_coordinator
|
||||
init(video_controller: VideoController, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.video_controller = video_controller
|
||||
self.urls = urls
|
||||
self._showMenu = State(initialValue: showMenu)
|
||||
self.settings = settings
|
||||
self._carouselSelection = ObservedObject(initialValue: CarouselSelection(index: selectedIndex.wrappedValue))
|
||||
_selectedIndex = selectedIndex
|
||||
self.content = content
|
||||
}
|
||||
|
||||
init(video_coordinator: DamusVideoCoordinator, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>) {
|
||||
self.video_coordinator = video_coordinator
|
||||
init(video_controller: VideoController, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>) {
|
||||
self.video_controller = video_controller
|
||||
self.urls = urls
|
||||
self._showMenu = State(initialValue: showMenu)
|
||||
self.settings = settings
|
||||
self._carouselSelection = ObservedObject(initialValue: CarouselSelection(index: selectedIndex.wrappedValue))
|
||||
_selectedIndex = selectedIndex
|
||||
self.content = nil
|
||||
}
|
||||
|
||||
var background: some ShapeStyle {
|
||||
if case .video = urls[safe: carouselSelection.index] {
|
||||
if case .video = urls[safe: selectedIndex] {
|
||||
return AnyShapeStyle(Color.black)
|
||||
}
|
||||
else {
|
||||
@@ -55,24 +55,23 @@ struct FullScreenCarouselView<Content: View>: View {
|
||||
Color(self.background_color)
|
||||
.ignoresSafeArea()
|
||||
|
||||
TabView(selection: $carouselSelection.index) {
|
||||
TabView(selection: $selectedIndex) {
|
||||
ForEach(urls.indices, id: \.self) { index in
|
||||
VStack {
|
||||
if case .video = urls[safe: index] {
|
||||
ImageContainerView(
|
||||
video_coordinator: video_coordinator,
|
||||
url: urls[index],
|
||||
settings: settings,
|
||||
imageDict: $imageDict
|
||||
)
|
||||
.modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}))
|
||||
.ignoresSafeArea()
|
||||
ImageContainerView(video_controller: video_controller, url: urls[index], settings: settings)
|
||||
.clipped() // SwiftUI hack from https://stackoverflow.com/a/74401288 to make playback controls show up within the TabView
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(.top, Theme.safeAreaInsets?.top)
|
||||
.padding(.bottom, Theme.safeAreaInsets?.bottom)
|
||||
.modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}))
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
else {
|
||||
ZoomableScrollView {
|
||||
ImageContainerView(video_coordinator: video_coordinator, url: urls[index], settings: settings, imageDict: $imageDict)
|
||||
ImageContainerView(video_controller: video_controller, url: urls[index], settings: settings)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(.top, Theme.safeAreaInsets?.top)
|
||||
.padding(.bottom, Theme.safeAreaInsets?.bottom)
|
||||
@@ -97,49 +96,17 @@ struct FullScreenCarouselView<Content: View>: View {
|
||||
GeometryReader { geo in
|
||||
VStack {
|
||||
if showMenu {
|
||||
HStack {
|
||||
Button(action: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}, label: {
|
||||
Image(systemName: "xmark")
|
||||
.frame(width: 30, height: 30)
|
||||
})
|
||||
.buttonStyle(PlayerCircleButtonStyle())
|
||||
|
||||
Spacer()
|
||||
|
||||
if let url = urls[safe: carouselSelection.index],
|
||||
let image = imageDict[url.url] {
|
||||
|
||||
ShareLink(item: Image(uiImage: image),
|
||||
preview: SharePreview(NSLocalizedString("Shared Picture",
|
||||
comment: "Label for the preview of the image being picture"),
|
||||
image: Image(uiImage: image))) {
|
||||
Image(systemName: "ellipsis")
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
.buttonStyle(PlayerCircleButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
NavDismissBarView(showBackgroundCircle: false)
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
|
||||
VStack {
|
||||
if urls.count > 1 {
|
||||
PageControlView(currentPage: $carouselSelection.index, numberOfPages: urls.count)
|
||||
.frame(maxWidth: 0, maxHeight: 0)
|
||||
.padding(.top, 5)
|
||||
}
|
||||
|
||||
if let focused_video = video_coordinator.focused_video {
|
||||
DamusVideoControlsView(video: focused_video)
|
||||
}
|
||||
|
||||
self.content?()
|
||||
if urls.count > 1 {
|
||||
PageControlView(currentPage: $selectedIndex, numberOfPages: urls.count)
|
||||
.frame(maxWidth: 0, maxHeight: 0)
|
||||
.padding(.top, 5)
|
||||
}
|
||||
.padding(.top, 5)
|
||||
.background(Color.black.opacity(0.7))
|
||||
|
||||
self.content?()
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut, value: showMenu)
|
||||
@@ -161,7 +128,7 @@ fileprivate struct FullScreenCarouselPreviewView<Content: View>: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
FullScreenCarouselView(video_coordinator: test_damus_state.video, urls: [test_video_url, url], settings: test_damus_state.settings, selectedIndex: $selectedIndex) {
|
||||
FullScreenCarouselView(video_controller: test_damus_state.video, urls: [test_video_url, url], settings: test_damus_state.settings, selectedIndex: $selectedIndex) {
|
||||
self.custom_content?()
|
||||
}
|
||||
.environmentObject(OrientationTracker())
|
||||
@@ -189,11 +156,3 @@ struct FullScreenCarouselView_Previews: PreviewProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Class to define object for monitoring selectedIndex and updating mutlples views
|
||||
final class CarouselSelection: ObservableObject {
|
||||
@Published var index: Int
|
||||
init(index: Int) {
|
||||
self.index = index
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,29 +10,18 @@ import Kingfisher
|
||||
|
||||
|
||||
struct ImageContainerView: View {
|
||||
let video_coordinator: DamusVideoCoordinator
|
||||
let video_controller: VideoController
|
||||
let url: MediaUrl
|
||||
let settings: UserSettingsStore
|
||||
|
||||
@Binding var imageDict: [URL: UIImage]
|
||||
@State private var image: UIImage?
|
||||
@State private var showShareSheet = false
|
||||
|
||||
init(video_coordinator: DamusVideoCoordinator, url: MediaUrl, settings: UserSettingsStore, imageDict: Binding<[URL: UIImage]>) {
|
||||
self.video_coordinator = video_coordinator
|
||||
self.url = url
|
||||
self.settings = settings
|
||||
self._imageDict = imageDict
|
||||
}
|
||||
|
||||
private struct ImageHandler: ImageModifier {
|
||||
@Binding var handler: UIImage?
|
||||
@Binding var imageDict: [URL: UIImage]
|
||||
let url: URL
|
||||
|
||||
func modify(_ image: UIImage) -> UIImage {
|
||||
handler = image
|
||||
imageDict[url] = image
|
||||
return image
|
||||
}
|
||||
}
|
||||
@@ -43,7 +32,7 @@ struct ImageContainerView: View {
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
.imageModifier(ImageHandler(handler: $image, imageDict: $imageDict, url: url))
|
||||
.imageModifier(ImageHandler(handler: $image))
|
||||
.kfClickable()
|
||||
.clipped()
|
||||
.modifier(ImageContextMenuModifier(url: url, image: image, settings: settings, showShareSheet: $showShareSheet))
|
||||
@@ -58,7 +47,7 @@ struct ImageContainerView: View {
|
||||
case .image(let url):
|
||||
Img(url: url)
|
||||
case .video(let url):
|
||||
DamusVideoPlayerView(url: url, coordinator: video_coordinator, style: .no_controls(on_tap: nil))
|
||||
DamusVideoPlayer(url: url, video_size: .constant(nil), controller: video_controller, style: .full, visibility_tracking_method: .generic)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,11 +58,10 @@ fileprivate let test_video_url = URL(string: "http://cdn.jb55.com/s/zaps-build.m
|
||||
|
||||
struct ImageContainerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
@State var imageDict: [URL: UIImage] = [:]
|
||||
Group {
|
||||
ImageContainerView(video_coordinator: test_damus_state.video, url: .image(test_image_url), settings: test_damus_state.settings, imageDict: $imageDict)
|
||||
ImageContainerView(video_controller: test_damus_state.video, url: .image(test_image_url), settings: test_damus_state.settings)
|
||||
.previewDisplayName("Image")
|
||||
ImageContainerView(video_coordinator: test_damus_state.video, url: .video(test_video_url), settings: test_damus_state.settings, imageDict: $imageDict)
|
||||
ImageContainerView(video_controller: test_damus_state.video, url: .video(test_video_url), settings: test_damus_state.settings)
|
||||
.previewDisplayName("Video")
|
||||
}
|
||||
.environmentObject(OrientationTracker())
|
||||
|
||||
@@ -78,7 +78,7 @@ struct ImageContextMenuModifier: ViewModifier {
|
||||
Label(NSLocalizedString("Share", comment: "Button to share an image."), image: "upload")
|
||||
}
|
||||
}
|
||||
.alert(String(format: NSLocalizedString("Found\n %@", comment: "Alert message asking if the user wants to open the link."), qrCodeValue).truncate(maxLength: 50), isPresented: $open_link_confirm) {
|
||||
.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 {
|
||||
|
||||
@@ -10,7 +10,8 @@ import Kingfisher
|
||||
struct ProfileImageContainerView: View {
|
||||
let url: URL?
|
||||
let settings: UserSettingsStore
|
||||
@Binding var image: UIImage?
|
||||
|
||||
@State private var image: UIImage?
|
||||
@State private var showShareSheet = false
|
||||
|
||||
private struct ImageHandler: ImageModifier {
|
||||
@@ -39,18 +40,13 @@ struct ProfileImageContainerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
enum NavDismissBarContainer {
|
||||
case fullScreenCarousel
|
||||
case profilePicImageView
|
||||
}
|
||||
|
||||
struct NavDismissBarView: View {
|
||||
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
let navDismissBarContainer: NavDismissBarContainer
|
||||
let showBackgroundCircle: Bool
|
||||
|
||||
init(navDismissBarContainer: NavDismissBarContainer) {
|
||||
self.navDismissBarContainer = navDismissBarContainer
|
||||
init(showBackgroundCircle: Bool = true) {
|
||||
self.showBackgroundCircle = showBackgroundCircle
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -58,18 +54,15 @@ struct NavDismissBarView: View {
|
||||
Button(action: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}, label: {
|
||||
switch navDismissBarContainer {
|
||||
case .profilePicImageView:
|
||||
if showBackgroundCircle {
|
||||
Image("close")
|
||||
.frame(width: 33, height: 33)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Circle())
|
||||
|
||||
case .fullScreenCarousel:
|
||||
}
|
||||
else {
|
||||
Image("close")
|
||||
.frame(width: 33, height: 33)
|
||||
.background(.damusBlack)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -83,10 +76,6 @@ struct ProfilePicImageView: View {
|
||||
let pubkey: Pubkey
|
||||
let profiles: Profiles
|
||||
let settings: UserSettingsStore
|
||||
let nav: NavigationCoordinator
|
||||
let shouldShowEditButton: Bool
|
||||
@State var image: UIImage?
|
||||
@State var showMenu = true
|
||||
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
@@ -96,57 +85,18 @@ struct ProfilePicImageView: View {
|
||||
.ignoresSafeArea()
|
||||
|
||||
ZoomableScrollView {
|
||||
ProfileImageContainerView(url: get_profile_url(picture: nil, pubkey: pubkey, profiles: profiles), settings: settings, image: $image)
|
||||
ProfileImageContainerView(url: get_profile_url(picture: nil, pubkey: pubkey, profiles: profiles), settings: settings)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(.top, Theme.safeAreaInsets?.top)
|
||||
.padding(.bottom, Theme.safeAreaInsets?.bottom)
|
||||
.padding(.horizontal)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}))
|
||||
}
|
||||
.overlay(
|
||||
Group {
|
||||
if showMenu {
|
||||
HStack {
|
||||
NavDismissBarView(navDismissBarContainer: .profilePicImageView)
|
||||
if let image = image {
|
||||
ShareLink(item: Image(uiImage: image),
|
||||
preview: SharePreview(NSLocalizedString("Damus Profile", comment: "Label for the preview of the profile picture"), image: Image(uiImage: image))) {
|
||||
Image(systemName: "ellipsis")
|
||||
.frame(width: 33, height: 33)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
alignment: .top
|
||||
)
|
||||
.overlay(
|
||||
shouldShowEditButton && showMenu ?
|
||||
Button(action: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
nav.push(route: Route.EditMetadata)
|
||||
}) {
|
||||
Text("Edit", comment: "Edit Button for editing profile")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(Color("DamusPurple"))
|
||||
Spacer()
|
||||
}
|
||||
.padding([.vertical, .leading], 20)
|
||||
: nil,
|
||||
alignment: .bottomLeading
|
||||
)
|
||||
.gesture(TapGesture(count: 1).onEnded {
|
||||
showMenu.toggle()
|
||||
})
|
||||
.animation(.easeInOut, value: showMenu)
|
||||
.overlay(NavDismissBarView(), alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +105,7 @@ struct ProfileZoomView_Previews: PreviewProvider {
|
||||
ProfilePicImageView(
|
||||
pubkey: test_pubkey,
|
||||
profiles: make_preview_profiles(test_pubkey),
|
||||
settings: test_damus_state.settings, nav: test_damus_state.nav, shouldShowEditButton: true)
|
||||
settings: test_damus_state.settings
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+19
-32
@@ -5,7 +5,6 @@
|
||||
// Created by William Casarin on 2022-05-22.
|
||||
//
|
||||
|
||||
import CodeScanner
|
||||
import SwiftUI
|
||||
|
||||
enum ParsedKey {
|
||||
@@ -104,7 +103,6 @@ struct LoginView: View {
|
||||
}
|
||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: 12, alignment: .center)
|
||||
}
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.sign_in_confirm_button.rawValue)
|
||||
.buttonStyle(GradientButtonStyle())
|
||||
.padding(.top, 10)
|
||||
}
|
||||
@@ -300,35 +298,27 @@ struct KeyInput: View {
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Button(action: {
|
||||
if let pastedkey = UIPasteboard.general.string {
|
||||
self.key.wrappedValue = pastedkey
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
.foregroundColor(.gray)
|
||||
.onTapGesture {
|
||||
if let pastedkey = UIPasteboard.general.string {
|
||||
self.key.wrappedValue = pastedkey
|
||||
}
|
||||
}
|
||||
}, label: {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
})
|
||||
.foregroundColor(.gray)
|
||||
.accessibilityLabel(NSLocalizedString("Paste private key", comment: "Accessibility label for the private key paste button"))
|
||||
|
||||
SignInScan(shouldSaveKey: shouldSaveKey, loginKey: key, privKeyFound: privKeyFound)
|
||||
|
||||
if is_secured {
|
||||
SecureField("", text: key)
|
||||
.nsecLoginStyle(key: key.wrappedValue, title: title)
|
||||
.accessibilityLabel(NSLocalizedString("Account private key", comment: "Accessibility label for the private key input field"))
|
||||
} else {
|
||||
TextField("", text: key)
|
||||
.nsecLoginStyle(key: key.wrappedValue, title: title)
|
||||
.accessibilityLabel(NSLocalizedString("Account private key", comment: "Accessibility label for the private key input field"))
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
is_secured.toggle()
|
||||
}, label: {
|
||||
Image(systemName: "eye.slash")
|
||||
})
|
||||
.foregroundColor(.gray)
|
||||
.accessibilityLabel(NSLocalizedString("Toggle key visibility", comment: "Accessibility label for toggling the visibility of the private key input field"))
|
||||
SecureField("", text: key)
|
||||
.nsecLoginStyle(key: key.wrappedValue, title: title)
|
||||
} else {
|
||||
TextField("", text: key)
|
||||
.nsecLoginStyle(key: key.wrappedValue, title: title)
|
||||
}
|
||||
Image(systemName: "eye.slash")
|
||||
.foregroundColor(.gray)
|
||||
.onTapGesture {
|
||||
is_secured.toggle()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 10)
|
||||
@@ -351,7 +341,6 @@ struct SignInHeader: View {
|
||||
.frame(width: 56, height: 56, alignment: .center)
|
||||
.shadow(color: DamusColors.purple, radius: 2)
|
||||
.padding(.bottom)
|
||||
.accessibilityLabel(NSLocalizedString("Damus logo", comment: "Accessibility label for damus logo"))
|
||||
|
||||
Text("Sign in", comment: "Title of view to log into an account.")
|
||||
.foregroundColor(DamusColors.neutral6)
|
||||
@@ -375,12 +364,10 @@ struct SignInEntry: View {
|
||||
.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."),
|
||||
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)
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.sign_in_nsec_key_entry_field.rawValue)
|
||||
|
||||
if privKeyFound {
|
||||
Toggle(NSLocalizedString("Save Key in Secure Keychain", comment: "Toggle to save private key to the Apple secure keychain."), isOn: shouldSaveKey)
|
||||
}
|
||||
@@ -401,7 +388,7 @@ struct SignInScan: View {
|
||||
Button(action: { showQR.toggle() }, label: {
|
||||
Image(systemName: "qrcode.viewfinder")})
|
||||
.foregroundColor(.gray)
|
||||
.accessibilityLabel(NSLocalizedString("Scan QR code", comment: "Accessibility label for a button that scans a private key QR code"))
|
||||
|
||||
}
|
||||
.sheet(isPresented: $showQR, onDismiss: {
|
||||
if qrkey == nil { resetView() }}
|
||||
|
||||
@@ -66,9 +66,7 @@ struct TabButton: View {
|
||||
|
||||
struct TabBar: View {
|
||||
var nstatus: NotificationStatusModel
|
||||
var navIsAtRoot: Bool
|
||||
@Binding var selected: Timeline
|
||||
@Binding var headerOffset: CGFloat
|
||||
|
||||
let settings: UserSettingsStore
|
||||
let action: (Timeline) -> ()
|
||||
@@ -83,6 +81,5 @@ struct TabBar: View {
|
||||
TabButton(timeline: .notifications, img: "notification-bell", selected: $selected, nstatus: nstatus, settings: settings, action: action).keyboardShortcut("4")
|
||||
}
|
||||
}
|
||||
.opacity(selected != .home || (selected == .home && !navIsAtRoot) ? 1.0 : 0.35 + abs(1.25 - (abs(headerOffset/100.0))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,27 +9,18 @@ import UIKit
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
enum MediaPickerEntry {
|
||||
case editPictureControl
|
||||
case postView
|
||||
}
|
||||
|
||||
struct MediaPicker: UIViewControllerRepresentable {
|
||||
|
||||
@Environment(\.presentationMode)
|
||||
@Binding private var presentationMode
|
||||
let mediaPickerEntry: MediaPickerEntry
|
||||
|
||||
@Binding var image_upload_confirm: Bool
|
||||
var imagesOnly: Bool = false
|
||||
let onMediaPicked: (PreUploadedMedia) -> Void
|
||||
|
||||
|
||||
final class Coordinator: NSObject, PHPickerViewControllerDelegate {
|
||||
var parent: MediaPicker
|
||||
|
||||
// properties used for returning medias in the same order as picking
|
||||
let dispatchGroup: DispatchGroup = DispatchGroup()
|
||||
var orderIds: [String] = []
|
||||
var orderMap: [String: PreUploadedMedia] = [:]
|
||||
let parent: MediaPicker
|
||||
|
||||
init(_ parent: MediaPicker) {
|
||||
self.parent = parent
|
||||
@@ -40,16 +31,7 @@ struct MediaPicker: UIViewControllerRepresentable {
|
||||
self.parent.presentationMode.dismiss()
|
||||
}
|
||||
|
||||
// When user dismiss the upload confirmation and re-adds again, reset orderIds and orderMap
|
||||
orderIds.removeAll()
|
||||
orderMap.removeAll()
|
||||
|
||||
for result in results {
|
||||
|
||||
let orderId = result.assetIdentifier ?? UUID().uuidString
|
||||
orderIds.append(orderId)
|
||||
dispatchGroup.enter()
|
||||
|
||||
if result.itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
||||
result.itemProvider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { (item, error) in
|
||||
guard let url = item as? URL else { return }
|
||||
@@ -68,7 +50,7 @@ struct MediaPicker: UIViewControllerRepresentable {
|
||||
do {
|
||||
try imageData.write(to: destinationURL)
|
||||
Task {
|
||||
await self.chooseMedia(.processed_image(destinationURL), orderId: orderId)
|
||||
await self.chooseMedia(.processed_image(destinationURL))
|
||||
}
|
||||
}
|
||||
catch {
|
||||
@@ -82,13 +64,13 @@ struct MediaPicker: UIViewControllerRepresentable {
|
||||
url: url,
|
||||
fallback: processImage,
|
||||
unprocessedEnum: {.unprocessed_image($0)},
|
||||
processedEnum: {.processed_image($0)},
|
||||
orderId: orderId)
|
||||
processedEnum: {.processed_image($0)}
|
||||
)
|
||||
} else {
|
||||
// Media was taken from camera
|
||||
result.itemProvider.loadObject(ofClass: UIImage.self) { (image, error) in
|
||||
if let image = image as? UIImage, error == nil {
|
||||
self.chooseMedia(.uiimage(image), orderId: orderId)
|
||||
self.chooseMedia(.uiimage(image))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,60 +83,41 @@ struct MediaPicker: UIViewControllerRepresentable {
|
||||
url: url,
|
||||
fallback: processVideo,
|
||||
unprocessedEnum: {.unprocessed_video($0)},
|
||||
processedEnum: {.processed_video($0)}, orderId: orderId
|
||||
processedEnum: {.processed_video($0)}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.notify(queue: .main) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
var arrMedia: [PreUploadedMedia] = []
|
||||
for id in self.orderIds {
|
||||
if let media = self.orderMap[id] {
|
||||
arrMedia.append(media)
|
||||
self.parent.onMediaPicked(media)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func chooseMedia(_ media: PreUploadedMedia, orderId: String) {
|
||||
private func chooseMedia(_ media: PreUploadedMedia) {
|
||||
self.parent.onMediaPicked(media)
|
||||
self.parent.image_upload_confirm = true
|
||||
self.orderMap[orderId] = media
|
||||
self.dispatchGroup.leave()
|
||||
}
|
||||
|
||||
private func attemptAcquireResourceAndChooseMedia(url: URL, fallback: (URL) -> URL?, unprocessedEnum: (URL) -> PreUploadedMedia, processedEnum: (URL) -> PreUploadedMedia, orderId: String) {
|
||||
private func attemptAcquireResourceAndChooseMedia(url: URL, fallback: (URL) -> URL?, unprocessedEnum: (URL) -> PreUploadedMedia, processedEnum: (URL) -> PreUploadedMedia) {
|
||||
if url.startAccessingSecurityScopedResource() {
|
||||
// Have permission from system to use url out of scope
|
||||
print("Acquired permission to security scoped resource")
|
||||
self.chooseMedia(unprocessedEnum(url), orderId: orderId)
|
||||
self.chooseMedia(unprocessedEnum(url))
|
||||
} else {
|
||||
// Need to copy URL to non-security scoped location
|
||||
guard let newUrl = fallback(url) else { return }
|
||||
self.chooseMedia(processedEnum(newUrl), orderId: orderId)
|
||||
self.chooseMedia(processedEnum(newUrl))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> PHPickerViewController {
|
||||
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||
switch mediaPickerEntry {
|
||||
case .postView:
|
||||
configuration.selectionLimit = 0 // allows multiple media selection
|
||||
configuration.filter = .any(of: [.images, .videos])
|
||||
configuration.selection = .ordered // images are returned in the order they were selected + numbered badge displayed
|
||||
case .editPictureControl:
|
||||
configuration.selectionLimit = 1 // allows one media selection
|
||||
configuration.filter = .images // allows image only
|
||||
}
|
||||
configuration.selectionLimit = 1
|
||||
configuration.filter = imagesOnly ? .images : .any(of: [.images, .videos])
|
||||
|
||||
let picker = PHPickerViewController(configuration: configuration)
|
||||
picker.delegate = context.coordinator as any PHPickerViewControllerDelegate
|
||||
return picker
|
||||
|
||||
@@ -13,10 +13,6 @@ struct AddMuteItemView: View {
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var trimmedText: String {
|
||||
new_text.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("Add mute item", comment: "Title text to indicate user to an add an item to their mutelist.")
|
||||
@@ -34,13 +30,12 @@ struct AddMuteItemView: View {
|
||||
Text("Duration", comment: "The duration in which to mute the given item.")
|
||||
}
|
||||
|
||||
let trimmedText = self.trimmedText
|
||||
|
||||
HStack {
|
||||
Label("", image: "copy2")
|
||||
.onTapGesture {
|
||||
if let pasted_text = UIPasteboard.general.string {
|
||||
self.new_text = pasted_text.trimmingCharacters(in: .whitespaces)
|
||||
self.new_text = pasted_text
|
||||
}
|
||||
}
|
||||
TextField(NSLocalizedString("npub, #hashtag, phrase", comment: "Placeholder example for relay server address."), text: $new_text)
|
||||
@@ -49,7 +44,7 @@ struct AddMuteItemView: View {
|
||||
|
||||
Label("", image: "close-circle")
|
||||
.foregroundColor(.accentColor)
|
||||
.opacity(trimmedText.isEmpty ? 0.0 : 1.0)
|
||||
.opacity((new_text == "") ? 0.0 : 1.0)
|
||||
.onTapGesture {
|
||||
self.new_text = ""
|
||||
}
|
||||
@@ -61,17 +56,17 @@ struct AddMuteItemView: View {
|
||||
Button(action: {
|
||||
let expiration_date: Date? = self.expiration.date_from_now
|
||||
let mute_item: MuteItem? = {
|
||||
if trimmedText.starts(with: "npub") {
|
||||
if let pubkey: Pubkey = bech32_pubkey_decode(trimmedText) {
|
||||
if new_text.starts(with: "npub") {
|
||||
if let pubkey: Pubkey = bech32_pubkey_decode(new_text) {
|
||||
return .user(pubkey, expiration_date)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
} else if trimmedText.starts(with: "#") {
|
||||
} else if new_text.starts(with: "#") {
|
||||
// Remove the starting `#` character
|
||||
return .hashtag(Hashtag(hashtag: String("\(trimmedText)".dropFirst())), expiration_date)
|
||||
return .hashtag(Hashtag(hashtag: String("\(new_text)".dropFirst())), expiration_date)
|
||||
} else {
|
||||
return .word(trimmedText, expiration_date)
|
||||
return .word(new_text, expiration_date)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -97,15 +92,13 @@ struct AddMuteItemView: View {
|
||||
dismiss()
|
||||
}) {
|
||||
HStack {
|
||||
Text("Add mute item", comment: "Button to an add an item to the user's mutelist.")
|
||||
Text(verbatim: "Add mute item")
|
||||
.bold()
|
||||
}
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.buttonStyle(GradientButtonStyle(padding: 10))
|
||||
.padding(.vertical)
|
||||
.opacity(trimmedText.isEmpty ? 0.5 : 1.0)
|
||||
.disabled(trimmedText.isEmpty)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
@@ -86,10 +86,7 @@ struct MutelistView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(
|
||||
header: Text(NSLocalizedString("Threads", comment: "Section header title for a list of threads that are muted.")),
|
||||
footer: Text("").padding(.bottom, 10 + tabHeight + getSafeAreaBottom())
|
||||
) {
|
||||
Section(NSLocalizedString("Threads", comment: "Section header title for a list of threads that are muted.")) {
|
||||
ForEach(threads, id: \.self) { item in
|
||||
if case let MuteItem.thread(note_id, _) = item {
|
||||
if let event = damus_state.events.lookup(note_id) {
|
||||
|
||||
@@ -119,7 +119,15 @@ struct NoteContentView: View {
|
||||
}
|
||||
|
||||
func fullscreen_preview(dismiss: @escaping () -> Void) -> some View {
|
||||
EmptyView()
|
||||
VStack {
|
||||
EventView(damus: damus_state, event: self.event, options: .embedded_text_only)
|
||||
.padding(.top)
|
||||
}
|
||||
.background(.thickMaterial)
|
||||
.onTapGesture(perform: {
|
||||
damus_state.nav.push(route: Route.Thread(thread: .init(event: self.event, damus_state: damus_state)))
|
||||
dismiss()
|
||||
})
|
||||
}
|
||||
|
||||
func MainContent(artifacts: NoteArtifactsSeparated) -> some View {
|
||||
@@ -295,9 +303,7 @@ struct NoteContentView: View {
|
||||
case .separated(let separated):
|
||||
if #available(iOS 17.4, macOS 14.4, *) {
|
||||
MainContent(artifacts: separated)
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
.translationPresentation(isPresented: $isAppleTranslationPopoverPresented, text: event.get_content(damus_state.keypair))
|
||||
#endif
|
||||
} else {
|
||||
MainContent(artifacts: separated)
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ func event_group_unique_pubkeys(profiles: Profiles, group: EventGroupType) -> [P
|
||||
"zapped_your_profile_2" - returned when 2 zaps occurred to the current user's profile
|
||||
"zapped_your_profile_3" - returned when 3 or more zaps occurred to the current user's profile
|
||||
*/
|
||||
func reacting_to_text(profiles: Profiles, our_pubkey: Pubkey, group: EventGroupType, ev: NostrEvent?, pubkeys: [Pubkey], locale: Locale = Locale.current) -> String {
|
||||
func reacting_to_text(profiles: Profiles, our_pubkey: Pubkey, group: EventGroupType, ev: NostrEvent?, pubkeys: [Pubkey], locale: Locale? = nil) -> String {
|
||||
if group.events.count == 0 {
|
||||
return "??"
|
||||
}
|
||||
@@ -188,8 +188,7 @@ struct EventGroupView: View {
|
||||
let group: EventGroupType
|
||||
|
||||
func GroupDescription(_ pubkeys: [Pubkey]) -> some View {
|
||||
let text = reacting_to_text(profiles: state.profiles, our_pubkey: state.pubkey, group: group, ev: event, pubkeys: pubkeys)
|
||||
return Text(text)
|
||||
Text(verbatim: "\(reacting_to_text(profiles: state.profiles, our_pubkey: state.pubkey, group: group, ev: event, pubkeys: pubkeys))")
|
||||
}
|
||||
|
||||
func ZapIcon(_ zapgrp: ZapGroup) -> some View {
|
||||
|
||||
@@ -38,9 +38,7 @@ struct OnboardingSuggestionsView: View {
|
||||
}, label: {
|
||||
Text("Skip", comment: "Button to dismiss the suggested users screen")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
})
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_sheet_skip_button.rawValue)
|
||||
)
|
||||
}))
|
||||
.tag(0)
|
||||
|
||||
PostView(
|
||||
@@ -114,10 +112,7 @@ struct SuggestedUsersSectionHeader: View {
|
||||
let model: SuggestedUsersViewModel
|
||||
var body: some View {
|
||||
HStack {
|
||||
let locale = Locale.current
|
||||
let format = localizedStringFormat(key: group.category, locale: locale)
|
||||
let categoryName = String(format: format, locale: locale)
|
||||
Text(categoryName)
|
||||
Text(group.title.uppercased())
|
||||
Spacer()
|
||||
Button(NSLocalizedString("Follow All", comment: "Button to follow all users in this section")) {
|
||||
model.follow(pubkeys: group.users)
|
||||
|
||||
@@ -48,10 +48,7 @@ struct SuggestedUserView: View {
|
||||
.foregroundColor(.gray)
|
||||
.font(.caption)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
|
||||
GradientFollowButton(target: target, follows_you: false, follow_state: damus_state.contacts.follow_state(target.pubkey))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@ import Combine
|
||||
|
||||
struct SuggestedUserGroup: Identifiable, Codable {
|
||||
let id = UUID()
|
||||
let category: String
|
||||
let title: String
|
||||
let users: [Pubkey]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case category, users
|
||||
case title, users
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[
|
||||
{
|
||||
"category": "suggested_users_nostr",
|
||||
"title": "nostr",
|
||||
"users": [
|
||||
"ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a",
|
||||
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
|
||||
@@ -9,29 +9,30 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "suggested_users_permaculture_livestock_gardening",
|
||||
"title": "permaculture & livestock & gardening",
|
||||
"users": [
|
||||
"4b1804c90d59dff195ee0e8f692b98a7c762bf1793b3e126c546d730dcb04477",
|
||||
"2c24e1af571fb4ccfeca3981649c1b09c695cd83b129709eb3b41c7ad2854899",
|
||||
"296842eaaed9be5ae0668da09fe48aac0521c4af859ad547d93145e5ac34c17e"
|
||||
"296842eaaed9be5ae0668da09fe48aac0521c4af859ad547d93145e5ac34c17e",
|
||||
"2c24e1af571fb4ccfeca3981649c1b09c695cd83b129709eb3b41c7ad2854899"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "suggested_users_music",
|
||||
"title": "music",
|
||||
"users": [
|
||||
"23708a76e7090cb108d33e8801fd36262c475e5499b23eb87eee4a31f4f0144e",
|
||||
"ca9d68eb25620fc755e1b8c76b5f155f4c7e96d99c532c109a8b36d208bdce55"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "suggested_users_books",
|
||||
"title": "books",
|
||||
"users": [
|
||||
"2652f3af10de63bc10a2628871a3fce62e08655e4fcf90a58be16f246bb65da3",
|
||||
"b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "suggested_users_art_photography",
|
||||
"title": "art & photography",
|
||||
"users": [
|
||||
"f96c3d76497074c4c83a7b3823380e77dc73d5a9494fd2e053e4a1453e17824b",
|
||||
"11b2d93b26d7e56fb57f0afce0d33bfa7fb35b913e4c0aeb7706464befb9ca97",
|
||||
@@ -49,7 +50,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "suggested_users_ai_art",
|
||||
"title": "ai art",
|
||||
"users": [
|
||||
"431fa2f340f0adf8963d6d7c6e2c20d913278c691fe609fd3857db13d8f39feb",
|
||||
"9936a53def39d712f886ac7e2ed509ce223b534834dd29d95caba9f6bc01ef35",
|
||||
@@ -59,7 +60,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "suggested_users_parenting",
|
||||
"title": "parenting",
|
||||
"users": [
|
||||
"c7c8f645fd45b09055fb6c26d148737ad7ed12ddecde0d4c877b88f8d4196865",
|
||||
"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
|
||||
@@ -69,7 +70,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "suggested_users_food",
|
||||
"title": "food",
|
||||
"users": [
|
||||
"cbb2f023b6aa09626d51d2f4ea99fa9138ea80ec7d5ffdce9feef8dcd6352031"
|
||||
]
|
||||
|
||||
+37
-125
@@ -31,7 +31,6 @@ enum PostAction {
|
||||
case quoting(NostrEvent)
|
||||
case posting(PostTarget)
|
||||
case highlighting(HighlightContentDraft)
|
||||
case sharing(ShareContent)
|
||||
|
||||
var ev: NostrEvent? {
|
||||
switch self {
|
||||
@@ -43,8 +42,6 @@ enum PostAction {
|
||||
return nil
|
||||
case .highlighting:
|
||||
return nil
|
||||
case .sharing(_):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,16 +54,13 @@ struct PostView: View {
|
||||
@State var error: String? = nil
|
||||
@State var uploadedMedias: [UploadedMedia] = []
|
||||
@State var image_upload_confirm: Bool = false
|
||||
@State var imagePastedFromPasteboard: PreUploadedMedia? = nil
|
||||
@State var imageUploadConfirmPasteboard: Bool = false
|
||||
@State var references: [RefId] = []
|
||||
@State var imageUploadConfirmDamusShare: Bool = false
|
||||
@State var filtered_pubkeys: Set<Pubkey> = []
|
||||
@State var focusWordAttributes: (String?, NSRange?) = (nil, nil)
|
||||
@State var newCursorIndex: Int?
|
||||
@State var textHeight: CGFloat? = nil
|
||||
|
||||
@State var preUploadedMedia: [PreUploadedMedia] = []
|
||||
@State var preUploadedMedia: PreUploadedMedia? = nil
|
||||
|
||||
@StateObject var image_upload: ImageUploadModel = ImageUploadModel()
|
||||
@StateObject var tagModel: TagModel = TagModel()
|
||||
@@ -157,7 +151,6 @@ struct PostView: View {
|
||||
|
||||
var ImageButton: some View {
|
||||
Button(action: {
|
||||
preUploadedMedia.removeAll()
|
||||
attach_media = true
|
||||
}, label: {
|
||||
Image("images")
|
||||
@@ -221,8 +214,6 @@ struct PostView: View {
|
||||
damus_state.drafts.post = nil
|
||||
case .highlighting(let draft):
|
||||
damus_state.drafts.highlights.removeValue(forKey: draft.source)
|
||||
case .sharing(_):
|
||||
damus_state.drafts.post = nil
|
||||
}
|
||||
|
||||
}
|
||||
@@ -255,9 +246,7 @@ struct PostView: View {
|
||||
TextViewWrapper(
|
||||
attributedText: $post,
|
||||
textHeight: $textHeight,
|
||||
initialTextSuffix: initial_text_suffix,
|
||||
imagePastedFromPasteboard: $imagePastedFromPasteboard,
|
||||
imageUploadConfirmPasteboard: $imageUploadConfirmPasteboard,
|
||||
initialTextSuffix: initial_text_suffix,
|
||||
cursorIndex: newCursorIndex,
|
||||
getFocusWordForMention: { word, range in
|
||||
focusWordAttributes = (word, range)
|
||||
@@ -304,7 +293,6 @@ struct PostView: View {
|
||||
.padding(10)
|
||||
})
|
||||
.buttonStyle(NeutralButtonStyle())
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.post_composer_cancel_button.rawValue)
|
||||
|
||||
if let error {
|
||||
Text(error)
|
||||
@@ -329,36 +317,34 @@ struct PostView: View {
|
||||
.padding()
|
||||
.padding(.top, 15)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handle_upload(media: MediaUpload) async -> Bool {
|
||||
|
||||
func handle_upload(media: MediaUpload) {
|
||||
let uploader = damus_state.settings.default_media_uploader
|
||||
|
||||
let img = getImage(media: media)
|
||||
print("img size w:\(img.size.width) h:\(img.size.height)")
|
||||
|
||||
async let blurhash = calculate_blurhash(img: img)
|
||||
let res = await image_upload.start(media: media, uploader: uploader, keypair: damus_state.keypair)
|
||||
|
||||
switch res {
|
||||
case .success(let url):
|
||||
guard let url = URL(string: url) else {
|
||||
self.error = "Error uploading image :("
|
||||
return false
|
||||
}
|
||||
let blurhash = await blurhash
|
||||
let meta = blurhash.map { bh in calculate_image_metadata(url: url, img: img, blurhash: bh) }
|
||||
let uploadedMedia = UploadedMedia(localURL: media.localURL, uploadedURL: url, representingImage: img, metadata: meta)
|
||||
uploadedMedias.append(uploadedMedia)
|
||||
return true
|
||||
Task {
|
||||
let img = getImage(media: media)
|
||||
print("img size w:\(img.size.width) h:\(img.size.height)")
|
||||
async let blurhash = calculate_blurhash(img: img)
|
||||
let res = await image_upload.start(media: media, uploader: uploader, keypair: damus_state.keypair)
|
||||
|
||||
case .failed(let error):
|
||||
if let error {
|
||||
self.error = error.localizedDescription
|
||||
} else {
|
||||
self.error = "Error uploading image :("
|
||||
switch res {
|
||||
case .success(let url):
|
||||
guard let url = URL(string: url) else {
|
||||
self.error = "Error uploading image :("
|
||||
return
|
||||
}
|
||||
let blurhash = await blurhash
|
||||
let meta = blurhash.map { bh in calculate_image_metadata(url: url, img: img, blurhash: bh) }
|
||||
let uploadedMedia = UploadedMedia(localURL: media.localURL, uploadedURL: url, representingImage: img, metadata: meta)
|
||||
uploadedMedias.append(uploadedMedia)
|
||||
|
||||
case .failed(let error):
|
||||
if let error {
|
||||
self.error = error.localizedDescription
|
||||
} else {
|
||||
self.error = "Error uploading image :("
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,11 +384,6 @@ struct PostView: View {
|
||||
else if case .highlighting(let draft) = action {
|
||||
HighlightDraftContentView(draft: draft)
|
||||
}
|
||||
else if case .sharing(let draft) = action,
|
||||
let url = draft.getLinkURL() {
|
||||
LinkViewRepresentable(meta: .url(url))
|
||||
.frame(height: 50)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
@@ -427,7 +408,7 @@ struct PostView: View {
|
||||
GeometryReader { (deviceSize: GeometryProxy) in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
let searching = get_searching_string(focusWordAttributes.0)
|
||||
let searchingHashTag = get_searching_hashTag(focusWordAttributes.0)
|
||||
|
||||
TopBar
|
||||
|
||||
ScrollViewReader { scroller in
|
||||
@@ -441,7 +422,7 @@ struct PostView: View {
|
||||
.padding(.top, 5)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: searching == nil && searchingHashTag == nil ? deviceSize.size.height : 70)
|
||||
.frame(maxHeight: searching == nil ? deviceSize.size.height : 70)
|
||||
.onAppear {
|
||||
scroll_to_event(scroller: scroller, id: "post", delay: 1.0, animate: true, anchor: .top)
|
||||
}
|
||||
@@ -452,17 +433,7 @@ struct PostView: View {
|
||||
UserSearch(damus_state: damus_state, search: searching, focusWordAttributes: $focusWordAttributes, newCursorIndex: $newCursorIndex, post: $post)
|
||||
.frame(maxHeight: .infinity)
|
||||
.environmentObject(tagModel)
|
||||
// This else observes '#' for hash-tag suggestions and creates SuggestedHashtagsView
|
||||
} else if let searchingHashTag {
|
||||
SuggestedHashtagsView(damus_state: damus_state,
|
||||
events: SearchHomeModel(damus_state: damus_state).events,
|
||||
isFromPostView: true,
|
||||
queryHashTag: searchingHashTag,
|
||||
focusWordAttributes: $focusWordAttributes,
|
||||
newCursorIndex: $newCursorIndex,
|
||||
post: $post)
|
||||
.environmentObject(tagModel)
|
||||
} else {
|
||||
} else {
|
||||
Divider()
|
||||
VStack(alignment: .leading) {
|
||||
AttachmentBar
|
||||
@@ -473,24 +444,17 @@ struct PostView: View {
|
||||
}
|
||||
.background(DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all))
|
||||
.sheet(isPresented: $attach_media) {
|
||||
MediaPicker(mediaPickerEntry: .postView, image_upload_confirm: $image_upload_confirm){ media in
|
||||
self.preUploadedMedia.append(media)
|
||||
MediaPicker(image_upload_confirm: $image_upload_confirm){ media in
|
||||
self.preUploadedMedia = media
|
||||
}
|
||||
.alert(NSLocalizedString("Are you sure you want to upload the selected media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $image_upload_confirm) {
|
||||
.alert(NSLocalizedString("Are you sure you want to upload this media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $image_upload_confirm) {
|
||||
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
|
||||
// initiate asynchronous uploading Task for multiple-images
|
||||
Task {
|
||||
for media in preUploadedMedia {
|
||||
if let mediaToUpload = generateMediaUpload(media) {
|
||||
await self.handle_upload(media: mediaToUpload)
|
||||
}
|
||||
}
|
||||
if let mediaToUpload = generateMediaUpload(preUploadedMedia) {
|
||||
self.handle_upload(media: mediaToUpload)
|
||||
self.attach_media = false
|
||||
}
|
||||
self.attach_media = false
|
||||
}
|
||||
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {
|
||||
preUploadedMedia.removeAll()
|
||||
}
|
||||
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $attach_camera) {
|
||||
@@ -499,31 +463,6 @@ struct PostView: View {
|
||||
self.attach_media = true
|
||||
}
|
||||
}
|
||||
// This alert seeks confirmation about Image-upload when user taps Paste option
|
||||
.alert(NSLocalizedString("Are you sure you want to upload this media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $imageUploadConfirmPasteboard) {
|
||||
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
|
||||
if let image = imagePastedFromPasteboard,
|
||||
let mediaToUpload = generateMediaUpload(image) {
|
||||
Task {
|
||||
await self.handle_upload(media: mediaToUpload)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
|
||||
}
|
||||
// This alert seeks confirmation about media-upload from Damus Share Extension
|
||||
.alert(NSLocalizedString("Are you sure you want to upload the selected media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $imageUploadConfirmDamusShare) {
|
||||
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
|
||||
Task {
|
||||
for media in preUploadedMedia {
|
||||
if let mediaToUpload = generateMediaUpload(media) {
|
||||
await self.handle_upload(media: mediaToUpload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
|
||||
}
|
||||
.onAppear() {
|
||||
let loaded_draft = load_draft()
|
||||
|
||||
@@ -537,15 +476,6 @@ struct PostView: View {
|
||||
fill_target_content(target: target)
|
||||
case .highlighting(let draft):
|
||||
references = [draft.source.ref()]
|
||||
case .sharing(let content):
|
||||
if let url = content.getLinkURL() {
|
||||
self.post = NSMutableAttributedString(string: "\(content.title)\n\(String(url.absoluteString))")
|
||||
} else {
|
||||
self.preUploadedMedia = content.getMediaArray()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
self.imageUploadConfirmDamusShare = true // display Confirm Sheet after 1 sec
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
@@ -556,7 +486,6 @@ struct PostView: View {
|
||||
if isEmpty() {
|
||||
clear_draft()
|
||||
}
|
||||
preUploadedMedia.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -584,17 +513,6 @@ func get_searching_string(_ word: String?) -> String? {
|
||||
return String(word.dropFirst())
|
||||
}
|
||||
|
||||
fileprivate func get_searching_hashTag(_ word: String?) -> String? {
|
||||
guard let word,
|
||||
word.count >= 2,
|
||||
let first_char = word.first,
|
||||
first_char == "#" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return String(word.dropFirst())
|
||||
}
|
||||
|
||||
struct PostView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PostView(action: .posting(.none), damus_state: test_damus_state)
|
||||
@@ -695,8 +613,6 @@ func set_draft_for_post(drafts: Drafts, action: PostAction, artifacts: DraftArti
|
||||
drafts.post = artifacts
|
||||
case .highlighting(let draft):
|
||||
drafts.highlights[draft.source] = artifacts
|
||||
case .sharing(_):
|
||||
drafts.post = artifacts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,8 +626,6 @@ func load_draft_for_post(drafts: Drafts, action: PostAction) -> DraftArtifacts?
|
||||
return drafts.post
|
||||
case .highlighting(let draft):
|
||||
return drafts.highlights[draft.source]
|
||||
case .sharing(_):
|
||||
return drafts.post
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,8 +701,6 @@ func build_post(state: DamusState, post: NSMutableAttributedString, action: Post
|
||||
break
|
||||
case .highlighting(let draft):
|
||||
break
|
||||
case .sharing(_):
|
||||
break
|
||||
}
|
||||
|
||||
// append additional tags
|
||||
|
||||
@@ -76,10 +76,10 @@ struct EditMetadataView: View {
|
||||
return NIP05.parse(nip05)
|
||||
}
|
||||
|
||||
func topSection(topLevelGeo: GeometryProxy) -> some View {
|
||||
var TopSection: some View {
|
||||
ZStack(alignment: .top) {
|
||||
GeometryReader { geo in
|
||||
EditBannerImageView(damus_state: damus_state, viewModel: bannerUploadObserver, callback: uploadedBanner(image_url:), safeAreaInsets: topLevelGeo.safeAreaInsets, banner_image: URL(string: banner))
|
||||
EditBannerImageView(damus_state: damus_state, viewModel: bannerUploadObserver, callback: uploadedBanner(image_url:), banner_image: URL(string: banner))
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: geo.size.width, height: BANNER_HEIGHT)
|
||||
.clipped()
|
||||
@@ -122,14 +122,8 @@ struct EditMetadataView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
self.content(topLevelGeo: proxy)
|
||||
}
|
||||
}
|
||||
|
||||
func content(topLevelGeo: GeometryProxy) -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
self.topSection(topLevelGeo: topLevelGeo)
|
||||
TopSection
|
||||
Form {
|
||||
Section(NSLocalizedString("Your Name", comment: "Label for Your Name section of user profile form.")) {
|
||||
let display_name_placeholder = "Satoshi Nakamoto"
|
||||
@@ -209,7 +203,7 @@ struct EditMetadataView: View {
|
||||
})
|
||||
.buttonStyle(GradientButtonStyle(padding: 15))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 10 + tabHeight)
|
||||
.padding(.bottom, 10)
|
||||
.disabled(!didChange())
|
||||
.opacity(!didChange() ? 0.5 : 1)
|
||||
.disabled(profileUploadObserver.isLoading || bannerUploadObserver.isLoading)
|
||||
@@ -224,7 +218,7 @@ struct EditMetadataView: View {
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationBarBackButtonHidden()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
ToolbarItem(placement: .principal) {
|
||||
navBackButton
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ class ImageUploadingObserver: ObservableObject {
|
||||
|
||||
struct EditPictureControl: View {
|
||||
let uploader: MediaUploader
|
||||
let keypair: Keypair?
|
||||
let pubkey: Pubkey
|
||||
var size: CGFloat? = 25
|
||||
var setup: Bool? = false
|
||||
@@ -41,7 +40,6 @@ struct EditPictureControl: View {
|
||||
}) {
|
||||
Text("Image URL", comment: "Option to enter a url")
|
||||
}
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.own_profile_banner_image_edit_from_url.rawValue)
|
||||
|
||||
Button(action: {
|
||||
self.show_library = true
|
||||
@@ -115,7 +113,7 @@ struct EditPictureControl: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $show_library) {
|
||||
MediaPicker(mediaPickerEntry: .editPictureControl, image_upload_confirm: $image_upload_confirm) { media in
|
||||
MediaPicker(image_upload_confirm: $image_upload_confirm, imagesOnly: true) { media in
|
||||
self.preUploadedMedia = media
|
||||
}
|
||||
.alert(NSLocalizedString("Are you sure you want to upload this image?", comment: "Alert message asking if the user wants to upload an image."), isPresented: $image_upload_confirm) {
|
||||
@@ -197,7 +195,7 @@ struct EditPictureControl: View {
|
||||
private func handle_upload(media: MediaUpload) {
|
||||
uploadObserver.isLoading = true
|
||||
Task {
|
||||
let res = await image_upload.start(media: media, uploader: uploader, keypair: keypair)
|
||||
let res = await image_upload.start(media: media, uploader: uploader)
|
||||
|
||||
switch res {
|
||||
case .success(let urlString):
|
||||
@@ -223,7 +221,7 @@ struct EditPictureControl_Previews: PreviewProvider {
|
||||
let observer = ImageUploadingObserver()
|
||||
ZStack {
|
||||
Color.gray
|
||||
EditPictureControl(uploader: .nostrBuild, keypair: test_keypair, pubkey: test_pubkey, size: 100, setup: false, image_url: url, uploadObserver: observer) { _ in
|
||||
EditPictureControl(uploader: .nostrBuild, pubkey: test_pubkey, size: 100, setup: false, image_url: url, uploadObserver: observer) { _ in
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ struct ProfileEditButton: View {
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.own_profile_edit_button.rawValue)
|
||||
}
|
||||
|
||||
func fillColor() -> Color {
|
||||
|
||||
@@ -33,7 +33,7 @@ struct EditProfilePictureView: View {
|
||||
.scaledToFill()
|
||||
.kfClickable()
|
||||
|
||||
EditPictureControl(uploader: damus_state?.settings.default_media_uploader ?? .nostrBuild, keypair: damus_state?.keypair, pubkey: pubkey, image_url: $profile_url, uploadObserver: uploadObserver, callback: callback)
|
||||
EditPictureControl(uploader: damus_state?.settings.default_media_uploader ?? .nostrBuild, pubkey: pubkey, image_url: $profile_url, uploadObserver: uploadObserver, callback: callback)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// ProfilePopup.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-08-21.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ProfilePopup: View {
|
||||
var body: some View {
|
||||
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfilePopup_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ProfilePopup()
|
||||
}
|
||||
}
|
||||
@@ -51,17 +51,13 @@ func followedByString(_ friend_intersection: [Pubkey], ndb: Ndb, locale: Locale
|
||||
|
||||
struct VisualEffectView: UIViewRepresentable {
|
||||
var effect: UIVisualEffect?
|
||||
var darkeningOpacity: CGFloat = 0.3 // degree of darkening
|
||||
|
||||
func makeUIView(context: UIViewRepresentableContext<Self>) -> UIVisualEffectView {
|
||||
let effectView = UIVisualEffectView()
|
||||
effectView.backgroundColor = UIColor.black.withAlphaComponent(darkeningOpacity)
|
||||
return effectView
|
||||
UIVisualEffectView()
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext<Self>) {
|
||||
uiView.effect = effect
|
||||
uiView.backgroundColor = UIColor.black.withAlphaComponent(darkeningOpacity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,18 +103,6 @@ struct ProfileView: View {
|
||||
return Double(-yOffset > navbarHeight ? progress : 0)
|
||||
}
|
||||
|
||||
func getProfileInfo() -> (String, String) {
|
||||
let profile_txn = self.damus_state.profiles.lookup(id: profile.pubkey)
|
||||
let ndbprofile = profile_txn?.unsafeUnownedValue
|
||||
let displayName = Profile.displayName(profile: ndbprofile, pubkey: profile.pubkey).displayName.truncate(maxLength: 25)
|
||||
let userName = Profile.displayName(profile: ndbprofile, pubkey: profile.pubkey).username.truncate(maxLength: 25)
|
||||
return (displayName, "@\(userName)")
|
||||
}
|
||||
|
||||
func showFollowBtnInBlurrBanner() -> Bool {
|
||||
damus_state.contacts.follow_state(profile.pubkey) == .unfollows && bannerBlurViewOpacity() > 1.0
|
||||
}
|
||||
|
||||
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
|
||||
var filters = ContentFilters.defaults(damus_state: damus_state)
|
||||
filters.append(fstate.filter)
|
||||
@@ -313,8 +297,8 @@ struct ProfileView: View {
|
||||
.onTapGesture {
|
||||
is_zoomed.toggle()
|
||||
}
|
||||
.damus_full_screen_cover($is_zoomed, damus_state: damus_state) {
|
||||
ProfilePicImageView(pubkey: profile.pubkey, profiles: damus_state.profiles, settings: damus_state.settings, nav: damus_state.nav, shouldShowEditButton: damus_state.pubkey == profile.pubkey)
|
||||
.fullScreenCover(isPresented: $is_zoomed) {
|
||||
ProfilePicImageView(pubkey: profile.pubkey, profiles: damus_state.profiles, settings: damus_state.settings)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -460,44 +444,19 @@ struct ProfileView: View {
|
||||
.zIndex(-yOffset > navbarHeight ? 0 : 1)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, tabHeight + getSafeAreaBottom())
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("")
|
||||
.navigationBarBackButtonHidden()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
HStack(spacing: 8) {
|
||||
navBackButton
|
||||
.padding(.top, 5)
|
||||
.accentColor(DamusColors.white)
|
||||
VStack(alignment: .leading, spacing: -4.5) {
|
||||
Text(getProfileInfo().0) // Display name
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
Text(getProfileInfo().1) // Username
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
.opacity(bannerBlurViewOpacity())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, max(5, 15 + (yOffset / 30)))
|
||||
}
|
||||
navBackButton
|
||||
.padding(.top, 5)
|
||||
.accentColor(DamusColors.white)
|
||||
}
|
||||
if showFollowBtnInBlurrBanner() {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
FollowButtonView(
|
||||
target: profile.get_follow_target(),
|
||||
follows_you: profile.follows(pubkey: damus_state.pubkey),
|
||||
follow_state: damus_state.contacts.follow_state(profile.pubkey)
|
||||
)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
} else {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
navActionSheetButton
|
||||
.padding(.top, 5)
|
||||
.accentColor(DamusColors.white)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
navActionSheetButton
|
||||
.padding(.top, 5)
|
||||
.accentColor(DamusColors.white)
|
||||
}
|
||||
}
|
||||
.toolbarBackground(.hidden)
|
||||
@@ -518,7 +477,7 @@ struct ProfileView: View {
|
||||
let url = URL(string: "https://damus.io/" + profile.pubkey.npub)!
|
||||
ShareSheet(activityItems: [url])
|
||||
}
|
||||
.damus_full_screen_cover($show_qr_code, damus_state: damus_state) {
|
||||
.fullScreenCover(isPresented: $show_qr_code) {
|
||||
QRCodeView(damus_state: damus_state, pubkey: profile.pubkey)
|
||||
}
|
||||
|
||||
@@ -526,7 +485,6 @@ struct ProfileView: View {
|
||||
PostButtonContainer(is_left_handed: damus_state.settings.left_handed) {
|
||||
notify(.compose(.posting(.user(profile.pubkey))))
|
||||
}
|
||||
.padding(.bottom, tabHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,9 @@ struct ProfileActionSheetView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
var navigationHandler: (() -> Void)?
|
||||
|
||||
init(damus_state: DamusState, pubkey: Pubkey, onNavigate navigationHandler: (() -> Void)? = nil) {
|
||||
init(damus_state: DamusState, pubkey: Pubkey) {
|
||||
self.damus_state = damus_state
|
||||
self._profile = StateObject(wrappedValue: ProfileModel(pubkey: pubkey, damus: damus_state))
|
||||
self.navigationHandler = navigationHandler
|
||||
}
|
||||
|
||||
func imageBorderColor() -> Color {
|
||||
@@ -40,12 +37,6 @@ struct ProfileActionSheetView: View {
|
||||
return self.profile_data()?.profile
|
||||
}
|
||||
|
||||
func navigate(route: Route) {
|
||||
damus_state.nav.push(route: route)
|
||||
self.navigationHandler?()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
var followButton: some View {
|
||||
return ProfileActionSheetFollowButton(
|
||||
target: .pubkey(self.profile.pubkey),
|
||||
@@ -74,7 +65,8 @@ struct ProfileActionSheetView: View {
|
||||
return VStack(alignment: .center, spacing: 10) {
|
||||
Button(
|
||||
action: {
|
||||
self.navigate(route: Route.DMChat(dms: dm_model))
|
||||
damus_state.nav.push(route: Route.DMChat(dms: dm_model))
|
||||
dismiss()
|
||||
},
|
||||
label: {
|
||||
Image("messages")
|
||||
@@ -134,7 +126,8 @@ struct ProfileActionSheetView: View {
|
||||
|
||||
Button(
|
||||
action: {
|
||||
self.navigate(route: Route.ProfileByKey(pubkey: profile.pubkey))
|
||||
damus_state.nav.push(route: Route.ProfileByKey(pubkey: profile.pubkey))
|
||||
dismiss()
|
||||
},
|
||||
label: {
|
||||
HStack {
|
||||
|
||||
@@ -9,7 +9,6 @@ import SwiftUI
|
||||
|
||||
struct PubkeyView: View {
|
||||
let pubkey: Pubkey
|
||||
var sidemenu: Bool = false
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@@ -46,21 +45,20 @@ struct PubkeyView: View {
|
||||
let bech32 = pubkey.npub
|
||||
|
||||
HStack {
|
||||
Text(verbatim: "\(abbrev_pubkey(bech32, amount: sidemenu ? 12 : 16))")
|
||||
.font(sidemenu ? .system(size: 10) : .footnote)
|
||||
Text(verbatim: "\(abbrev_pubkey(bech32, amount: 16))")
|
||||
.font(.footnote)
|
||||
.foregroundColor(keyColor())
|
||||
.padding(5)
|
||||
.padding([.leading], 5)
|
||||
.lineLimit(1)
|
||||
|
||||
|
||||
HStack {
|
||||
if isCopied {
|
||||
Image("check-circle")
|
||||
.resizable()
|
||||
.foregroundColor(DamusColors.green)
|
||||
.frame(width: sidemenu ? 15 : 20, height: sidemenu ? 15 : 20)
|
||||
.frame(width: 20, height: 20)
|
||||
Text("Copied", comment: "Label indicating that a user's key was copied.")
|
||||
.font(sidemenu ? .system(size: 10) : .footnote)
|
||||
.font(.footnote)
|
||||
.layoutPriority(1)
|
||||
.foregroundColor(DamusColors.green)
|
||||
} else {
|
||||
@@ -74,7 +72,7 @@ struct PubkeyView: View {
|
||||
.resizable()
|
||||
.contentShape(Rectangle())
|
||||
.foregroundColor(colorScheme == .light ? DamusColors.darkGrey : DamusColors.lightGrey)
|
||||
.frame(width: sidemenu ? 15 : 20, height: sidemenu ? 15 : 20)
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
.labelStyle(IconOnlyLabelStyle())
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
|
||||
@@ -70,7 +70,7 @@ struct DamusPurpleWelcomeView: View {
|
||||
.opacity(start ? 1.0 : 0.0)
|
||||
.animation(Animation.snappy(duration: 2).delay(0), value: start)
|
||||
|
||||
Text("Thank you very much for signing up for Damus Purple. Your contribution helps us continue our fight for a more Open and Free internet.\n\nYou will also get access to premium features, and a star badge on your profile.\n\nEnjoy!", comment: "Appreciation to user for purchasing subscription service")
|
||||
Text("Thank you very much for signing up for Damus\u{00A0}Purple. Your contribution helps us continue our fight for a more Open and Free\u{00A0}internet.\n\nYou will also get access to premium features, and a star badge on your profile.\n\nEnjoy!", comment: "Appreciation to user for purchasing subscription service")
|
||||
.lineSpacing(5)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
|
||||
+149
-310
@@ -7,16 +7,61 @@
|
||||
|
||||
import SwiftUI
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import CodeScanner
|
||||
|
||||
struct ProfileScanResult: Equatable {
|
||||
let pubkey: Pubkey
|
||||
|
||||
init?(hex: String) {
|
||||
guard let pk = hex_decode(hex).map({ bytes in Pubkey(Data(bytes)) }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.pubkey = pk
|
||||
}
|
||||
|
||||
init?(string: String) {
|
||||
var str = string
|
||||
guard str.count != 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if str.hasPrefix("nostr:") {
|
||||
str.removeFirst("nostr:".count)
|
||||
}
|
||||
|
||||
if let decoded = hex_decode(str),
|
||||
str.count == 64
|
||||
{
|
||||
self.pubkey = Pubkey(Data(decoded))
|
||||
return
|
||||
}
|
||||
|
||||
if str.starts(with: "npub"),
|
||||
let b32 = try? bech32_decode(str)
|
||||
{
|
||||
self.pubkey = Pubkey(b32.data)
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct QRCodeView: View {
|
||||
let damus_state: DamusState
|
||||
@State var pubkey: Pubkey
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
@State private var selectedTab = 0
|
||||
@State var scanResult: ProfileScanResult? = nil
|
||||
@State var profile: Profile? = nil
|
||||
@State var error: String? = nil
|
||||
@State private var outerTrimEnd: CGFloat = 0
|
||||
|
||||
var animationDuration: Double = 0.5
|
||||
|
||||
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||
|
||||
@ViewBuilder
|
||||
func navImage(systemImage: String) -> some View {
|
||||
@@ -28,7 +73,7 @@ struct QRCodeView: View {
|
||||
|
||||
var navBackButton: some View {
|
||||
Button {
|
||||
dismiss()
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
} label: {
|
||||
navImage(systemImage: "chevron.left")
|
||||
}
|
||||
@@ -53,7 +98,7 @@ struct QRCodeView: View {
|
||||
TabView(selection: $selectedTab) {
|
||||
QRView
|
||||
.tag(0)
|
||||
self.qrCameraView
|
||||
QRCameraView()
|
||||
.tag(1)
|
||||
}
|
||||
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
|
||||
@@ -75,9 +120,18 @@ struct QRCodeView: View {
|
||||
VStack(alignment: .center) {
|
||||
let profile_txn = damus_state.profiles.lookup(id: pubkey, txn_name: "qrview-profile")
|
||||
let profile = profile_txn?.unsafeUnownedValue
|
||||
let our_profile = profile_txn.flatMap({ ptxn in
|
||||
damus_state.ndb.lookup_profile_with_txn(damus_state.pubkey, txn: ptxn)?.profile
|
||||
})
|
||||
|
||||
ProfilePicView(pubkey: pubkey, size: 90.0, highlight: .custom(DamusColors.white, 3.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
if our_profile?.picture != nil {
|
||||
ProfilePicView(pubkey: pubkey, size: 90.0, highlight: .custom(DamusColors.white, 3.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
.padding(.top, 20)
|
||||
} else {
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: 60))
|
||||
.padding(.top, 20)
|
||||
}
|
||||
|
||||
if let display_name = profile?.display_name {
|
||||
Text(display_name)
|
||||
@@ -85,7 +139,7 @@ struct QRCodeView: View {
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
if let name = profile?.name {
|
||||
Text(verbatim: "@" + name)
|
||||
Text("@" + name)
|
||||
.font(.body)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
@@ -105,17 +159,10 @@ struct QRCodeView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// apply the same styling to both text-views without code duplication
|
||||
Group {
|
||||
if damus_state.pubkey.npub == pubkey.npub {
|
||||
Text("Follow me on Nostr", comment: "Text on QR code view to prompt viewer looking at screen to follow the user.")
|
||||
} else {
|
||||
Text("Follow \(profile?.display_name ?? profile?.name ?? "") on Nostr", comment: "Text on QR code view to prompt viewer looking at screen to follow the user.")
|
||||
}
|
||||
}
|
||||
.font(.system(size: 24, weight: .heavy))
|
||||
.padding(.top, 10)
|
||||
.foregroundColor(.white)
|
||||
Text("Follow me on Nostr", comment: "Text on QR code view to prompt viewer looking at screen to follow the user.")
|
||||
.font(.system(size: 24, weight: .heavy))
|
||||
.padding(.top, 10)
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text("Scan the code", comment: "Text on QR code view to prompt viewer to scan the QR code on screen with their device camera.")
|
||||
.font(.system(size: 18, weight: .ultraLight))
|
||||
@@ -137,8 +184,35 @@ struct QRCodeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var qrCameraView: some View {
|
||||
QRCameraView(damusState: damus_state, bottomContent: {
|
||||
func QRCameraView() -> some View {
|
||||
return VStack(alignment: .center) {
|
||||
Text("Scan a user's pubkey", comment: "Text to prompt scanning a QR code of a user's pubkey to open their profile.")
|
||||
.padding(.top, 50)
|
||||
.font(.system(size: 24, weight: .heavy))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
CodeScannerView(codeTypes: [.qr], scanMode: .continuous, simulatedData: "npub1k92qsr95jcumkpu6dffurkvwwycwa2euvx4fthv78ru7gqqz0nrs2ngfwd", shouldVibrateOnSuccess: false) { result in
|
||||
switch result {
|
||||
case .success(let success):
|
||||
handleProfileScan(success.string)
|
||||
case .failure(let failure):
|
||||
self.error = failure.localizedDescription
|
||||
}
|
||||
}
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: 300, maxHeight: 300)
|
||||
.cornerRadius(10)
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(DamusColors.white, lineWidth: 5.0).scaledToFit())
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).trim(from: 0.0, to: outerTrimEnd).stroke(DamusColors.black, lineWidth: 5.5)
|
||||
.rotationEffect(.degrees(-90)).scaledToFit())
|
||||
.shadow(radius: 10)
|
||||
|
||||
Spacer()
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
selectedTab = 0
|
||||
}) {
|
||||
@@ -146,11 +220,65 @@ struct QRCodeView: View {
|
||||
Text("View QR Code", comment: "Button to switch to view users QR Code")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: 12, alignment: .center)
|
||||
.frame( maxWidth: .infinity, maxHeight: 12, alignment: .center)
|
||||
}
|
||||
.buttonStyle(GradientButtonStyle())
|
||||
.padding(50)
|
||||
}, dismiss: dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
func handleProfileScan(_ scanned_str: String) {
|
||||
guard let result = ProfileScanResult(string: scanned_str) else {
|
||||
self.error = "Invalid profile QR"
|
||||
return
|
||||
}
|
||||
|
||||
self.error = nil
|
||||
|
||||
guard result != self.scanResult else {
|
||||
return
|
||||
}
|
||||
|
||||
generator.impactOccurred()
|
||||
cameraAnimate {
|
||||
scanResult = result
|
||||
|
||||
find_event(state: damus_state, query: .profile(pubkey: result.pubkey)) { res in
|
||||
guard let res else {
|
||||
error = "Profile not found"
|
||||
return
|
||||
}
|
||||
|
||||
switch res {
|
||||
case .invalid_profile:
|
||||
error = "Profile was found but was corrupt."
|
||||
|
||||
case .profile:
|
||||
show_profile_after_delay()
|
||||
|
||||
case .event:
|
||||
print("invalid search result")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func show_profile_after_delay() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
|
||||
if let scanResult {
|
||||
damus_state.nav.push(route: Route.ProfileByKey(pubkey: scanResult.pubkey))
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cameraAnimate(completion: @escaping () -> Void) {
|
||||
outerTrimEnd = 0.0
|
||||
withAnimation(.easeInOut(duration: animationDuration)) {
|
||||
outerTrimEnd = 1.05 // Set to 1.05 instead of 1.0 since sometimes `completion()` runs before the value reaches 1.0. This ensures the animation is done.
|
||||
}
|
||||
completion()
|
||||
}
|
||||
|
||||
func generateQRCode(pubkey: String) -> UIImage {
|
||||
@@ -173,295 +301,6 @@ struct QRCodeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A view that scans for pubkeys/npub QR codes and displays a profile when needed.
|
||||
///
|
||||
/// ## Implementation notes:
|
||||
///
|
||||
/// - Marked as `fileprivate` since it is a relatively niche view, but can be made public with some adaptation if reuse is needed
|
||||
/// - The main state is tracked by a single enum, to ensure mutual exclusion of states (only one of the states can be active at a time), and that the info for each state is there when needed — both enforced at compile-time
|
||||
fileprivate struct QRCameraView<Content: View>: View {
|
||||
|
||||
// MARK: Input parameters
|
||||
|
||||
var damusState: DamusState
|
||||
/// A custom view to display on the bottom of the camera view
|
||||
var bottomContent: () -> Content
|
||||
var dismiss: DismissAction
|
||||
|
||||
|
||||
// MARK: State properties
|
||||
|
||||
/// The main state of this view.
|
||||
@State var scannerState: ScannerState = .scanning {
|
||||
didSet {
|
||||
switch (oldValue, scannerState) {
|
||||
case (.scanning, .scanSuccessful), (.incompatibleQRCodeFound, .scanSuccessful):
|
||||
generator.impactOccurred() // Haptic feedback upon a successful scan
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: Helper properties and objects
|
||||
|
||||
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||
/// A timer that ticks every second.
|
||||
/// We need this to dismiss the incompatible QR code message automatically once the user is no longer pointing the camera at it
|
||||
let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
|
||||
|
||||
/// This is used to create a nice border animation when a scan is successful
|
||||
///
|
||||
/// Computed property to simplify state management
|
||||
var outerTrimEnd: CGFloat {
|
||||
switch scannerState {
|
||||
case .scanning, .error, .incompatibleQRCodeFound:
|
||||
return 0.0
|
||||
case .scanSuccessful:
|
||||
return 1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A computed binding that indicates if there is an error to be displayed.
|
||||
///
|
||||
/// This property is computed based on the main state `scannerState`, and is used to manage the error sheet without adding any extra state variables
|
||||
var errorBinding: Binding<ScannerError?> {
|
||||
Binding(
|
||||
get: {
|
||||
guard case .error(let error) = scannerState else { return nil }
|
||||
return error
|
||||
},
|
||||
set: { newError in
|
||||
guard let newError else {
|
||||
self.scannerState = .scanning
|
||||
return
|
||||
}
|
||||
self.scannerState = .error(newError)
|
||||
})
|
||||
}
|
||||
|
||||
/// A computed binding that indicates if there is a profile scan result to be displayed
|
||||
///
|
||||
/// This property is computed based on the main state `scannerState`, and is used to manage the profile sheet without adding any extra state variables
|
||||
var profileScanResultBinding: Binding<ProfileScanResult?> {
|
||||
Binding(
|
||||
get: {
|
||||
guard case .scanSuccessful(result: let scanResult) = scannerState else { return nil }
|
||||
return scanResult
|
||||
},
|
||||
set: { newProfileScanResult in
|
||||
guard let newProfileScanResult else {
|
||||
self.scannerState = .scanning
|
||||
return
|
||||
}
|
||||
self.scannerState = .scanSuccessful(result: newProfileScanResult)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// MARK: View layouts
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center) {
|
||||
Text("Scan a user's pubkey", comment: "Text to prompt scanning a QR code of a user's pubkey to open their profile.")
|
||||
.padding(.top, 50)
|
||||
.font(.system(size: 24, weight: .heavy))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
CodeScannerView(codeTypes: [.qr], scanMode: .continuous, scanInterval: 1, showViewfinder: true, simulatedData: "npub1k92qsr95jcumkpu6dffurkvwwycwa2euvx4fthv78ru7gqqz0nrs2ngfwd", shouldVibrateOnSuccess: false) { result in
|
||||
self.handleNewProfileScanInfo(result)
|
||||
}
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: 300, maxHeight: 300)
|
||||
.cornerRadius(10)
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(DamusColors.white, lineWidth: 5.0).scaledToFit())
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).trim(from: 0.0, to: outerTrimEnd).stroke(DamusColors.black, lineWidth: 5.5)
|
||||
.rotationEffect(.degrees(-90)).scaledToFit())
|
||||
.shadow(radius: 10)
|
||||
|
||||
Spacer()
|
||||
|
||||
self.hintMessage
|
||||
|
||||
Spacer()
|
||||
|
||||
self.bottomContent()
|
||||
}
|
||||
// Show an error sheet if we are on an error state
|
||||
.sheet(item: self.errorBinding, content: { error in
|
||||
self.errorSheet(error: error)
|
||||
})
|
||||
// Show the profile sheet if we have successfully scanned
|
||||
.sheet(item: self.profileScanResultBinding, content: { scanResult in
|
||||
ProfileActionSheetView(damus_state: self.damusState, pubkey: scanResult.pubkey, onNavigate: {
|
||||
dismiss()
|
||||
})
|
||||
.tint(DamusColors.adaptableBlack)
|
||||
.presentationDetents([.large])
|
||||
})
|
||||
// Dismiss an incompatible QR code message automatically after a second or two of pointing it elsewhere.
|
||||
.onReceive(timer) { _ in
|
||||
switch self.scannerState {
|
||||
case .incompatibleQRCodeFound(scannedAt: let date):
|
||||
if abs(date.timeIntervalSinceNow) > 1.5 {
|
||||
self.scannerState = .scanning
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hintMessage: some View {
|
||||
HStack {
|
||||
switch self.scannerState {
|
||||
case .scanning:
|
||||
Text("Point your camera to a QR code…", comment: "Text on QR code camera view instructing user to point to QR code")
|
||||
case .incompatibleQRCodeFound:
|
||||
Text("Sorry, this QR code looks incompatible with Damus. Please try another one.", comment: "Text on QR code camera view telling the user a QR is incompatible")
|
||||
case .scanSuccessful:
|
||||
Text("Found profile!", comment: "Text on QR code camera view telling user that profile scan was successful.")
|
||||
case .error:
|
||||
Text("Error, please try again", comment: "Text on QR code camera view indicating an error")
|
||||
}
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
}
|
||||
|
||||
func errorSheet(error: ScannerError) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
Text("Error", comment: "Headline label for an error sheet on the QR code scanner")
|
||||
.font(.headline)
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
.tint(DamusColors.adaptableBlack)
|
||||
}
|
||||
|
||||
|
||||
// MARK: Scanning and state management logic
|
||||
|
||||
/// A base handler anytime the scanner sends new info,
|
||||
///
|
||||
/// Behavior depends on the current state. In some states we completely ignore new scanner info (e.g. when looking at a profile)
|
||||
/// This function mutates our state
|
||||
func handleNewProfileScanInfo(_ scanInfo: Result<ScanResult, ScanError>) {
|
||||
switch scannerState {
|
||||
case .scanning, .incompatibleQRCodeFound:
|
||||
withAnimation {
|
||||
self.scannerState = self.processScanAndComputeNextState(scanInfo)
|
||||
}
|
||||
case .scanSuccessful, .error:
|
||||
return // We don't want new scan results to pop-up while in these states
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes a QR code scan, and computes the next state to be applied to the view
|
||||
func processScanAndComputeNextState(_ scanInfo: Result<ScanResult, ScanError>) -> ScannerState {
|
||||
switch scanInfo {
|
||||
case .success(let successfulScan):
|
||||
guard let result = ProfileScanResult(string: successfulScan.string) else {
|
||||
return .incompatibleQRCodeFound(scannedAt: Date.now)
|
||||
}
|
||||
return .scanSuccessful(result: result)
|
||||
case .failure(let error):
|
||||
return .error(.scanError(error))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Helper types
|
||||
|
||||
/// A custom type for `QRCameraView` to track the state of the scanner.
|
||||
///
|
||||
/// This is done to avoid having multiple independent variables to track the state, which increases the chance of state inconsistency.
|
||||
/// By using this we guarantee at compile-time that we will always be in one state at a time, and that the state is coherent/consistent/clear.
|
||||
enum ScannerState {
|
||||
/// Camera is on and actively scanning new QR codes
|
||||
case scanning
|
||||
/// Scan and decoding was successful. Show profile.
|
||||
case scanSuccessful(result: ProfileScanResult)
|
||||
/// Tell the user they scanned a QR code that is incompatible
|
||||
case incompatibleQRCodeFound(scannedAt: Date)
|
||||
/// There was an error. Display a human readable and actionable message
|
||||
case error(ScannerError)
|
||||
}
|
||||
|
||||
/// Represents an error in this view, to be displayed to the user
|
||||
///
|
||||
/// **Implementation notes:**
|
||||
/// 1. This is identifiable because it that is needed for the error sheet view
|
||||
/// 2. Currently there is only one error type (`ScanError`), but this is still used to allow us to customize it and add future error types outside the scanner.
|
||||
enum ScannerError: Error, Identifiable {
|
||||
case scanError(ScanError)
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .scanError(let scanError):
|
||||
switch scanError {
|
||||
case .badInput:
|
||||
NSLocalizedString("The camera could not be accessed.", comment: "Camera's bad input error label")
|
||||
case .badOutput:
|
||||
NSLocalizedString("The camera was not capable of scanning the requested codes.", comment: "Camera's bad output error label")
|
||||
case .initError(_):
|
||||
NSLocalizedString("There was an unexpected error in initializing the camera.", comment: "Camera's initialization error label")
|
||||
case .permissionDenied:
|
||||
NSLocalizedString("Camera's permission was denied. You can change this in iOS settings.", comment: "Camera's permission denied error label")
|
||||
}
|
||||
}
|
||||
}
|
||||
var id: String { return self.localizedDescription }
|
||||
}
|
||||
|
||||
/// A struct that holds results of a profile scan
|
||||
struct ProfileScanResult: Equatable, Identifiable {
|
||||
var id: Pubkey { return self.pubkey }
|
||||
let pubkey: Pubkey
|
||||
|
||||
init?(hex: String) {
|
||||
guard let pk = hex_decode(hex).map({ bytes in Pubkey(Data(bytes)) }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.pubkey = pk
|
||||
}
|
||||
|
||||
init?(string: String) {
|
||||
var str = string.trimmingCharacters(in: ["\n", "\t", " "])
|
||||
guard str.count != 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if str.hasPrefix("nostr:") {
|
||||
str.removeFirst("nostr:".count)
|
||||
}
|
||||
|
||||
if let decoded = hex_decode(str),
|
||||
str.count == 64
|
||||
{
|
||||
self.pubkey = Pubkey(Data(decoded))
|
||||
return
|
||||
}
|
||||
|
||||
if str.starts(with: "npub"),
|
||||
let b32 = try? bech32_decode(str)
|
||||
{
|
||||
self.pubkey = Pubkey(b32.data)
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct QRCodeView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
QRCodeView(damus_state: test_damus_state, pubkey: test_note.pubkey)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
// Created by Jericho Hasselbush on 9/29/23.
|
||||
//
|
||||
|
||||
import CodeScanner
|
||||
import SwiftUI
|
||||
import VisionKit
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ struct ReactionsView: View {
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.padding(.bottom, tabHeight)
|
||||
.navigationBarTitle(NSLocalizedString("Reactions", comment: "Navigation bar title for Reactions view."))
|
||||
.onAppear {
|
||||
model.subscribe()
|
||||
|
||||
@@ -14,9 +14,9 @@ enum RelayTab: Int, CaseIterable{
|
||||
var title: String{
|
||||
switch self {
|
||||
case .myRelays:
|
||||
return NSLocalizedString("My Relays", comment: "Title of the tab that shows the user's list of their own relays.")
|
||||
return "My relays"
|
||||
case .recommended:
|
||||
return NSLocalizedString("Recommended", comment: "Title of the tab that shows the list of relays recommended by Damus.")
|
||||
return "Recommended"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,10 +48,10 @@ struct RelayConfigView: View {
|
||||
NavigationView {
|
||||
ZStack(alignment: .bottom){
|
||||
TabView(selection: $selectedTab) {
|
||||
RelayList(title: RelayTab.myRelays.title, relayList: relays, recommended: false)
|
||||
RelayList(title: "My Relays", relayList: relays, recommended: false)
|
||||
.tag(0)
|
||||
|
||||
RelayList(title: RelayTab.recommended.title, relayList: recommended, recommended: true)
|
||||
RelayList(title: "Recommended", relayList: recommended, recommended: true)
|
||||
.tag(1)
|
||||
}
|
||||
ZStack{
|
||||
@@ -83,13 +83,13 @@ struct RelayConfigView: View {
|
||||
.toolbar {
|
||||
if state.keypair.privkey != nil && selectedTab == 0 {
|
||||
if showActionButtons {
|
||||
Button(NSLocalizedString("Done", comment: "Button to leave edit mode for modifying the list of relays.")) {
|
||||
Button("Done") {
|
||||
withAnimation {
|
||||
showActionButtons.toggle()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(NSLocalizedString("Edit", comment: "Button to enter edit mode for modifying the list of relays.")) {
|
||||
Button("Edit") {
|
||||
withAnimation {
|
||||
showActionButtons.toggle()
|
||||
}
|
||||
|
||||
@@ -13,14 +13,15 @@ struct SignalView: View {
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
NavigationLink(value: Route.RelayConfig) {
|
||||
Text("\(signal.signal)/\(signal.max_signal)", comment: "Fraction of how many of the user's relay servers that are operational.")
|
||||
.font(.callout)
|
||||
.foregroundColor(.gray)
|
||||
if signal.signal != signal.max_signal {
|
||||
NavigationLink(value: Route.RelayConfig) {
|
||||
Text("\(signal.signal)/\(signal.max_signal)", comment: "Fraction of how many of the user's relay servers that are operational.")
|
||||
.font(.callout)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
} else {
|
||||
Text("")
|
||||
}
|
||||
.frame(width:50,height:30)
|
||||
.opacity(signal.signal != signal.max_signal ? 1 : 0)
|
||||
.disabled(signal.signal == signal.max_signal)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ struct QuoteRepostsView: View {
|
||||
|
||||
var body: some View {
|
||||
TimelineView<AnyView>(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true, filter: ContentFilters.default_filters(damus_state: damus_state).filter(ev:))
|
||||
.padding(.bottom, tabHeight)
|
||||
.navigationBarTitle(NSLocalizedString("Quotes", comment: "Navigation bar title for Quote Reposts view."))
|
||||
.onAppear {
|
||||
model.subscribe()
|
||||
|
||||
@@ -20,7 +20,6 @@ struct RepostsView: View {
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.padding(.bottom, tabHeight)
|
||||
.navigationBarTitle(NSLocalizedString("Reposts", comment: "Navigation bar title for Reposts view."))
|
||||
.onAppear {
|
||||
model.subscribe()
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
//
|
||||
// NDBSearchView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 9/9/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NDBSearchView: View {
|
||||
|
||||
let damus_state: DamusState
|
||||
@Binding var results: [NostrEvent]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
if results.count > 0 {
|
||||
HStack {
|
||||
Spacer()
|
||||
Image("search")
|
||||
Text("Top hits", comment: "A label indicating that the notes being displayed below it are all top note search results")
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
LazyVStack {
|
||||
ForEach(results, id: \.self) { note in
|
||||
EventView(damus: damus_state, event: note, options: [.truncate_content])
|
||||
.onTapGesture {
|
||||
let event = note.get_inner_event(cache: damus_state.events) ?? note
|
||||
let thread = ThreadModel(event: event, damus_state: damus_state)
|
||||
damus_state.nav.push(route: Route.Thread(thread: thread))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
ThiccDivider()
|
||||
}
|
||||
}
|
||||
|
||||
} else if results.count == 0 {
|
||||
HStack {
|
||||
Spacer()
|
||||
Image("search")
|
||||
Text("No results", comment: "A label indicating that note search resulted in no results")
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@ struct PullDownSearchView: View {
|
||||
let on_cancel: () -> Void
|
||||
|
||||
func do_search(query: String) {
|
||||
let limit = 128
|
||||
let note_keys = state.ndb.text_search(query: query, limit: limit, order: .newest_first)
|
||||
let limit = 16
|
||||
var note_keys = state.ndb.text_search(query: query, limit: limit, order: .newest_first)
|
||||
var res = [NostrEvent]()
|
||||
// TODO: fix duplicate results from search
|
||||
var keyset = Set<NoteKey>()
|
||||
|
||||
@@ -91,7 +91,7 @@ struct SearchHomeView: View {
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 20)
|
||||
.padding(.horizontal)
|
||||
}.padding(.bottom, 50))
|
||||
})
|
||||
}
|
||||
)
|
||||
.refreshable {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MultiSearch {
|
||||
let text: String
|
||||
let hashtag: String
|
||||
let profiles: [Pubkey]
|
||||
}
|
||||
@@ -44,7 +43,6 @@ enum Search: Identifiable {
|
||||
struct InnerSearchResults: View {
|
||||
let damus_state: DamusState
|
||||
let search: Search?
|
||||
@Binding var results: [NostrEvent]
|
||||
|
||||
func ProfileSearchResult(pk: Pubkey) -> some View {
|
||||
FollowUserView(target: .pubkey(pk), damus_state: damus_state)
|
||||
@@ -53,33 +51,7 @@ struct InnerSearchResults: View {
|
||||
func HashtagSearch(_ ht: String) -> some View {
|
||||
let search_model = SearchModel(state: damus_state, search: .filter_hashtag([ht]))
|
||||
return NavigationLink(value: Route.Search(search: search_model)) {
|
||||
HStack {
|
||||
Text("#\(ht)", comment: "Navigation link to search hashtag.")
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
.padding(.vertical, 5)
|
||||
.background(DamusColors.neutral1)
|
||||
.cornerRadius(20)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(DamusColors.neutral3, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TextSearch(_ txt: String) -> some View {
|
||||
return NavigationLink(value: Route.NDBSearch(results: $results)) {
|
||||
HStack {
|
||||
Text(txt)
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
.padding(.vertical, 5)
|
||||
.background(DamusColors.neutral1)
|
||||
.cornerRadius(20)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(DamusColors.neutral3, lineWidth: 1)
|
||||
)
|
||||
Text("Search hashtag: #\(ht)", comment: "Navigation link to search hashtag.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,13 +88,8 @@ struct InnerSearchResults: View {
|
||||
case .naddr(let naddr):
|
||||
SearchingEventView(state: damus_state, search_type: .naddr(naddr))
|
||||
case .multi(let multi):
|
||||
VStack(alignment: .leading) {
|
||||
HStack(spacing: 20) {
|
||||
HashtagSearch(multi.hashtag)
|
||||
TextSearch(multi.text)
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
|
||||
VStack {
|
||||
HashtagSearch(multi.hashtag)
|
||||
ProfilesSearch(multi.profiles)
|
||||
}
|
||||
|
||||
@@ -137,47 +104,10 @@ struct SearchResultsView: View {
|
||||
let damus_state: DamusState
|
||||
@Binding var search: String
|
||||
@State var result: Search? = nil
|
||||
@State var results: [NostrEvent] = []
|
||||
let debouncer: Debouncer = Debouncer(interval: 0.25)
|
||||
|
||||
func do_search(query: String) {
|
||||
let limit = 128
|
||||
var note_keys = damus_state.ndb.text_search(query: query, limit: limit, order: .newest_first)
|
||||
var res = [NostrEvent]()
|
||||
// TODO: fix duplicate results from search
|
||||
var keyset = Set<NoteKey>()
|
||||
|
||||
// try reverse because newest first is a bit buggy on partial searches
|
||||
if note_keys.count == 0 {
|
||||
// don't touch existing results if there are no new ones
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
|
||||
for note_key in note_keys {
|
||||
guard let note = damus_state.ndb.lookup_note_by_key_with_txn(note_key, txn: txn) else {
|
||||
continue
|
||||
}
|
||||
|
||||
if !keyset.contains(note_key) {
|
||||
let owned_note = note.to_owned()
|
||||
res.append(owned_note)
|
||||
keyset.insert(note_key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let res_ = res
|
||||
|
||||
Task { @MainActor [res_] in
|
||||
results = res_
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
InnerSearchResults(damus_state: damus_state, search: result, results: $results)
|
||||
InnerSearchResults(damus_state: damus_state, search: result)
|
||||
.padding()
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
@@ -189,13 +119,6 @@ struct SearchResultsView: View {
|
||||
guard let txn = NdbTxn.init(ndb: damus_state.ndb) else { return }
|
||||
self.result = search_for_string(profiles: damus_state.profiles, contacts: damus_state.contacts, search: search, txn: txn)
|
||||
}
|
||||
.onChange(of: search) { query in
|
||||
debouncer.debounce {
|
||||
Task.detached {
|
||||
do_search(query: query)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +174,7 @@ func search_for_string<Y>(profiles: Profiles, contacts: Contacts, search new: St
|
||||
return .naddr(naddr)
|
||||
}
|
||||
|
||||
let multisearch = MultiSearch(text: new, hashtag: make_hashtagable(searchQuery), profiles: search_profiles(profiles: profiles, contacts: contacts, search: new, txn: txn))
|
||||
let multisearch = MultiSearch(hashtag: make_hashtagable(searchQuery), profiles: search_profiles(profiles: profiles, contacts: contacts, search: new, txn: txn))
|
||||
return .multi(multisearch)
|
||||
}
|
||||
|
||||
@@ -285,7 +208,7 @@ func search_profiles<Y>(profiles: Profiles, contacts: Contacts, search: String,
|
||||
return [pk]
|
||||
}
|
||||
|
||||
return profiles.search(search, limit: 128, txn: txn).sorted { a, b in
|
||||
return profiles.search(search, limit: 10, txn: txn).sorted { a, b in
|
||||
let aFriendTypePriority = get_friend_type(contacts: contacts, pubkey: a)?.priority ?? 0
|
||||
let bFriendTypePriority = get_friend_type(contacts: contacts, pubkey: b)?.priority ?? 0
|
||||
|
||||
|
||||
@@ -108,7 +108,6 @@ struct AppearanceSettingsView: View {
|
||||
Section(
|
||||
header: Text("Profiles", comment: "Section title for profile view configuration."),
|
||||
footer: Text("Profile action sheets allow you to follow, zap, or DM profiles more quickly without having to view their full profile", comment: "Section footer clarifying what the profile action sheet feature does")
|
||||
.padding(.bottom, tabHeight + getSafeAreaBottom())
|
||||
) {
|
||||
Toggle(NSLocalizedString("Show profile action sheets", comment: "Setting to show profile action sheets when clicking on a user's profile picture"), isOn: $settings.show_profile_action_sheet_on_pfp_click)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
@@ -177,10 +177,7 @@ struct NotificationSettingsView: View {
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
|
||||
Section(
|
||||
header: Text("Notification Dots", comment: "Section header for notification indicator dot settings"),
|
||||
footer: Text("").padding(.bottom, tabHeight + getSafeAreaBottom())
|
||||
) {
|
||||
Section(header: Text("Notification Dots", comment: "Section header for notification indicator dot settings")) {
|
||||
Toggle(NSLocalizedString("Zaps", comment: "Setting to enable Zap Local Notification"), isOn: indicator_binding(.zaps))
|
||||
.toggleStyle(.switch)
|
||||
Toggle(NSLocalizedString("Mentions", comment: "Setting to enable Mention Local Notification"), isOn: indicator_binding(.mentions))
|
||||
|
||||
+12
-12
@@ -56,21 +56,21 @@ struct SetupView: View {
|
||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: 12, alignment: .center)
|
||||
}
|
||||
.buttonStyle(GradientButtonStyle())
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.sign_in_option_button.rawValue)
|
||||
.padding()
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Text("By continuing you agree to our ")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(DamusColors.neutral6)
|
||||
|
||||
Button(action: {
|
||||
navigationCoordinator.push(route: Route.EULA)
|
||||
}, label: {
|
||||
HStack {
|
||||
Text("By continuing, you agree to our EULA", comment: "Disclaimer to user that they are agreeing to the End User License Agreement if they create an account or sign in.")
|
||||
Button(action: {
|
||||
navigationCoordinator.push(route: Route.EULA)
|
||||
}, label: {
|
||||
Text("EULA", comment: "End User License Agreement")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(DamusColors.neutral6)
|
||||
|
||||
Image(systemName: "arrow.forward")
|
||||
}
|
||||
})
|
||||
.padding(.vertical, 5)
|
||||
})
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
+102
-88
@@ -11,14 +11,23 @@ import SwiftUI
|
||||
struct SideMenuView: View {
|
||||
let damus_state: DamusState
|
||||
@Binding var isSidebarVisible: Bool
|
||||
@Binding var selected: Timeline
|
||||
@State var confirm_logout: Bool = false
|
||||
@State private var showQRCode = false
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var sideBarWidth = min(UIScreen.main.bounds.size.width * 0.65, 400.0)
|
||||
let verticalSpacing: CGFloat = 25
|
||||
let verticalSpacing: CGFloat = 20
|
||||
let padding: CGFloat = 30
|
||||
|
||||
func fillColor() -> Color {
|
||||
colorScheme == .light ? DamusColors.white : DamusColors.black
|
||||
}
|
||||
|
||||
func textColor() -> Color {
|
||||
colorScheme == .light ? DamusColors.black : DamusColors.white
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
GeometryReader { _ in
|
||||
@@ -40,7 +49,6 @@ struct SideMenuView: View {
|
||||
NavigationLink(value: Route.Profile(profile: profile_model, followers: followers)) {
|
||||
navLabel(title: NSLocalizedString("Profile", comment: "Sidebar menu label for Profile view."), img: "user")
|
||||
}
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.side_menu_profile_button.rawValue)
|
||||
|
||||
NavigationLink(value: Route.Wallet(wallet: damus_state.wallet)) {
|
||||
navLabel(title: NSLocalizedString("Wallet", comment: "Sidebar menu label for Wallet view."), img: "wallet")
|
||||
@@ -48,11 +56,11 @@ struct SideMenuView: View {
|
||||
|
||||
if damus_state.purple.enable_purple {
|
||||
NavigationLink(destination: DamusPurpleView(damus_state: damus_state)) {
|
||||
HStack(spacing: 23) {
|
||||
HStack(spacing: 13) {
|
||||
Image("nostr-hashtag")
|
||||
Text("Purple")
|
||||
.foregroundColor(DamusColors.purple)
|
||||
.font(.title2.weight(.semibold))
|
||||
.font(.title2.weight(.bold))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
@@ -71,22 +79,12 @@ struct SideMenuView: View {
|
||||
}
|
||||
|
||||
Link(destination: URL(string: "https://store.damus.io/?ref=damus_ios_app")!) {
|
||||
navLabel(title: NSLocalizedString("Merch", comment: "Sidebar menu label for merch store link."), img: "shop")
|
||||
navLabel(title: NSLocalizedString("Merch", comment: "Sidebar menu label for merch store link."), img: "basket")
|
||||
}
|
||||
|
||||
NavigationLink(value: Route.Config) {
|
||||
navLabel(title: NSLocalizedString("Settings", comment: "Sidebar menu label for accessing the app settings"), img: "settings")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
if damus_state.keypair.privkey == nil {
|
||||
logout(damus_state)
|
||||
} else {
|
||||
confirm_logout = true
|
||||
}
|
||||
}, label: {
|
||||
navLabel(title: NSLocalizedString("Logout", comment: "Sidebar menu label to sign out of the account."), img: "logout")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,68 +99,38 @@ struct SideMenuView: View {
|
||||
display_name = profile?.display_name
|
||||
}
|
||||
|
||||
return VStack(alignment: .leading) {
|
||||
HStack(spacing: 10) {
|
||||
|
||||
ProfilePicView(pubkey: damus_state.pubkey, size: 50, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
present_sheet(.user_status)
|
||||
isSidebarVisible = false
|
||||
}, label: {
|
||||
Image("add-reaction")
|
||||
.resizable()
|
||||
.frame(width: 25, height: 25)
|
||||
.padding(5)
|
||||
.foregroundColor(DamusColors.adaptableBlack)
|
||||
.background {
|
||||
Circle()
|
||||
.foregroundColor(DamusColors.neutral3)
|
||||
}
|
||||
})
|
||||
|
||||
Button(action: {
|
||||
showQRCode.toggle()
|
||||
isSidebarVisible = false
|
||||
}, label: {
|
||||
Image("qr-code")
|
||||
.resizable()
|
||||
.frame(width: 25, height: 25)
|
||||
.padding(5)
|
||||
.foregroundColor(DamusColors.adaptableBlack)
|
||||
.background {
|
||||
Circle()
|
||||
.foregroundColor(DamusColors.neutral3)
|
||||
}
|
||||
}).damus_full_screen_cover($showQRCode, damus_state: damus_state) {
|
||||
QRCodeView(damus_state: damus_state, pubkey: damus_state.pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
|
||||
if let display_name {
|
||||
Text(display_name)
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(DamusColors.adaptableBlack)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.dynamicTypeSize(.xSmall)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let name {
|
||||
if !name.isEmpty {
|
||||
Text(verbatim: "@" + name)
|
||||
return VStack(alignment: .leading, spacing: verticalSpacing) {
|
||||
HStack {
|
||||
ProfilePicView(pubkey: damus_state.pubkey, size: 60, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
|
||||
if let display_name {
|
||||
Text(display_name)
|
||||
.foregroundColor(textColor())
|
||||
.font(.title)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let name {
|
||||
Text("@" + name)
|
||||
.foregroundColor(DamusColors.mediumGrey)
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
PubkeyView(pubkey: damus_state.pubkey, sidemenu: true)
|
||||
.pubkey_context_menu(pubkey: damus_state.pubkey)
|
||||
}
|
||||
|
||||
navLabel(title: NSLocalizedString("Set Status", comment: "Sidebar menu label to set user status"), img: "add-reaction")
|
||||
.font(.title2)
|
||||
.foregroundColor(textColor())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.dynamicTypeSize(.xSmall)
|
||||
.onTapGesture {
|
||||
present_sheet(.user_status)
|
||||
}
|
||||
|
||||
UserStatusView(status: damus_state.profiles.profile_data(damus_state.pubkey).status, show_general: true, show_music: true)
|
||||
.dynamicTypeSize(.xSmall)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,31 +140,68 @@ struct SideMenuView: View {
|
||||
let profile_model = ProfileModel(pubkey: damus_state.pubkey, damus: damus_state)
|
||||
|
||||
NavigationLink(value: Route.Profile(profile: profile_model, followers: followers), label: {
|
||||
|
||||
TopProfile
|
||||
.padding(.bottom, verticalSpacing)
|
||||
})
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
isSidebarVisible = false
|
||||
})
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
SidemenuItems(profile_model: profile_model, followers: followers)
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
isSidebarVisible = false
|
||||
})
|
||||
.labelStyle(SideMenuLabelStyle())
|
||||
.padding([.top, .bottom], verticalSpacing)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
HStack(alignment: .top) {
|
||||
ZStack(alignment: .top) {
|
||||
DamusColors.adaptableWhite
|
||||
fillColor()
|
||||
.ignoresSafeArea()
|
||||
|
||||
MainSidemenu
|
||||
.padding([.leading, .trailing], padding)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
MainSidemenu
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
isSidebarVisible = false
|
||||
})
|
||||
|
||||
Divider()
|
||||
|
||||
HStack() {
|
||||
Button(action: {
|
||||
//ConfigView(state: damus_state)
|
||||
if damus_state.keypair.privkey == nil {
|
||||
logout(damus_state)
|
||||
} else {
|
||||
confirm_logout = true
|
||||
}
|
||||
}, label: {
|
||||
Label(NSLocalizedString("Sign out", comment: "Sidebar menu label to sign out of the account."), image: "logout")
|
||||
.font(.title3)
|
||||
.foregroundColor(textColor())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.dynamicTypeSize(.xSmall)
|
||||
})
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
showQRCode.toggle()
|
||||
}, label: {
|
||||
Image("qr-code")
|
||||
.font(.title)
|
||||
.foregroundColor(textColor())
|
||||
.dynamicTypeSize(.xSmall)
|
||||
}).fullScreenCover(isPresented: $showQRCode) {
|
||||
QRCodeView(damus_state: damus_state, pubkey: damus_state.pubkey)
|
||||
}
|
||||
}
|
||||
.padding(.top, verticalSpacing)
|
||||
}
|
||||
.padding(.top, -(padding / 2.0))
|
||||
.padding([.leading, .trailing, .bottom], padding)
|
||||
}
|
||||
.frame(width: sideBarWidth)
|
||||
.offset(x: isSidebarVisible ? 0 : -(sideBarWidth + padding))
|
||||
@@ -217,17 +222,26 @@ struct SideMenuView: View {
|
||||
}
|
||||
|
||||
func navLabel(title: String, img: String) -> some View {
|
||||
HStack(spacing: 20) {
|
||||
HStack {
|
||||
Image(img)
|
||||
.tint(DamusColors.adaptableBlack)
|
||||
|
||||
Text(title)
|
||||
.font(.title2.weight(.semibold))
|
||||
.foregroundColor(DamusColors.adaptableBlack)
|
||||
.font(.title2)
|
||||
.foregroundColor(textColor())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.dynamicTypeSize(.xSmall)
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
struct SideMenuLabelStyle: LabelStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
configuration.icon
|
||||
.frame(width: 24, height: 24)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
configuration.title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,6 +249,6 @@ struct SideMenuView: View {
|
||||
struct Previews_SideMenuView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let ds = test_damus_state
|
||||
SideMenuView(damus_state: ds, isSidebarVisible: .constant(true), selected: .constant(.home))
|
||||
SideMenuView(damus_state: ds, isSidebarVisible: .constant(true))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ struct SuggestedHashtagsView: View {
|
||||
.sorted(by: { a, b in
|
||||
a.count > b.count
|
||||
})
|
||||
SuggestedHashtagsView.lastRefresh_hashtags = all_items // Collecting recent hash-tag data from Search-page
|
||||
guard let item_limit else {
|
||||
return all_items
|
||||
}
|
||||
@@ -47,55 +46,10 @@ struct SuggestedHashtagsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static var lastRefresh_hashtags: [HashtagWithUserCount] = [] // Holds hash-tag data for PostView
|
||||
var isFromPostView: Bool
|
||||
var queryHashTag: String
|
||||
|
||||
var filteredSuggestedHashtags: [HashtagWithUserCount] {
|
||||
let val = SuggestedHashtagsView.lastRefresh_hashtags.filter {$0.hashtag.hasPrefix(returnFirstWordOnly(hashTag: queryHashTag))}
|
||||
if val.isEmpty {
|
||||
if SuggestedHashtagsView.lastRefresh_hashtags.isEmpty {
|
||||
// This is special case when user goes directly to PostView without opening Search-page previously.
|
||||
var val = hashtags_with_count_to_display // retrieves default hash-tage values
|
||||
// if not-found, put query hash tag at top
|
||||
val.insert(HashtagWithUserCount(hashtag: returnFirstWordOnly(hashTag: queryHashTag), count: 0), at: 0)
|
||||
return val
|
||||
} else {
|
||||
// if not-found, put query hash tag at top
|
||||
var val = SuggestedHashtagsView.lastRefresh_hashtags
|
||||
val.insert(HashtagWithUserCount(hashtag: returnFirstWordOnly(hashTag: queryHashTag), count: 0), at: 0)
|
||||
return val
|
||||
}
|
||||
} else {
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
@Binding var focusWordAttributes: (String?, NSRange?)
|
||||
@Binding var newCursorIndex: Int?
|
||||
@Binding var post: NSMutableAttributedString
|
||||
@EnvironmentObject var tagModel: TagModel
|
||||
|
||||
init(damus_state: DamusState,
|
||||
suggested_hashtags: [String]? = nil,
|
||||
max_items item_limit: Int? = nil,
|
||||
events: EventHolder,
|
||||
isFromPostView: Bool = false,
|
||||
queryHashTag: String = "",
|
||||
focusWordAttributes: Binding<(String?, NSRange?)> = .constant((nil, nil)),
|
||||
newCursorIndex: Binding<Int?> = .constant(nil),
|
||||
post: Binding<NSMutableAttributedString> = .constant(NSMutableAttributedString(string: ""))) {
|
||||
init(damus_state: DamusState, suggested_hashtags: [String]? = nil, max_items item_limit: Int? = nil, events: EventHolder) {
|
||||
self.damus_state = damus_state
|
||||
self.suggested_hashtags = suggested_hashtags ?? DEFAULT_SUGGESTED_HASHTAGS
|
||||
self.item_limit = item_limit
|
||||
|
||||
self.isFromPostView = isFromPostView
|
||||
self.queryHashTag = queryHashTag
|
||||
self._focusWordAttributes = focusWordAttributes
|
||||
self._newCursorIndex = newCursorIndex
|
||||
self._post = post
|
||||
|
||||
_events = StateObject.init(wrappedValue: events)
|
||||
}
|
||||
|
||||
@@ -105,43 +59,24 @@ struct SuggestedHashtagsView: View {
|
||||
Image(systemName: "sparkles")
|
||||
Text("Suggested hashtags", comment: "A label indicating that the items below it are suggested hashtags")
|
||||
Spacer()
|
||||
// Don't show suggestion expand/contract button when user is in PostView
|
||||
if !isFromPostView {
|
||||
Button(action: {
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
show_suggested_hashtags.toggle()
|
||||
}
|
||||
}) {
|
||||
if show_suggested_hashtags {
|
||||
Image(systemName: "rectangle.compress.vertical")
|
||||
.foregroundStyle(PinkGradient)
|
||||
} else {
|
||||
Image(systemName: "rectangle.expand.vertical")
|
||||
.foregroundStyle(PinkGradient)
|
||||
}
|
||||
Button(action: {
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
show_suggested_hashtags.toggle()
|
||||
}
|
||||
}) {
|
||||
if show_suggested_hashtags {
|
||||
Image(systemName: "rectangle.compress.vertical")
|
||||
.foregroundStyle(PinkGradient)
|
||||
} else {
|
||||
Image(systemName: "rectangle.expand.vertical")
|
||||
.foregroundStyle(PinkGradient)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
if isFromPostView {
|
||||
ScrollView {
|
||||
LazyVStack {
|
||||
ForEach(filteredSuggestedHashtags,
|
||||
id: \.self) { hashtag_with_count in
|
||||
SuggestedHashtagView(damus_state: damus_state,
|
||||
hashtag: hashtag_with_count.hashtag,
|
||||
count: hashtag_with_count.count,
|
||||
isFromPostView: true,
|
||||
focusWordAttributes: $focusWordAttributes,
|
||||
newCursorIndex: $newCursorIndex,
|
||||
post: $post)
|
||||
.environmentObject(tagModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if show_suggested_hashtags {
|
||||
if show_suggested_hashtags {
|
||||
ForEach(hashtags_with_count_to_display,
|
||||
id: \.self) { hashtag_with_count in
|
||||
SuggestedHashtagView(damus_state: damus_state, hashtag: hashtag_with_count.hashtag, count: hashtag_with_count.count)
|
||||
@@ -156,26 +91,10 @@ struct SuggestedHashtagsView: View {
|
||||
let hashtag: String
|
||||
let count: Int
|
||||
|
||||
let isFromPostView: Bool
|
||||
@Binding var focusWordAttributes: (String?, NSRange?)
|
||||
@Binding var newCursorIndex: Int?
|
||||
@Binding var post: NSMutableAttributedString
|
||||
@EnvironmentObject var tagModel: TagModel
|
||||
|
||||
init(damus_state: DamusState,
|
||||
hashtag: String,
|
||||
count: Int,
|
||||
isFromPostView: Bool = false,
|
||||
focusWordAttributes: Binding<(String?, NSRange?)> = .constant((nil, nil)),
|
||||
newCursorIndex: Binding<Int?> = .constant(nil),
|
||||
post: Binding<NSMutableAttributedString> = .constant(NSMutableAttributedString(string: ""))) {
|
||||
init(damus_state: DamusState, hashtag: String, count: Int) {
|
||||
self.damus_state = damus_state
|
||||
self.hashtag = hashtag
|
||||
self.count = count
|
||||
self.isFromPostView = isFromPostView
|
||||
self._focusWordAttributes = focusWordAttributes
|
||||
self._newCursorIndex = newCursorIndex
|
||||
self._post = post
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -186,48 +105,18 @@ struct SuggestedHashtagsView: View {
|
||||
Text(verbatim: "#\(hashtag)")
|
||||
.bold()
|
||||
|
||||
// Don't show user-talking label from PostView when the count is 0
|
||||
if isFromPostView {
|
||||
if count != 0 {
|
||||
let pluralizedString = pluralizedString(key: "users_talking_about_it", count: self.count)
|
||||
Text(pluralizedString)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
let pluralizedString = pluralizedString(key: "users_talking_about_it", count: self.count)
|
||||
Text(pluralizedString)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
let pluralizedString = pluralizedString(key: "users_talking_about_it", count: self.count)
|
||||
Text(pluralizedString)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle()) // make the entire row/rectangle tappable
|
||||
.onTapGesture {
|
||||
if isFromPostView {
|
||||
let hashTag = NSMutableAttributedString(string: "#\(returnFirstWordOnly(hashTag: hashtag))",
|
||||
attributes: [
|
||||
NSAttributedString.Key.foregroundColor: UIColor.black,
|
||||
NSAttributedString.Key.link: "#\(hashtag)"
|
||||
])
|
||||
appendHashTag(withTag: hashTag)
|
||||
} else {
|
||||
let search_model = SearchModel(state: damus_state, search: NostrFilter.init(hashtag: [hashtag]))
|
||||
damus_state.nav.push(route: Route.Search(search: search_model))
|
||||
}
|
||||
let search_model = SearchModel(state: damus_state, search: NostrFilter.init(hashtag: [hashtag]))
|
||||
damus_state.nav.push(route: Route.Search(search: search_model))
|
||||
}
|
||||
}
|
||||
|
||||
// Current working-code similar to UserSearch/appendUserTag
|
||||
private func appendHashTag(withTag tag: NSMutableAttributedString) {
|
||||
guard let wordRange = focusWordAttributes.1 else { return }
|
||||
let appended = append_user_tag(tag: tag, post: post, word_range: wordRange)
|
||||
self.post = appended.post
|
||||
// adjust cursor position appropriately: ('diff' used in TextViewWrapper / updateUIView after below update of 'post')
|
||||
tagModel.diff = appended.tag.length - wordRange.length
|
||||
focusWordAttributes = (nil, nil)
|
||||
newCursorIndex = wordRange.location + appended.tag.length
|
||||
}
|
||||
}
|
||||
|
||||
func users_talking_about(hashtag: Hashtag) -> Int {
|
||||
@@ -258,6 +147,3 @@ struct SuggestedHashtagsView_Previews: PreviewProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func returnFirstWordOnly(hashTag: String) -> String {
|
||||
return hashTag.components(separatedBy: " ").first?.lowercased() ?? ""
|
||||
}
|
||||
|
||||
@@ -12,16 +12,13 @@ struct TextViewWrapper: UIViewRepresentable {
|
||||
@EnvironmentObject var tagModel: TagModel
|
||||
@Binding var textHeight: CGFloat?
|
||||
let initialTextSuffix: String?
|
||||
@Binding var imagePastedFromPasteboard: PreUploadedMedia?
|
||||
@Binding var imageUploadConfirmPasteboard: Bool
|
||||
|
||||
let cursorIndex: Int?
|
||||
var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil
|
||||
let updateCursorPosition: ((Int) -> Void)
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let textView = CustomPostTextView(imagePastedFromPasteboard: $imagePastedFromPasteboard,
|
||||
imageUploadConfirm: $imageUploadConfirmPasteboard)
|
||||
let textView = UITextView()
|
||||
textView.backgroundColor = UIColor(DamusColors.adaptableWhite)
|
||||
textView.delegate = context.coordinator
|
||||
|
||||
@@ -93,7 +90,7 @@ struct TextViewWrapper: UIViewRepresentable {
|
||||
let updateCursorPosition: ((Int) -> Void)
|
||||
let initialTextSuffix: String?
|
||||
var initialTextSuffixWasAdded: Bool = false
|
||||
static let ESCAPE_SEQUENCES = ["\n", "@", " ", ", ", ". ", "! ", "? ", "; ", "#"]
|
||||
static let ESCAPE_SEQUENCES = ["\n", "@", " ", ", ", ". ", "! ", "? ", "; "]
|
||||
|
||||
init(attributedText: Binding<NSMutableAttributedString>,
|
||||
getFocusWordForMention: ((String?, NSRange?) -> Void)?,
|
||||
@@ -243,55 +240,3 @@ struct TextViewWrapper: UIViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
class CustomPostTextView: UITextView {
|
||||
@Binding var imagePastedFromPasteboard: PreUploadedMedia?
|
||||
@Binding var imageUploadConfirm: Bool
|
||||
|
||||
// Custom initializer
|
||||
init(imagePastedFromPasteboard: Binding<PreUploadedMedia?>, imageUploadConfirm: Binding<Bool>) {
|
||||
self._imagePastedFromPasteboard = imagePastedFromPasteboard
|
||||
self._imageUploadConfirm = imageUploadConfirm
|
||||
super.init(frame: .zero, textContainer: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
// Override canPerformAction to enable image pasting
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
if action == #selector(UIResponderStandardEditActions.paste(_:)),
|
||||
UIPasteboard.general.image != nil {
|
||||
return true // Show `Paste` option while long-pressing if there is an image present in the clipboard
|
||||
}
|
||||
return super.canPerformAction(action, withSender: sender) // Default behavior for other actions
|
||||
}
|
||||
|
||||
// Override paste to handle image pasting
|
||||
override func paste(_ sender: Any?) {
|
||||
let pasteboard = UIPasteboard.general
|
||||
|
||||
if let data = pasteboard.data(forPasteboardType: Constants.GIF_IMAGE_TYPE),
|
||||
let url = saveGIFToTemporaryDirectory(data) {
|
||||
imagePastedFromPasteboard = PreUploadedMedia.unprocessed_image(url)
|
||||
imageUploadConfirm = true
|
||||
} else if let image = pasteboard.image {
|
||||
// handle .png, .jpeg files here
|
||||
imagePastedFromPasteboard = PreUploadedMedia.uiimage(image)
|
||||
// Show alert view in PostView for Confirming upload
|
||||
imageUploadConfirm = true
|
||||
} else {
|
||||
// fall back to default paste behavior if no image or gif file found
|
||||
super.paste(sender)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveGIFToTemporaryDirectory(_ data: Data) -> URL? {
|
||||
let tempDirectory = FileManager.default.temporaryDirectory
|
||||
let gifURL = tempDirectory.appendingPathComponent("pasted_image.gif")
|
||||
do {
|
||||
try data.write(to: gifURL)
|
||||
return gifURL
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,11 @@ struct PostingTimelineView: View {
|
||||
@State var initialOffset: CGFloat?
|
||||
@State var offset: CGFloat?
|
||||
@State var showSearch: Bool = true
|
||||
@Binding var isSideBarOpened: Bool
|
||||
@Binding var active_sheet: Sheets?
|
||||
@FocusState private var isSearchFocused: Bool
|
||||
@State private var contentOffset: CGFloat = 0
|
||||
@State private var indicatorWidth: CGFloat = 0
|
||||
@State private var indicatorPosition: CGFloat = 0
|
||||
@State var headerHeight: CGFloat = 0
|
||||
@Binding var headerOffset: CGFloat
|
||||
@SceneStorage("PostingTimelineView.filter_state") var filter_state : FilterState = .posts_and_replies
|
||||
|
||||
var mystery: some View {
|
||||
@@ -38,56 +35,8 @@ struct PostingTimelineView: View {
|
||||
}
|
||||
|
||||
func contentTimelineView(filter: (@escaping (NostrEvent) -> Bool)) -> some View {
|
||||
TimelineView<AnyView>(events: home.events, loading: .constant(false), headerHeight: $headerHeight, headerOffset: $headerOffset, damus: damus_state, show_friend_icon: false, filter: filter)
|
||||
}
|
||||
|
||||
func HeaderView()->some View {
|
||||
VStack {
|
||||
VStack(spacing: 0) {
|
||||
// This is needed for the Dynamic Island
|
||||
HStack {}
|
||||
.frame(height: getSafeAreaTop())
|
||||
|
||||
HStack(alignment: .top) {
|
||||
TopbarSideMenuButton(damus_state: damus_state, isSideBarOpened: $isSideBarOpened)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image("damus-home")
|
||||
.resizable()
|
||||
.frame(width:30,height:30)
|
||||
.shadow(color: DamusColors.purple, radius: 2)
|
||||
.opacity(isSideBarOpened ? 0 : 1)
|
||||
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||
.onTapGesture {
|
||||
isSideBarOpened.toggle()
|
||||
}
|
||||
.padding(.leading)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(alignment: .center) {
|
||||
SignalView(state: damus_state, signal: home.signal)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
CustomPicker(tabs: [
|
||||
(NSLocalizedString("Notes", comment: "Label for filter for seeing only notes (instead of notes and replies)."), FilterState.posts),
|
||||
(NSLocalizedString("Notes & Replies", comment: "Label for filter for seeing notes and replies (instead of only notes)."), FilterState.posts_and_replies)
|
||||
],
|
||||
selection: $filter_state)
|
||||
|
||||
Divider()
|
||||
.frame(height: 1)
|
||||
}
|
||||
}
|
||||
.background {
|
||||
DamusColors.adaptableWhite
|
||||
.ignoresSafeArea()
|
||||
TimelineView(events: home.events, loading: .constant(false), damus: damus_state, show_friend_icon: false, filter: filter) {
|
||||
PullDownSearchView(state: damus_state, on_cancel: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,26 +60,21 @@ struct PostingTimelineView: View {
|
||||
PostButtonContainer(is_left_handed: damus_state.settings.left_handed) {
|
||||
self.active_sheet = .post(.posting(.none))
|
||||
}
|
||||
.padding(.bottom, tabHeight + getSafeAreaBottom())
|
||||
.opacity(0.35 + abs(1.25 - (abs(headerOffset/100.0))))
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
HeaderView()
|
||||
.anchorPreference(key: HeaderBoundsKey.self, value: .bounds){$0}
|
||||
.overlayPreferenceValue(HeaderBoundsKey.self) { value in
|
||||
GeometryReader{ proxy in
|
||||
if let anchor = value{
|
||||
Color.clear
|
||||
.onAppear {
|
||||
headerHeight = proxy[anchor].height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.offset(y: -headerOffset < headerHeight ? headerOffset : (headerOffset < 0 ? headerOffset : 0))
|
||||
.opacity(1.0 - (abs(headerOffset/100.0)))
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
CustomPicker(tabs: [
|
||||
(NSLocalizedString("Notes", comment: "Label for filter for seeing only notes (instead of notes and replies)."), FilterState.posts),
|
||||
(NSLocalizedString("Notes & Replies", comment: "Label for filter for seeing notes and replies (instead of only notes)."), FilterState.posts_and_replies)
|
||||
],
|
||||
selection: $filter_state)
|
||||
|
||||
Divider()
|
||||
.frame(height: 1)
|
||||
}
|
||||
.background(DamusColors.adaptableWhite)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,6 @@ import SwiftUI
|
||||
struct TimelineView<Content: View>: View {
|
||||
@ObservedObject var events: EventHolder
|
||||
@Binding var loading: Bool
|
||||
@Binding var headerHeight: CGFloat
|
||||
@Binding var headerOffset: CGFloat
|
||||
@State var shiftOffset: CGFloat = 0
|
||||
@State var lastHeaderOffset: CGFloat = 0
|
||||
@State var direction: SwipeDirection = .none
|
||||
|
||||
let damus: DamusState
|
||||
let show_friend_icon: Bool
|
||||
@@ -22,23 +17,9 @@ struct TimelineView<Content: View>: View {
|
||||
let content: Content?
|
||||
let apply_mute_rules: Bool
|
||||
|
||||
init(events: EventHolder, loading: Binding<Bool>, headerHeight: Binding<CGFloat>, headerOffset: Binding<CGFloat>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
|
||||
self.events = events
|
||||
self._loading = loading
|
||||
self._headerHeight = headerHeight
|
||||
self._headerOffset = headerOffset
|
||||
self.damus = damus
|
||||
self.show_friend_icon = show_friend_icon
|
||||
self.filter = filter
|
||||
self.apply_mute_rules = apply_mute_rules
|
||||
self.content = content?()
|
||||
}
|
||||
|
||||
init(events: EventHolder, loading: Binding<Bool>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
|
||||
self.events = events
|
||||
self._loading = loading
|
||||
self._headerHeight = .constant(0.0)
|
||||
self._headerOffset = .constant(0.0)
|
||||
self.damus = damus
|
||||
self.show_friend_icon = show_friend_icon
|
||||
self.filter = filter
|
||||
@@ -57,43 +38,20 @@ struct TimelineView<Content: View>: View {
|
||||
content
|
||||
}
|
||||
|
||||
Color.clear
|
||||
Color.white.opacity(0)
|
||||
.id("startblock")
|
||||
.frame(height: 0)
|
||||
.frame(height: 1)
|
||||
|
||||
InnerTimelineView(events: events, damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules)
|
||||
.redacted(reason: loading ? .placeholder : [])
|
||||
.shimmer(loading)
|
||||
.disabled(loading)
|
||||
.padding(.top, headerHeight - getSafeAreaTop())
|
||||
.offsetY { previous, current in
|
||||
if previous > current{
|
||||
if direction != .up && current < 0 {
|
||||
shiftOffset = current - headerOffset
|
||||
direction = .up
|
||||
lastHeaderOffset = headerOffset
|
||||
}
|
||||
|
||||
let offset = current < 0 ? (current - shiftOffset) : 0
|
||||
headerOffset = (-offset < headerHeight ? (offset < 0 ? offset : 0) : -headerHeight)
|
||||
}else {
|
||||
if direction != .down {
|
||||
shiftOffset = current
|
||||
direction = .down
|
||||
lastHeaderOffset = headerOffset
|
||||
}
|
||||
|
||||
let offset = lastHeaderOffset + (current - shiftOffset)
|
||||
headerOffset = (offset > 0 ? 0 : offset)
|
||||
}
|
||||
}
|
||||
.background {
|
||||
GeometryReader { proxy -> Color in
|
||||
handle_scroll_queue(proxy, queue: self.events)
|
||||
return Color.clear
|
||||
}
|
||||
}
|
||||
.background(GeometryReader { proxy -> Color in
|
||||
handle_scroll_queue(proxy, queue: self.events)
|
||||
return Color.clear
|
||||
})
|
||||
}
|
||||
//.buttonStyle(BorderlessButtonStyle())
|
||||
.coordinateSpace(name: "scroll")
|
||||
.onReceive(handle_notify(.scroll_to_top)) { () in
|
||||
events.flush()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// AVPlayerView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Bryan Montz on 9/4/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
|
||||
struct DamusAVPlayerView: UIViewControllerRepresentable {
|
||||
|
||||
let player: AVPlayer
|
||||
var controller: AVPlayerViewController
|
||||
let show_playback_controls: Bool
|
||||
|
||||
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
||||
self.controller.showsPlaybackControls = show_playback_controls
|
||||
return self.controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
|
||||
if uiViewController.player == nil {
|
||||
uiViewController.player = player
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: ()) {
|
||||
uiViewController.player?.pause()
|
||||
uiViewController.player = nil
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
//
|
||||
// DamusVideoControlsView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-10-18.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
/// A view with playback video controls, made to work seamlessly with `DamusVideoPlayer`
|
||||
struct DamusVideoControlsView: View {
|
||||
@ObservedObject var video: DamusVideoPlayer
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Text(video_timestamp_indicator)
|
||||
.bold()
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
video.is_muted.toggle()
|
||||
}, label: {
|
||||
if video.is_muted {
|
||||
Image(systemName: "speaker.slash")
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
else {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
})
|
||||
.buttonStyle(PlayerCircleButtonStyle())
|
||||
}
|
||||
HStack {
|
||||
Button(action: {
|
||||
video.is_playing.toggle()
|
||||
}, label: {
|
||||
if video.is_playing {
|
||||
Image(systemName: "pause.fill")
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
else {
|
||||
Image(systemName: "play.fill")
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
})
|
||||
.buttonStyle(PlayerCircleButtonStyle())
|
||||
if let video_duration = video.duration, video_duration > 0 {
|
||||
Slider(value: $video.current_time, in: 0...video_duration, onEditingChanged: { editing in
|
||||
video.is_editing_current_time = editing
|
||||
})
|
||||
.tint(.white)
|
||||
}
|
||||
else {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
|
||||
var video_timestamp_indicator: String {
|
||||
guard let video_duration = video.duration else {
|
||||
return "\(formatTimeInterval(video.current_time))"
|
||||
}
|
||||
return "\(formatTimeInterval(video.current_time)) / \(formatTimeInterval(video_duration))"
|
||||
}
|
||||
|
||||
func formatTimeInterval(_ interval: TimeInterval) -> String {
|
||||
if interval.isNaN {
|
||||
return "--:--"
|
||||
}
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = interval >= 60 * 60 ? [.hour, .minute, .second] : [.minute, .second]
|
||||
formatter.unitsStyle = .positional
|
||||
formatter.zeroFormattingBehavior = [.pad]
|
||||
|
||||
guard let formattedString = formatter.string(from: interval) else {
|
||||
return ""
|
||||
}
|
||||
return formattedString
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerCircleButtonStyle: ButtonStyle {
|
||||
let padding: CGFloat
|
||||
|
||||
init(padding: CGFloat = 8.0) {
|
||||
self.padding = padding
|
||||
}
|
||||
|
||||
func makeBody(configuration: Self.Configuration) -> some View {
|
||||
return configuration.label
|
||||
.padding(padding)
|
||||
.foregroundColor(Color.white)
|
||||
.background {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.5))
|
||||
}
|
||||
.scaleEffect(configuration.isPressed ? 0.95 : 1)
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
//
|
||||
// DamusVideoCoordinator.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Bryan Montz on 9/3/23.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUICore
|
||||
import AVFoundation
|
||||
|
||||
/// DamusVideoCoordinator is responsible for coordinating the various video players throughout the app, and providing a nicely orchestrated experience.
|
||||
/// The goals of this object are to:
|
||||
/// - ensure some video playing states (such as mute state and current time) are consistent across different video player view instances of the same video
|
||||
/// - ensure only one video is playing at a time
|
||||
/// - Provide global video playback controls to control the currently playing video
|
||||
///
|
||||
/// This is used as a singleton object (one global object per `DamusState`), which gets passed around to video players, which can then interact with the coordinator to ensure an app-wide coherent experience
|
||||
///
|
||||
/// A good analogy here is that video players and their models/states are like individual cars and their drivers, and this coordinator is like a traffic control person + traffic lights that ensures cars don't crash each other.
|
||||
final class DamusVideoCoordinator: ObservableObject {
|
||||
// MARK: - States
|
||||
|
||||
// MARK: State and information about each video
|
||||
private var players: [URL: DamusVideoPlayer] = [:]
|
||||
|
||||
// MARK: Main stage requests from player views
|
||||
// The stacks of video player views that have marked themselves as visible on the user screen.
|
||||
//
|
||||
// Because our visibility tracker cannot tell if a player is obscured by a view in front of it,
|
||||
// we need to implement two stacks representing the different view layers:
|
||||
// - Normal layer: For timelines, threads, etc
|
||||
// - Full screen layer: For full screen views
|
||||
|
||||
private var normal_layer_main_stage_requests: [MainStageRequest] = []
|
||||
private var full_screen_layer_stage_requests: [MainStageRequest] = []
|
||||
|
||||
// MARK: Coordinator state
|
||||
// Members representing the state of the coordinator itself
|
||||
|
||||
private var full_screen_mode: Bool = false {
|
||||
didSet {
|
||||
self.select_focused_video()
|
||||
}
|
||||
}
|
||||
|
||||
/// The video currently in focus
|
||||
/// This can only be chosen by the coordinator. To get a video in focus, use one of the instance methods that provide an interface for focus control.
|
||||
@MainActor
|
||||
@Published private(set) var focused_video: DamusVideoPlayer? {
|
||||
didSet {
|
||||
oldValue?.pause()
|
||||
focused_video?.play()
|
||||
Log.info("VIDEO_COORDINATOR: %s paused, playing %s", for: .video_coordination, oldValue?.url.absoluteString ?? "no video", focused_video?.url.absoluteString ?? "no video")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interface to set and fetch information about each different video
|
||||
|
||||
|
||||
@MainActor
|
||||
func get_player(for url: URL) -> DamusVideoPlayer {
|
||||
if let player = self.players[url] {
|
||||
return player
|
||||
}
|
||||
let player = DamusVideoPlayer(url: url)
|
||||
self.players[url] = player
|
||||
return player
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Interface for video players to come to the foreground
|
||||
// This portion provides an interface for video players to signal their visibility changes,
|
||||
// and implements some coordination logic to choose which video to play and pause at a given time.
|
||||
|
||||
func request_main_stage(_ request: MainStageRequest) {
|
||||
Log.info("VIDEO_COORDINATOR: %s requested main stage", for: .video_coordination, request.requestor_id.uuidString)
|
||||
switch request.layer_context {
|
||||
case .normal_layer:
|
||||
if normal_layer_main_stage_requests.first(where: { $0.requestor_id == request.requestor_id }) != nil { return } // Entry exists already
|
||||
normal_layer_main_stage_requests.append(request)
|
||||
case .full_screen_layer:
|
||||
if full_screen_layer_stage_requests.first(where: { $0.requestor_id == request.requestor_id }) != nil { return } // Entry exists already
|
||||
full_screen_layer_stage_requests.append(request)
|
||||
}
|
||||
self.select_focused_video()
|
||||
}
|
||||
|
||||
func give_up_main_stage(request_id: UUID) {
|
||||
Log.info("VIDEO_COORDINATOR: %s gave up the main stage", for: .video_coordination, request_id.uuidString)
|
||||
normal_layer_main_stage_requests.removeAll(where: { $0.requestor_id == request_id })
|
||||
full_screen_layer_stage_requests.removeAll(where: { $0.requestor_id == request_id })
|
||||
self.select_focused_video()
|
||||
}
|
||||
|
||||
// MARK: - Additional interface to help with video coordination
|
||||
|
||||
func set_full_screen_mode(_ is_full_screen: Bool) {
|
||||
full_screen_mode = is_full_screen
|
||||
}
|
||||
|
||||
// MARK: - Internal video coordination logic
|
||||
|
||||
private func select_focused_video() {
|
||||
// This function may be called during a SwiftUI view update,
|
||||
// so schedule this change for the next render pass to ensure state immutability/stability within a single render pass
|
||||
DispatchQueue.main.async { [weak self] in // [weak self] to safeguard in cases this object is deallocated by the time we execute this task
|
||||
guard let self else { return }
|
||||
// The focused video will always be the last one that was inserted — similar to a LIFO stack
|
||||
// The reason is that:
|
||||
// - both a LIFO stack and a FIFO queue are decent at selecting videos when scrolling on the Y axis (timeline),
|
||||
// - The LIFO stack is better at selecting videos when navigating on the Z axis (e.g. opening and closing full screen covers or sheets), since those sheets operate like a stack as well
|
||||
let winning_request = self.full_screen_mode ? self.full_screen_layer_stage_requests.last : self.normal_layer_main_stage_requests.last
|
||||
self.focused_video = winning_request?.player
|
||||
winning_request?.main_stage_granted?()
|
||||
}
|
||||
Log.info("VIDEO_COORDINATOR: fullscreen layer main stage request stack: %s", for: .video_coordination, full_screen_layer_stage_requests.map({ $0.requestor_id.uuidString }).debugDescription)
|
||||
Log.info("VIDEO_COORDINATOR: normal layer main stage request stack: %s", for: .video_coordination, normal_layer_main_stage_requests.map({ $0.requestor_id.uuidString }).debugDescription)
|
||||
Log.info("VIDEO_COORDINATOR: full_screen_mode: %s", for: .video_coordination, String(describing: self.full_screen_mode))
|
||||
}
|
||||
|
||||
// MARK: - Helper structures
|
||||
|
||||
struct MainStageRequest {
|
||||
var requestor_id: UUID
|
||||
var layer_context: ViewLayerContext
|
||||
var player: DamusVideoPlayer
|
||||
var main_stage_granted: (() -> Void)?
|
||||
}
|
||||
}
|
||||
@@ -1,248 +1,178 @@
|
||||
//
|
||||
// DamusVideoPlayer.swift
|
||||
// VideoPlayerView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Bryan Montz on 9/5/23.
|
||||
// Created by William Casarin on 2023-04-05.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// DamusVideoPlayer has the function of wrapping `AVPlayer` and exposing a control interface that integrates seamlessly with SwiftUI views
|
||||
///
|
||||
/// This is **NOT** a video player view. This is a headless video object concerned about the video and its playback. To display a video, you need `DamusVideoPlayerView`
|
||||
/// This is also **NOT** a control view. Please see `DamusVideoControlsView` for that.
|
||||
///
|
||||
/// **Implementation notes:**
|
||||
/// - `@MainActor` is needed because `@Published` properties need to be updated on the main thread to avoid SwiftUI mutations within a single render pass
|
||||
/// - `@Published` variables are the chosen interface because they integrate very seamlessly with SwiftUI views. Avoid the use of procedural functions to avoid SwiftUI state desync.
|
||||
@MainActor final class DamusVideoPlayer: ObservableObject {
|
||||
|
||||
// MARK: Immutable foundational instance members
|
||||
|
||||
/// The URL of the video
|
||||
/// get coordinates in Global reference frame given a Local point & geometry
|
||||
func globalCoordinate(localX x: CGFloat, localY y: CGFloat,
|
||||
localGeometry geo: GeometryProxy) -> CGPoint {
|
||||
let localPoint = CGPoint(x: x, y: y)
|
||||
return geo.frame(in: .global).origin.applying(
|
||||
.init(translationX: localPoint.x, y: localPoint.y)
|
||||
)
|
||||
}
|
||||
|
||||
struct DamusVideoPlayer: View {
|
||||
let url: URL
|
||||
/// The underlying AVPlayer that we are wrapping.
|
||||
/// This is not public because we don't want any callers of this class controlling the `AVPlayer` directly, we want them to go through our interface
|
||||
/// This measure helps avoid state inconsistencies and other flakiness. DO NOT USE THIS OUTSIDE `DamusVideoPlayer`
|
||||
private let player: AVPlayer
|
||||
@StateObject var model: DamusVideoPlayerViewModel
|
||||
@EnvironmentObject private var orientationTracker: OrientationTracker
|
||||
let style: Style
|
||||
let visibility_tracking_method: VisibilityTrackingMethod
|
||||
@State var isVisible: Bool = false
|
||||
|
||||
|
||||
// MARK: SwiftUI-friendly interface
|
||||
|
||||
/// Indicates whether the video has audio at all
|
||||
@Published private(set) var has_audio = false
|
||||
/// Whether whether this is a live video
|
||||
@Published private(set) var is_live = false
|
||||
/// The video size
|
||||
@Published private(set) var video_size: CGSize?
|
||||
/// Whether or not to mute the video
|
||||
@Published var is_muted = true {
|
||||
didSet {
|
||||
if oldValue == is_muted { return }
|
||||
player.isMuted = is_muted
|
||||
init(url: URL, video_size: Binding<CGSize?>, controller: VideoController, style: Style, visibility_tracking_method: VisibilityTrackingMethod = .y_scroll) {
|
||||
self.url = url
|
||||
let mute: Bool?
|
||||
if case .full = style {
|
||||
mute = false
|
||||
}
|
||||
else {
|
||||
mute = nil
|
||||
}
|
||||
_model = StateObject(wrappedValue: DamusVideoPlayerViewModel(url: url, video_size: video_size, controller: controller, mute: mute))
|
||||
self.visibility_tracking_method = visibility_tracking_method
|
||||
self.style = style
|
||||
}
|
||||
/// Whether the video is loading
|
||||
@Published private(set) var is_loading = true
|
||||
/// The current time of playback, in seconds
|
||||
/// Usage note: If editing (such as in a slider), make sure to set `is_editing_current_time` to `true` to detach this value from the current playback
|
||||
@Published var current_time: TimeInterval = .zero
|
||||
/// Whether video is playing or not
|
||||
@Published var is_playing = false {
|
||||
didSet {
|
||||
if oldValue == is_playing { return }
|
||||
// When scrubbing, the playback control is temporarily decoupled, so don't play/pause our `AVPlayer`
|
||||
// When scrubbing stops, the `is_editing_current_time` handler will automatically play/pause depending on `is_playing`
|
||||
if is_editing_current_time { return }
|
||||
if is_playing {
|
||||
player.play()
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let localFrame = geo.frame(in: .local)
|
||||
let centerY = globalCoordinate(localX: 0, localY: localFrame.midY, localGeometry: geo).y
|
||||
ZStack {
|
||||
if case .full = self.style {
|
||||
DamusAVPlayerView(player: model.player, controller: model.player_view_controller, show_playback_controls: true)
|
||||
}
|
||||
if case .preview(let on_tap) = self.style {
|
||||
DamusAVPlayerView(player: model.player, controller: model.player_view_controller, show_playback_controls: false)
|
||||
.simultaneousGesture(TapGesture().onEnded({
|
||||
on_tap?()
|
||||
}))
|
||||
}
|
||||
|
||||
if model.is_loading {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.tint(.white)
|
||||
.scaleEffect(CGSize(width: 1.5, height: 1.5))
|
||||
}
|
||||
|
||||
if case .preview = self.style {
|
||||
if model.has_audio {
|
||||
mute_button
|
||||
}
|
||||
}
|
||||
if model.is_live {
|
||||
live_indicator
|
||||
}
|
||||
}
|
||||
else {
|
||||
player.pause()
|
||||
.onChange(of: centerY) { _ in
|
||||
if case .y_scroll = visibility_tracking_method {
|
||||
update_is_visible(centerY: centerY)
|
||||
}
|
||||
}
|
||||
.on_visibility_change(perform: { new_visibility in
|
||||
if case .generic = visibility_tracking_method {
|
||||
model.set_view_is_visible(new_visibility)
|
||||
}
|
||||
})
|
||||
.onAppear {
|
||||
if case .y_scroll = visibility_tracking_method {
|
||||
update_is_visible(centerY: centerY)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if case .y_scroll = visibility_tracking_method {
|
||||
model.view_did_disappear()
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the current time is being manually edited (e.g. when user is scrubbing through the video)
|
||||
/// **Implementation note:** When set to `true`, this decouples the `current_time` from the video playback observer — in a way analogous to a clutch on a standard transmission car, if you are into Automotive engineering.
|
||||
@Published var is_editing_current_time = false {
|
||||
didSet {
|
||||
if oldValue == is_editing_current_time { return }
|
||||
if !is_editing_current_time {
|
||||
Task {
|
||||
await self.player.seek(to: CMTime(seconds: current_time, preferredTimescale: 60))
|
||||
// Start playing video again, if we were playing before scrubbing
|
||||
if self.is_playing {
|
||||
self.player.play()
|
||||
|
||||
private func update_is_visible(centerY: CGFloat) {
|
||||
let isBelowTop = centerY > 100, /// 100 =~ approx. bottom (y) of ContentView's TabView
|
||||
isAboveBottom = centerY < orientationTracker.deviceMajorAxis
|
||||
model.set_view_is_visible(isBelowTop && isAboveBottom)
|
||||
}
|
||||
|
||||
private var mute_icon: String {
|
||||
!model.has_audio || model.is_muted ? "speaker.slash" : "speaker"
|
||||
}
|
||||
|
||||
private var mute_icon_color: Color {
|
||||
model.has_audio ? .white : .red
|
||||
}
|
||||
|
||||
private var mute_button: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
model.did_tap_mute_button()
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.opacity(0.2)
|
||||
.frame(width: 32, height: 32)
|
||||
.foregroundColor(.black)
|
||||
|
||||
Image(systemName: mute_icon)
|
||||
.padding()
|
||||
.foregroundColor(mute_icon_color)
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Pause playing video, if we were playing before we started scrubbing
|
||||
if self.is_playing { self.player.pause() }
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The duration of the video, in seconds.
|
||||
var duration: TimeInterval? {
|
||||
return player.currentItem?.duration.seconds
|
||||
}
|
||||
|
||||
// MARK: Internal instance members
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var videoSizeObserver: NSKeyValueObservation?
|
||||
private var videoDurationObserver: NSKeyValueObservation?
|
||||
private var videoCurrentTimeObserver: Any?
|
||||
private var videoIsPlayingObserver: NSKeyValueObservation?
|
||||
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(url: URL) {
|
||||
self.url = url
|
||||
self.player = AVPlayer(playerItem: AVPlayerItem(url: url))
|
||||
self.video_size = nil
|
||||
|
||||
Task {
|
||||
await load()
|
||||
}
|
||||
|
||||
player.isMuted = is_muted
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(did_play_to_end),
|
||||
name: Notification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: player.currentItem
|
||||
)
|
||||
|
||||
observeVideoSize()
|
||||
observeDuration()
|
||||
observeCurrentTime()
|
||||
observeVideoIsPlaying()
|
||||
}
|
||||
|
||||
// MARK: - Observers
|
||||
// Functions that allow us to observe certain variables and publish their changes for view updates
|
||||
// These are all private because they are part of the internal logic
|
||||
|
||||
private func observeVideoSize() {
|
||||
videoSizeObserver = player.currentItem?.observe(\.presentationSize, options: [.new], changeHandler: { [weak self] (playerItem, change) in
|
||||
guard let self else { return }
|
||||
if let newSize = change.newValue, newSize != .zero {
|
||||
DispatchQueue.main.async {
|
||||
self.video_size = newSize // Update the bound value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func observeDuration() {
|
||||
videoDurationObserver = player.currentItem?.observe(\.duration, options: [.new], changeHandler: { [weak self] (playerItem, change) in
|
||||
guard let self else { return }
|
||||
if let newDuration = change.newValue, newDuration != .zero {
|
||||
DispatchQueue.main.async {
|
||||
self.is_live = newDuration == .indefinite
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func observeCurrentTime() {
|
||||
videoCurrentTimeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 1, preferredTimescale: 600), queue: .main) { [weak self] time in
|
||||
guard let self else { return }
|
||||
DispatchQueue.main.async { // Must use main thread to update @Published properties
|
||||
if self.is_editing_current_time == false {
|
||||
self.current_time = time.seconds
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func observeVideoIsPlaying() {
|
||||
videoIsPlayingObserver = player.observe(\.rate, changeHandler: { [weak self] (player, change) in
|
||||
guard let self else { return }
|
||||
guard let new_rate = change.newValue else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.is_playing = new_rate > 0
|
||||
private var live_indicator: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Text("LIVE", comment: "Text indicator that the video is a livestream.")
|
||||
.bold()
|
||||
.foregroundColor(.red)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 5)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.black.opacity(0.5))
|
||||
)
|
||||
.padding([.top, .leading])
|
||||
Spacer()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Other internal logic functions
|
||||
|
||||
private func load() async {
|
||||
has_audio = await self.video_has_audio()
|
||||
is_loading = false
|
||||
}
|
||||
|
||||
private func video_has_audio() async -> Bool {
|
||||
do {
|
||||
let hasAudibleTracks = ((try await player.currentItem?.asset.loadMediaSelectionGroup(for: .audible)) != nil)
|
||||
let tracks = try? await player.currentItem?.asset.load(.tracks)
|
||||
let hasAudioTrack = tracks?.filter({ t in t.mediaType == .audio }).first != nil // Deal with odd cases of audio only MOV
|
||||
return hasAudibleTracks || hasAudioTrack
|
||||
} catch {
|
||||
return false
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func did_play_to_end() {
|
||||
player.seek(to: CMTime.zero)
|
||||
player.play()
|
||||
enum Style {
|
||||
/// A full video player with playback controls
|
||||
case full
|
||||
/// A style suitable for muted, auto-playing videos on a feed
|
||||
case preview(on_tap: (() -> Void)?)
|
||||
}
|
||||
|
||||
// MARK: - Deinit
|
||||
|
||||
deinit {
|
||||
videoSizeObserver?.invalidate()
|
||||
videoDurationObserver?.invalidate()
|
||||
videoIsPlayingObserver?.invalidate()
|
||||
}
|
||||
|
||||
// MARK: - Convenience interface functions
|
||||
|
||||
func play() {
|
||||
self.is_playing = true
|
||||
}
|
||||
|
||||
func pause() {
|
||||
self.is_playing = false
|
||||
enum VisibilityTrackingMethod {
|
||||
/// Detects visibility based on its Y position relative to viewport. Ideal for long feeds
|
||||
case y_scroll
|
||||
/// Detects visibility based whether the view intersects with the viewport
|
||||
case generic
|
||||
}
|
||||
}
|
||||
|
||||
extension DamusVideoPlayer {
|
||||
/// The simplest view for a `DamusVideoPlayer` object.
|
||||
///
|
||||
/// Other views with more features should use this as a base.
|
||||
///
|
||||
/// ## Implementation notes:
|
||||
///
|
||||
/// 1. This is defined inside `DamusVideoPlayer` to allow it to access the private `AVPlayer` instance required to initialize it, which is otherwise hidden away from every other class.
|
||||
/// 2. DO NOT write any `AVPlayer` control/manipulation code, the `AVPlayer` instance is owned by `DamusVideoPlayer` and only managed there to keep things sane.
|
||||
struct BaseView: UIViewControllerRepresentable {
|
||||
|
||||
let player: DamusVideoPlayer
|
||||
let show_playback_controls: Bool
|
||||
|
||||
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
||||
let controller = AVPlayerViewController()
|
||||
controller.showsPlaybackControls = show_playback_controls
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
|
||||
if uiViewController.player == nil {
|
||||
uiViewController.player = player.player
|
||||
}
|
||||
}
|
||||
|
||||
static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: ()) {
|
||||
uiViewController.player = nil
|
||||
struct DamusVideoPlayer_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
DamusVideoPlayer(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, video_size: .constant(nil), controller: VideoController(), style: .full)
|
||||
.environmentObject(OrientationTracker())
|
||||
.previewDisplayName("Full video player")
|
||||
|
||||
DamusVideoPlayer(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, video_size: .constant(nil), controller: VideoController(), style: .preview(on_tap: nil))
|
||||
.environmentObject(OrientationTracker())
|
||||
.previewDisplayName("Preview video player")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
//
|
||||
// DamusVideoPlayerView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-05.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// get coordinates in Global reference frame given a Local point & geometry
|
||||
func globalCoordinate(localX x: CGFloat, localY y: CGFloat,
|
||||
localGeometry geo: GeometryProxy) -> CGPoint {
|
||||
let localPoint = CGPoint(x: x, y: y)
|
||||
return geo.frame(in: .global).origin.applying(
|
||||
.init(translationX: localPoint.x, y: localPoint.y)
|
||||
)
|
||||
}
|
||||
|
||||
/// A feature-rich, generic video player view that plays along well with the multi-video coordinator
|
||||
struct DamusVideoPlayerView: View {
|
||||
let url: URL
|
||||
@ObservedObject var model: DamusVideoPlayer
|
||||
let style: Style
|
||||
let main_state_requestor_id: UUID = UUID()
|
||||
|
||||
@State var is_visible: Bool = false {
|
||||
didSet {
|
||||
if self.is_visible {
|
||||
// We are visible, request main stage
|
||||
video_coordinator.request_main_stage(
|
||||
DamusVideoCoordinator.MainStageRequest(
|
||||
requestor_id: self.main_state_requestor_id,
|
||||
layer_context: self.view_layer,
|
||||
player: self.model,
|
||||
main_stage_granted: self.main_stage_granted
|
||||
)
|
||||
)
|
||||
}
|
||||
else {
|
||||
// We are no longer visible, give up the main stage
|
||||
video_coordinator.give_up_main_stage(request_id: self.main_state_requestor_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The context this video player is in.
|
||||
@Environment(\.view_layer_context) var view_layer_context
|
||||
/// The video coordinator in this environment
|
||||
let video_coordinator: DamusVideoCoordinator
|
||||
|
||||
var view_layer: ViewLayerContext {
|
||||
return view_layer_context ?? .normal_layer
|
||||
}
|
||||
|
||||
init(url: URL, coordinator: DamusVideoCoordinator, style: Style) {
|
||||
self.url = url
|
||||
self.model = coordinator.get_player(for: url)
|
||||
self.video_coordinator = coordinator
|
||||
self.style = style
|
||||
}
|
||||
|
||||
init(model: DamusVideoPlayer, coordinator: DamusVideoCoordinator, style: Style) {
|
||||
self.url = model.url
|
||||
self.model = model
|
||||
self.video_coordinator = coordinator
|
||||
self.style = style
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
switch self.style {
|
||||
case .full:
|
||||
DamusVideoPlayer.BaseView(player: model, show_playback_controls: true)
|
||||
case .preview(on_tap: let on_tap), .no_controls(on_tap: let on_tap):
|
||||
if let on_tap {
|
||||
DamusVideoPlayer.BaseView(player: model, show_playback_controls: false)
|
||||
.highPriorityGesture(TapGesture().onEnded({
|
||||
on_tap()
|
||||
}))
|
||||
}
|
||||
else {
|
||||
DamusVideoPlayer.BaseView(player: model, show_playback_controls: false)
|
||||
}
|
||||
}
|
||||
|
||||
if model.is_loading {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.tint(.white)
|
||||
.scaleEffect(CGSize(width: 1.5, height: 1.5))
|
||||
}
|
||||
|
||||
if case .preview = self.style {
|
||||
if model.has_audio {
|
||||
mute_button
|
||||
}
|
||||
}
|
||||
if model.is_live {
|
||||
live_indicator
|
||||
}
|
||||
}
|
||||
.on_visibility_change(perform: { new_is_visible in
|
||||
self.is_visible = new_is_visible
|
||||
}, method: self.visibility_tracking_method)
|
||||
}
|
||||
|
||||
private var visibility_tracking_method: VisibilityTracker.Method {
|
||||
switch self.view_layer {
|
||||
case .normal_layer:
|
||||
return .standard
|
||||
case .full_screen_layer:
|
||||
return .no_y_scroll_detection
|
||||
}
|
||||
}
|
||||
|
||||
func main_stage_granted() {
|
||||
switch self.style {
|
||||
case .full, .no_controls:
|
||||
self.model.is_muted = false
|
||||
case .preview:
|
||||
self.model.is_muted = true
|
||||
}
|
||||
}
|
||||
|
||||
private var mute_icon: String {
|
||||
!model.has_audio || model.is_muted ? "speaker.slash" : "speaker"
|
||||
}
|
||||
|
||||
private var mute_icon_color: Color {
|
||||
model.has_audio ? .white : .red
|
||||
}
|
||||
|
||||
private var mute_button: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack {
|
||||
ZStack {
|
||||
Circle()
|
||||
.opacity(0.2)
|
||||
.frame(width: 32, height: 32)
|
||||
.foregroundColor(.black)
|
||||
|
||||
Image(systemName: mute_icon)
|
||||
.padding()
|
||||
.foregroundColor(mute_icon_color)
|
||||
}
|
||||
.highPriorityGesture(TapGesture().onEnded {
|
||||
model.is_muted.toggle()
|
||||
})
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var live_indicator: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Text("LIVE", comment: "Text indicator that the video is a livestream.")
|
||||
.bold()
|
||||
.foregroundColor(.red)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 5)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.black.opacity(0.5))
|
||||
)
|
||||
.padding([.top, .leading])
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper structures
|
||||
|
||||
enum Style {
|
||||
/// A full video player with playback controls
|
||||
case full
|
||||
/// A style suitable for muted, auto-playing videos on a feed
|
||||
case preview(on_tap: (() -> Void)?)
|
||||
/// A video player without any playback controls, suitable if using custom controls elsewhere.
|
||||
case no_controls(on_tap: (() -> Void)?)
|
||||
}
|
||||
}
|
||||
struct DamusVideoPlayer_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
DamusVideoPlayerView(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, coordinator: DamusVideoCoordinator(), style: .full)
|
||||
.environmentObject(OrientationTracker())
|
||||
.environmentObject(DamusVideoCoordinator())
|
||||
.previewDisplayName("Full video player")
|
||||
|
||||
DamusVideoPlayerView(url: URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!, coordinator: DamusVideoCoordinator(), style: .preview(on_tap: nil))
|
||||
.environmentObject(OrientationTracker())
|
||||
.environmentObject(DamusVideoCoordinator())
|
||||
.previewDisplayName("Preview video player")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//
|
||||
// DamusVideoPlayerViewModel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Bryan Montz on 9/5/23.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
func video_has_audio(player: AVPlayer) async -> Bool {
|
||||
do {
|
||||
let hasAudibleTracks = ((try await player.currentItem?.asset.loadMediaSelectionGroup(for: .audible)) != nil)
|
||||
let tracks = try? await player.currentItem?.asset.load(.tracks)
|
||||
let hasAudioTrack = tracks?.filter({ t in t.mediaType == .audio }).first != nil // Deal with odd cases of audio only MOV
|
||||
return hasAudibleTracks || hasAudioTrack
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DamusVideoPlayerViewModel: ObservableObject {
|
||||
|
||||
private let url: URL
|
||||
private let player_item: AVPlayerItem
|
||||
let player: AVPlayer
|
||||
fileprivate let controller: VideoController
|
||||
let player_view_controller = AVPlayerViewController()
|
||||
let id = UUID()
|
||||
|
||||
@Published var has_audio = false
|
||||
@Published var is_live = false
|
||||
@Binding var video_size: CGSize?
|
||||
@Published var is_muted = true
|
||||
@Published var is_loading = true
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private var videoSizeObserver: NSKeyValueObservation?
|
||||
private var videoDurationObserver: NSKeyValueObservation?
|
||||
|
||||
private var is_scrolled_into_view = false {
|
||||
didSet {
|
||||
if is_scrolled_into_view && !oldValue {
|
||||
// we have just scrolled from out of view into view
|
||||
controller.focused_model_id = id
|
||||
} else if !is_scrolled_into_view && oldValue {
|
||||
// we have just scrolled from in view to out of view
|
||||
if controller.focused_model_id == id {
|
||||
controller.focused_model_id = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(url: URL, video_size: Binding<CGSize?>, controller: VideoController, mute: Bool? = nil) {
|
||||
self.url = url
|
||||
player_item = AVPlayerItem(url: url)
|
||||
player = AVPlayer(playerItem: player_item)
|
||||
self.controller = controller
|
||||
_video_size = video_size
|
||||
|
||||
Task {
|
||||
await load()
|
||||
}
|
||||
|
||||
is_muted = mute ?? controller.should_mute_video(url: url)
|
||||
player.isMuted = is_muted
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(did_play_to_end),
|
||||
name: Notification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: player_item
|
||||
)
|
||||
|
||||
controller.$focused_model_id
|
||||
.sink { [weak self] model_id in
|
||||
model_id == self?.id ? self?.player.play() : self?.player.pause()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
observeVideoSize()
|
||||
observeDuration()
|
||||
}
|
||||
|
||||
private func observeVideoSize() {
|
||||
videoSizeObserver = player.currentItem?.observe(\.presentationSize, options: [.new], changeHandler: { [weak self] (playerItem, change) in
|
||||
guard let self else { return }
|
||||
if let newSize = change.newValue, newSize != .zero {
|
||||
DispatchQueue.main.async {
|
||||
self.video_size = newSize // Update the bound value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func observeDuration() {
|
||||
videoDurationObserver = player.currentItem?.observe(\.duration, options: [.new], changeHandler: { [weak self] (playerItem, change) in
|
||||
guard let self else { return }
|
||||
if let newDuration = change.newValue, newDuration != .zero {
|
||||
DispatchQueue.main.async {
|
||||
self.is_live = newDuration == .indefinite
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
if let meta = controller.metadata(for: url) {
|
||||
has_audio = meta.has_audio
|
||||
video_size = meta.size
|
||||
} else {
|
||||
has_audio = await video_has_audio(player: player)
|
||||
}
|
||||
|
||||
is_loading = false
|
||||
}
|
||||
|
||||
func did_tap_mute_button() {
|
||||
is_muted.toggle()
|
||||
player.isMuted = is_muted
|
||||
controller.toggle_should_mute_video(url: url)
|
||||
}
|
||||
|
||||
func set_view_is_visible(_ is_visible: Bool) {
|
||||
is_scrolled_into_view = is_visible
|
||||
}
|
||||
|
||||
func view_did_disappear() {
|
||||
set_view_is_visible(false)
|
||||
}
|
||||
|
||||
@objc private func did_play_to_end() {
|
||||
player.seek(to: CMTime.zero)
|
||||
player.play()
|
||||
}
|
||||
|
||||
deinit {
|
||||
videoSizeObserver?.invalidate()
|
||||
videoDurationObserver?.invalidate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// VideoController.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Bryan Montz on 9/3/23.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
struct VideoMetadata {
|
||||
let has_audio: Bool
|
||||
let size: CGSize
|
||||
}
|
||||
|
||||
final class VideoController: ObservableObject {
|
||||
private var mute_states: [URL: Bool] = [:]
|
||||
private var metadatas: [URL: VideoMetadata] = [:]
|
||||
|
||||
@Published var focused_model_id: UUID?
|
||||
|
||||
func toggle_should_mute_video(url: URL) {
|
||||
let state = mute_states[url] ?? true
|
||||
mute_states[url] = !state
|
||||
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
func should_mute_video(url: URL) -> Bool {
|
||||
mute_states[url] ?? true
|
||||
}
|
||||
|
||||
func set_metadata(_ metadata: VideoMetadata, url: URL) {
|
||||
metadatas[url] = metadata
|
||||
}
|
||||
|
||||
func metadata(for url: URL) -> VideoMetadata? {
|
||||
metadatas[url]
|
||||
}
|
||||
|
||||
func size_for_url(_ url: URL) -> CGSize? {
|
||||
metadatas[url]?.size
|
||||
}
|
||||
}
|
||||
@@ -178,7 +178,7 @@ struct ConnectWalletView: View {
|
||||
Text("Damus Wallet", comment: "Title text for Damus Wallet view.")
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text("Securely connect your Damus app to your wallet using Nostr Wallet Connect", comment: "Text to prompt user to connect their wallet using 'Nostr Wallet Connect'.")
|
||||
Text("Securely connect your Damus app to your wallet using Nostr\u{00A0}Wallet\u{00A0}Connect", comment: "Text to prompt user to connect their wallet using 'Nostr Wallet Connect'.")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CodeScanner
|
||||
|
||||
enum WalletScanResult: Equatable {
|
||||
static func == (lhs: WalletScanResult, rhs: WalletScanResult) -> Bool {
|
||||
|
||||
@@ -28,7 +28,6 @@ struct ZapsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, tabHeight)
|
||||
.navigationBarTitle(NSLocalizedString("Zaps", comment: "Navigation bar title for the Zaps view."))
|
||||
.onAppear {
|
||||
model.subscribe()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -2,30 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>followed_by_three_and_others</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@OTHERS@</string>
|
||||
<key>OTHERS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>متابع من قبل %2$@, %3$@, %4$@ & %1$d وآخرين</string>
|
||||
<key>one</key>
|
||||
<string>متابع من قبل %2$@, %3$@, %4$@ & %1$d آخر</string>
|
||||
<key>two</key>
|
||||
<string>متابع من قبل %2$@, %3$@, %4$@ & %1$d وآخرين</string>
|
||||
<key>few</key>
|
||||
<string>متابع من قبل %2$@, %3$@, %4$@ & %1$d وآخرين</string>
|
||||
<key>many</key>
|
||||
<string>متابع من قبل %2$@, %3$@, %4$@ & %1$d وآخرين</string>
|
||||
<key>other</key>
|
||||
<string>متابع من قبل %2$@, %3$@, %4$@ & %1$d وآخرين</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>followers_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
@@ -74,30 +50,6 @@
|
||||
<string>المتابَعون</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>imports_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@IMPORTS@</string>
|
||||
<key>IMPORTS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>المستوردات</string>
|
||||
<key>one</key>
|
||||
<string>استورد</string>
|
||||
<key>two</key>
|
||||
<string>المستوردات</string>
|
||||
<key>few</key>
|
||||
<string>المستوردات</string>
|
||||
<key>many</key>
|
||||
<string>المستوردات</string>
|
||||
<key>other</key>
|
||||
<string>المستوردات</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>reacted_tagged_in_3</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
@@ -109,20 +61,20 @@
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>%2$@ و %1$ وغيرهم تفاعلوا مع منشور تم ذكر حسابك فيه</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر تفاعل مع منشور تمت الإشارة لك فيه</string>
|
||||
<key>one</key>
|
||||
<string>%2$@ و %1$ تفاعل مع منشور تم ذكر حسابك فيه</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر تفاعل مع منشور تمت الإشارة لك فيه</string>
|
||||
<key>two</key>
|
||||
<string>%2$@ و %1$ تفاعلا مع منشور تم ذكر حسابك فيه</string>
|
||||
<string>%2$@ و %1$d آخران تفاعلوا مع منشور تمت الإشارة لك فيه</string>
|
||||
<key>few</key>
|
||||
<string>%2$@ و %1$ تفاعلا مع منشور تم ذكر حسابك فيه</string>
|
||||
<string>%2$@ و %1$d آخرون تفاعلوا مع منشور تمت الإشارة لك فيه</string>
|
||||
<key>many</key>
|
||||
<string>%2$@ و %1$ وغيرهم تفاعلوا مع منشور تم ذكر حسابك فيه</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر تفاعلوا مع منشور تمت الإشارة لك فيه</string>
|
||||
<key>other</key>
|
||||
<string>%2$@ و %1$ وغيرهم تفاعلوا مع منشور تم ذكر حسابك فيه</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر تفاعلوا مع منشور تمت الإشارة لك فيه</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>reacted_your_note_3</key>
|
||||
<key>reacted_your_post_3</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@REACTED@</string>
|
||||
@@ -133,17 +85,17 @@
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>%2$@ و %1$d وغيرهم تفاعلوا مع منشورك</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر تفاعل مع منشورك</string>
|
||||
<key>one</key>
|
||||
<string>%2$@ و %1$d أخر تفاعل مع منشورك</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر تفاعل مع منشورك</string>
|
||||
<key>two</key>
|
||||
<string>%2$@ و %1$d غيرهم تفاعلا مع منشورك</string>
|
||||
<string>%2$@ و %1$d آخران تفاعلا مع منشورك</string>
|
||||
<key>few</key>
|
||||
<string>%2$@ و %1$d غيرهم تفاعلوا مع منشورك</string>
|
||||
<string>%2$@ و %1$d آخرون تفاعلوا مع منشورك</string>
|
||||
<key>many</key>
|
||||
<string>%2$@ و %1$d غيرهم تفاعلوا مع منشورك</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر تفاعلوا مع منشورك</string>
|
||||
<key>other</key>
|
||||
<string>%2$@ و %1$d غيرهم تفاعلوا مع منشورك</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر تفاعلوا مع منشورك</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>reacted_your_profile_3</key>
|
||||
@@ -253,20 +205,20 @@
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>%2$@ و %1$d غيرهم أعادوا مشاركة منشور تم ذكر حسابك فيه</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر نشر منشورا تمت الإشارة لك فيه</string>
|
||||
<key>one</key>
|
||||
<string>%2$@ و %1$d أخر أعاد مشاركة منشور تم ذكر حسابك فيه</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر نشر منشورا تمت الإشارة لك فيه</string>
|
||||
<key>two</key>
|
||||
<string>%2$@ و %1$d غيرهم أعادا مشاركة منشور تم ذكر حسابك فيه</string>
|
||||
<string>%2$@ و %1$d آخران نشروا منشورا تمت الإشارة لك فيه</string>
|
||||
<key>few</key>
|
||||
<string>%2$@ و %1$d غيرهم أعادوا مشاركة منشور تم ذكر حسابك فيه</string>
|
||||
<string>%2$@ و %1$d آخرون نشروا منشورا تمت الإشارة لك فيه</string>
|
||||
<key>many</key>
|
||||
<string>%2$@ و %1$d غيرهم أعادوا مشاركة منشور تم ذكر حسابك فيه</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر نشروا منشورا تمت الإشارة لك فيه</string>
|
||||
<key>other</key>
|
||||
<string>%2$@ و %1$d غيرهم أعادوا مشاركة منشور تم ذكر حسابك فيه</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر نشروا منشورا تمت الإشارة لك فيه</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>reposted_your_note_3</key>
|
||||
<key>reposted_your_post_3</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@REPOSTED@</string>
|
||||
@@ -277,17 +229,17 @@
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>%2$@ و %1$d أعاد مشاركة منشورك</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر نشر منشورك</string>
|
||||
<key>one</key>
|
||||
<string>%2$@ و %1$d أعاد مشاركة منشورك</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر نشر منشورك</string>
|
||||
<key>two</key>
|
||||
<string>%2$@ و %1$d أعادا مشاركة منشورك</string>
|
||||
<string>%2$@ و %1$d آخران نشروا منشورك</string>
|
||||
<key>few</key>
|
||||
<string>%2$@ و %1$d أعادوا مشاركة منشورك</string>
|
||||
<string>%2$@ و %1$d آخرون نشروا منشورك</string>
|
||||
<key>many</key>
|
||||
<string>%2$@ و %1$d أعادوا مشاركة منشورك</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر نشروا منشورك</string>
|
||||
<key>other</key>
|
||||
<string>%2$@ و %1$d أعادوا مشاركة منشورك</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر نشروا منشورك</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>reposted_your_profile_3</key>
|
||||
@@ -338,30 +290,6 @@
|
||||
<string>اعادة نشر</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>quoted_reposts_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@QUOTE_REPOSTS@</string>
|
||||
<key>QUOTE_REPOSTS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>اقتباسات</string>
|
||||
<key>one</key>
|
||||
<string>اقتباس</string>
|
||||
<key>two</key>
|
||||
<string>اقتباسات</string>
|
||||
<key>few</key>
|
||||
<string>اقتباسات</string>
|
||||
<key>many</key>
|
||||
<string>اقتباسات</string>
|
||||
<key>other</key>
|
||||
<string>اقتباسات</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>sats</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
@@ -410,54 +338,6 @@
|
||||
<string>%2$@ ساتوشي</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>users_talking_about_it</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@USERS@</string>
|
||||
<key>USERS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>%d يتكلم عنك</string>
|
||||
<key>one</key>
|
||||
<string>%d يتكلم عنك</string>
|
||||
<key>two</key>
|
||||
<string>%d يتكلمون عنك</string>
|
||||
<key>few</key>
|
||||
<string>%d يتكلمون عنك</string>
|
||||
<key>many</key>
|
||||
<string>%d يتكلمون عنك</string>
|
||||
<key>other</key>
|
||||
<string>%d يتكلمون عنك</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>word_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@WORDS@</string>
|
||||
<key>WORDS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>%d كلمة</string>
|
||||
<key>one</key>
|
||||
<string>%d كلمة</string>
|
||||
<key>two</key>
|
||||
<string>%d كلمتان</string>
|
||||
<key>few</key>
|
||||
<string>%d من الكلمات</string>
|
||||
<key>many</key>
|
||||
<string>%d من الكلمات</string>
|
||||
<key>other</key>
|
||||
<string>%d من الكلمات</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>zap_notification_no_message</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
@@ -493,17 +373,17 @@
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>@</string>
|
||||
<key>zero</key>
|
||||
<string>لقد وصلك %2$@ سات من %3$@: "%4$@"</string>
|
||||
<string>تم استلام %2$@ من %3$@: "%4$@"</string>
|
||||
<key>one</key>
|
||||
<string>لقد وصلك %2$@ سات من %3$@: "%4$@"</string>
|
||||
<string>تم استلام %2$@ من %3$@: "%4$@"</string>
|
||||
<key>two</key>
|
||||
<string>لقد وصلك %2$@ ساتس من %3$@: "%4$@"</string>
|
||||
<string>تم استلام %2$@ من %3$@: "%4$@"</string>
|
||||
<key>few</key>
|
||||
<string>لقد وصلك %2$@ ساتس من %3$@: "%4$@"</string>
|
||||
<string>تم استلام %2$@ من %3$@: "%4$@"</string>
|
||||
<key>many</key>
|
||||
<string>لقد وصلك %2$@ ساتس من %3$@: "%4$@"</string>
|
||||
<string>تم استلام %2$@ من %3$@: "%4$@"</string>
|
||||
<key>other</key>
|
||||
<string>لقد وصلك %2$@ ساتس من %3$@: "%4$@"</string>
|
||||
<string>تم استلام %2$@ من %3$@: "%4$@"</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>zapped_tagged_in_3</key>
|
||||
@@ -517,20 +397,20 @@
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>%2$@ و %1$d غيرهم ومضوا منشور تم ذكر حستبك فيه</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومّض منشورا تمت الإشارة لك فيه</string>
|
||||
<key>one</key>
|
||||
<string>%2$@ و %1$d آخر ومض منشور تم ذكر حستبك فيه</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومّض منشورا تمت الإشارة لك فيه</string>
|
||||
<key>two</key>
|
||||
<string>%2$@ و %1$d آخرين ومضوا منشور تم ذكر حستبك فيه</string>
|
||||
<string>%2$@ و %1$d آخران ومّضوا منشورا تمت الإشارة لك فيه</string>
|
||||
<key>few</key>
|
||||
<string>%2$@ و %1$d آخرين ومضوا منشور تم ذكر حستبك فيه</string>
|
||||
<string>%2$@ و %1$d آخررن ومّضوا منشورا تمت الإشارة لك فيه</string>
|
||||
<key>many</key>
|
||||
<string>%2$@ و %1$d آخرين ومضوا منشور تم ذكر حستبك فيه</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومّضوا منشورا تمت الإشارة لك فيه</string>
|
||||
<key>other</key>
|
||||
<string>%2$@ و %1$d آخرين ومضوا منشور تم ذكر حستبك فيه</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومّضوا منشورا تمت الإشارة لك فيه</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>zapped_your_note_3</key>
|
||||
<key>zapped_your_post_3</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@ZAPPED@</string>
|
||||
@@ -541,13 +421,13 @@
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومّضوا منشورك</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومّض منشورك</string>
|
||||
<key>one</key>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومّضوا منشورك</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومّض منشورك</string>
|
||||
<key>two</key>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومّضوا منشورك</string>
|
||||
<string>%2$@ و %1$d آخران ومّضوا منشورك</string>
|
||||
<key>few</key>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومّضوا منشورك</string>
|
||||
<string>%2$@ و %1$d آخرون ومّضوا منشورك</string>
|
||||
<key>many</key>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومّضوا منشورك</string>
|
||||
<key>other</key>
|
||||
@@ -565,17 +445,17 @@
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>zero</key>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومضوا حسابك</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومّض حسابك</string>
|
||||
<key>one</key>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومضوا حسابك</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومّض حسابك</string>
|
||||
<key>two</key>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومضوا حسابك</string>
|
||||
<string>%2$@ و %1$d آخران ومّضوا حسابك</string>
|
||||
<key>few</key>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومضوا حسابك</string>
|
||||
<string>%2$@ و %1$d آخرون ومّضوا حسابك</string>
|
||||
<key>many</key>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومضوا حسابك</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومّضوا حسابك</string>
|
||||
<key>other</key>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومضوا حسابك</string>
|
||||
<string>%2$@ و %1$d مستخدم آخر ومّضوا حسابك</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>zaps_count</key>
|
||||
|
||||
Binary file not shown.
@@ -44,7 +44,6 @@ struct MainView: View {
|
||||
.onReceive(handle_notify(.logout)) { () in
|
||||
try? clear_keypair()
|
||||
keypair = nil
|
||||
SuggestedHashtagsView.lastRefresh_hashtags.removeAll()
|
||||
// We need to disconnect and reconnect to all relays when the user signs out
|
||||
// This is to conform to NIP-42 and ensure we aren't persisting old connections
|
||||
notify(.disconnect_relays)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user