Просмотр исходного кода

app/chatview: URLs are clickable and call xdg-open (or platform equivalent)

darkfi 1 неделя назад
Родитель
Сommit
346d2b818e
5 измененных файлов с 155 добавлено и 2 удалено
  1. 31 1
      bin/app/Cargo.lock
  2. 2 0
      bin/app/Cargo.toml
  3. 11 0
      bin/app/java/MainActivity.java
  4. 21 0
      bin/app/src/android/mod.rs
  5. 90 1
      bin/app/src/ui/chatview/page.rs

+ 31 - 1
bin/app/Cargo.lock

@@ -1878,6 +1878,7 @@ dependencies = [
  "image",
  "image",
  "indoc",
  "indoc",
  "miniquad",
  "miniquad",
+ "open",
  "parking_lot 0.12.5",
  "parking_lot 0.12.5",
  "parley",
  "parley",
  "peniko",
  "peniko",
@@ -4141,6 +4142,15 @@ dependencies = [
  "libc",
  "libc",
 ]
 ]
 
 
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
 [[package]]
 [[package]]
 name = "is-terminal"
 name = "is-terminal"
 version = "0.4.17"
 version = "0.4.17"
@@ -4152,6 +4162,16 @@ dependencies = [
  "windows-sys 0.61.2",
  "windows-sys 0.61.2",
 ]
 ]
 
 
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
 [[package]]
 [[package]]
 name = "is_terminal_polyfill"
 name = "is_terminal_polyfill"
 version = "1.70.2"
 version = "1.70.2"
@@ -5156,6 +5176,16 @@ version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
 checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
 
 
+[[package]]
+name = "open"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408"
+dependencies = [
+ "is-wsl",
+ "libc",
+]
+
 [[package]]
 [[package]]
 name = "option-ext"
 name = "option-ext"
 version = "0.2.0"
 version = "0.2.0"
@@ -6480,7 +6510,7 @@ dependencies = [
  "errno",
  "errno",
  "libc",
  "libc",
  "linux-raw-sys 0.4.15",
  "linux-raw-sys 0.4.15",
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
 ]
 ]
 
 
 [[package]]
 [[package]]

+ 2 - 0
bin/app/Cargo.toml

@@ -95,6 +95,8 @@ halo2_gadgets = { git="https://github.com/parazyd/halo2", branch="v050" }
 
 
 [target.'cfg(not(target_os = "android"))'.dependencies]
 [target.'cfg(not(target_os = "android"))'.dependencies]
 dirs = "5.0.1"
 dirs = "5.0.1"
+# Open URLs in the system's default handler on click (Android uses a JNI intent instead).
+open = "5"
 # AV1 video decoding - rav1d with Rust API from leo030303 fork
 # AV1 video decoding - rav1d with Rust API from leo030303 fork
 # Enable ASM routines for non-Android platforms.
 # Enable ASM routines for non-Android platforms.
 rav1d = { git = "https://github.com/leo030303/rav1d", branch = "add-rust-api", features = ["bitdepth_8"] }
 rav1d = { git = "https://github.com/leo030303/rav1d", branch = "add-rust-api", features = ["bitdepth_8"] }

+ 11 - 0
bin/app/java/MainActivity.java

@@ -4,6 +4,8 @@ import android.view.ViewGroup;
 import android.view.WindowInsets.Type;
 import android.view.WindowInsets.Type;
 import android.view.inputmethod.EditorInfo;
 import android.view.inputmethod.EditorInfo;
 import android.text.InputType;
 import android.text.InputType;
+import android.content.Intent;
+import android.net.Uri;
 import android.util.Log;
 import android.util.Log;
 import java.util.HashMap;
 import java.util.HashMap;
 
 
@@ -153,6 +155,15 @@ public VideoDecoder createVideoDecoder() {
     return decoder;
     return decoder;
 }
 }
 
 
+public void openUrl(String url) {
+    try {
+        Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
+        startActivity(intent);
+    } catch (Exception e) {
+        Log.e("darkfi", "Failed to open URL " + url + ": " + e.getMessage());
+    }
+}
+
 //% END
 //% END
 
 
 //% MAIN_ACTIVITY_ON_CREATE
 //% MAIN_ACTIVITY_ON_CREATE

+ 21 - 0
bin/app/src/android/mod.rs

@@ -84,3 +84,24 @@ pub fn get_screen_density() -> f32 {
 pub fn is_ime_visible() -> bool {
 pub fn is_ime_visible() -> bool {
     call_mainactivity_bool_method!("isImeVisible")
     call_mainactivity_bool_method!("isImeVisible")
 }
 }
+
+/// Open `url` in the platform's default handler (e.g. the browser) by calling the
+/// `openUrl` method on MainActivity, which fires an `ACTION_VIEW` intent. Android
+/// only. The URL string is converted to a Java `String` via `NewStringUTF` and the
+/// local reference is released after the call.
+pub fn open_url(url: &str) {
+    unsafe {
+        let env = get_jni_env();
+        let curl = std::ffi::CString::new(url).unwrap();
+        let jurl = (**env).NewStringUTF.unwrap()(env, curl.as_ptr());
+        ndk_utils::call_void_method!(
+            env,
+            android::ACTIVITY,
+            "openUrl",
+            "(Ljava/lang/String;)V",
+            jurl
+        );
+        let delete_local_ref = (**env).DeleteLocalRef.unwrap();
+        delete_local_ref(env, jurl);
+    }
+}

+ 90 - 1
bin/app/src/ui/chatview/page.rs

@@ -79,6 +79,11 @@ pub struct PrivMessage {
 
 
     mesh_cache: Option<Vec<DrawInstruction>>,
     mesh_cache: Option<Vec<DrawInstruction>>,
     txt_layout: Option<parley::Layout<Color>>,
     txt_layout: Option<parley::Layout<Color>>,
+
+    /// Bounding rects of this message's URL runs in message-local coordinates,
+    /// each tagged with its URL string. Populated in `gen_mesh`, used by
+    /// `handle_mouse_btn_up` for click hit-testing. Cleared in `clear_mesh`.
+    url_click_rects: Vec<(String, Rectangle)>,
 }
 }
 
 
 impl PrivMessage {
 impl PrivMessage {
@@ -108,6 +113,7 @@ impl PrivMessage {
             is_selected: false,
             is_selected: false,
             mesh_cache: None,
             mesh_cache: None,
             txt_layout: None,
             txt_layout: None,
+            url_click_rects: vec![],
         })
         })
     }
     }
 
 
@@ -263,6 +269,9 @@ impl PrivMessage {
             all_instrs.extend(bg_instrs);
             all_instrs.extend(bg_instrs);
         }
         }
 
 
+        // Record this message's URL hit-rectangles for click detection.
+        self.url_click_rects = self.compute_url_click_rects(timestamp_width, url_text_color);
+
         let text_instrs = text::render_layout(
         let text_instrs = text::render_layout(
             self.txt_layout.as_ref().unwrap(),
             self.txt_layout.as_ref().unwrap(),
             renderer,
             renderer,
@@ -285,11 +294,91 @@ impl PrivMessage {
         // Auto-deletes when refs are dropped
         // Auto-deletes when refs are dropped
         self.mesh_cache = None;
         self.mesh_cache = None;
         self.txt_layout = None;
         self.txt_layout = None;
+        self.url_click_rects.clear();
     }
     }
 
 
     fn select(&mut self) {
     fn select(&mut self) {
         self.is_selected = true;
         self.is_selected = true;
     }
     }
+
+    /// Build the URL hit-rectangles for this message, in message-local coordinates.
+    /// Each URL-colored glyph run (`style().brush == url_text_color`) becomes a rect
+    /// `(timestamp_width + run.offset, run.baseline - ascent, run.advance,
+    /// ascent + descent)`. The run is tagged with its URL string by intersecting its
+    /// (coarse) font-run `text_range()` with the message's URL byte ranges in
+    /// `linetext`, so wrapped URLs and multiple URLs are handled correctly.
+    fn compute_url_click_rects(
+        &self,
+        timestamp_width: f32,
+        url_text_color: Color,
+    ) -> Vec<(String, Rectangle)> {
+        let mut rects = vec![];
+        let Some(layout) = self.txt_layout.as_ref() else { return rects };
+
+        let is_notice = self.nick == "NOTICE";
+        let linetext =
+            if is_notice { self.text.clone() } else { format!("{} {}", self.nick, self.text) };
+        let body_offset = if is_notice { 0 } else { self.nick.len() + 1 };
+
+        // URL byte ranges within linetext (the color value is unused here).
+        let url_ranges: Vec<Range<usize>> =
+            url_color_ranges(&self.text, body_offset, url_text_color)
+                .into_iter()
+                .map(|(r, _)| r)
+                .collect();
+        if url_ranges.is_empty() {
+            return rects
+        }
+
+        for line in layout.lines() {
+            for item in line.items() {
+                let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue };
+                if glyph_run.style().brush != url_text_color {
+                    continue
+                }
+
+                // Map this run to its URL via the (coarse) font-run range intersected
+                // with the URL byte ranges.
+                let font_range = glyph_run.run().text_range();
+                let Some(url_range) = url_ranges
+                    .iter()
+                    .find(|r| r.start < font_range.end && r.end > font_range.start)
+                else {
+                    continue
+                };
+                let url_str = linetext[url_range.clone()].to_string();
+
+                let metrics = glyph_run.run().metrics();
+                let x = timestamp_width + glyph_run.offset();
+                let y = glyph_run.baseline() - metrics.ascent;
+                let w = glyph_run.advance();
+                let h = metrics.ascent + metrics.descent;
+                rects.push((url_str, Rectangle::new(x, y, w, h)));
+            }
+        }
+
+        rects
+    }
+
+    async fn handle_mouse_btn_up(&self, btn: MouseButton, mouse_pos: Point) -> bool {
+        if btn != MouseButton::Left {
+            return false
+        }
+        for (url, rect) in &self.url_click_rects {
+            if rect.contains(mouse_pos) {
+                info!(target: "ui::chatview", "URL clicked: {url}");
+
+                #[cfg(target_os = "android")]
+                crate::android::open_url(url);
+
+                #[cfg(not(target_os = "android"))]
+                let _ = open::that(url);
+
+                return true
+            }
+        }
+        false
+    }
 }
 }
 
 
 impl std::fmt::Debug for PrivMessage {
 impl std::fmt::Debug for PrivMessage {
@@ -897,7 +986,7 @@ impl UIObject for Message {
     }
     }
     async fn handle_mouse_btn_up(&self, btn: MouseButton, mouse_pos: Point) -> bool {
     async fn handle_mouse_btn_up(&self, btn: MouseButton, mouse_pos: Point) -> bool {
         match self {
         match self {
-            Self::Priv(_) => false,
+            Self::Priv(m) => m.handle_mouse_btn_up(btn, mouse_pos).await,
             Self::Date(_) => false,
             Self::Date(_) => false,
             Self::File(m) => m.handle_mouse_btn_up(btn, mouse_pos).await,
             Self::File(m) => m.handle_mouse_btn_up(btn, mouse_pos).await,
         }
         }