add trash panel and re-tag stale cached rooms
This commit is contained in:
+57
-10
@@ -397,6 +397,7 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
if !errors.is_empty() {
|
if !errors.is_empty() {
|
||||||
let mut newly_trashed = 0;
|
let mut newly_trashed = 0;
|
||||||
|
|
||||||
trash.update(cx, |trash, cx| {
|
trash.update(cx, |trash, cx| {
|
||||||
newly_trashed = errors
|
newly_trashed = errors
|
||||||
.iter()
|
.iter()
|
||||||
@@ -405,16 +406,9 @@ impl ChatRegistry {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Only newly trashed messages emit an error event so repeated
|
|
||||||
// reprocess attempts of the same message don't re-alert the user
|
|
||||||
if newly_trashed > 0 {
|
if newly_trashed > 0 {
|
||||||
let reason = if newly_trashed == 1 {
|
|
||||||
"A message could not be decrypted".to_string()
|
|
||||||
} else {
|
|
||||||
format!("{newly_trashed} messages could not be decrypted")
|
|
||||||
};
|
|
||||||
this.update(cx, |_, cx| {
|
this.update(cx, |_, cx| {
|
||||||
cx.emit(ChatEvent::Error(reason));
|
cx.emit(ChatEvent::Ping);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -556,7 +550,7 @@ impl ChatRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Retry unwrapping trashed gift wraps with the newly available encryption key.
|
/// Retry unwrapping trashed gift wraps with the newly available encryption key.
|
||||||
fn reprocess_trash(&mut self, cx: &mut Context<Self>) {
|
pub fn reprocess_trash(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
@@ -632,6 +626,32 @@ impl ChatRegistry {
|
|||||||
.count()
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many messages are waiting in the trash.
|
||||||
|
pub fn trashed_count(&self, cx: &App) -> usize {
|
||||||
|
self.trash.read(cx).len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The messages that could not be decrypted, newest first.
|
||||||
|
pub fn trashed(&self, cx: &App) -> Vec<TrashedMessage> {
|
||||||
|
let mut messages: Vec<TrashedMessage> = self
|
||||||
|
.trash
|
||||||
|
.read(cx)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|message| {
|
||||||
|
let event = Event::from_json(message.raw_event.as_str()).ok()?;
|
||||||
|
|
||||||
|
Some(TrashedMessage {
|
||||||
|
id: event.id,
|
||||||
|
created_at: event.created_at,
|
||||||
|
reason: message.reason.clone(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
messages.sort_by_key(|message| std::cmp::Reverse(message.created_at));
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the relays that have seen a given rumor id.
|
/// Get the relays that have seen a given rumor id.
|
||||||
pub fn rumor_seen_on(&self, id: &EventId) -> Option<HashSet<RelayUrl>> {
|
pub fn rumor_seen_on(&self, id: &EventId) -> Option<HashSet<RelayUrl>> {
|
||||||
self.event_map
|
self.event_map
|
||||||
@@ -854,8 +874,9 @@ impl ChatRegistry {
|
|||||||
.custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
|
.custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
|
||||||
|
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
|
||||||
|
|
||||||
|
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||||
|
let mut stale: Vec<(EventId, UnsignedEvent)> = Vec::new();
|
||||||
// Keep the single-threaded web event loop responsive during large rescans
|
// Keep the single-threaded web event loop responsive during large rescans
|
||||||
let mut last_yield = Instant::now();
|
let mut last_yield = Instant::now();
|
||||||
|
|
||||||
@@ -869,15 +890,41 @@ impl ChatRegistry {
|
|||||||
&& rumor.tags.public_keys().next().is_some()
|
&& rumor.tags.public_keys().next().is_some()
|
||||||
{
|
{
|
||||||
search.write().unwrap().insert(&rumor);
|
search.write().unwrap().insert(&rumor);
|
||||||
|
|
||||||
if rumor.pubkey != public_key
|
if rumor.pubkey != public_key
|
||||||
&& !rumor.tags.public_keys().any(|k| k == public_key)
|
&& !rumor.tags.public_keys().any(|k| k == public_key)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let room_id = rumor.uniq_id().to_string();
|
||||||
|
let stored_room = raw
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find(|tag| tag.kind() == "r")
|
||||||
|
.and_then(|tag| tag.content());
|
||||||
|
|
||||||
|
if stored_room.is_none_or(|stored| stored != room_id)
|
||||||
|
&& let Some(gift_wrap) = raw
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find(|tag| tag.kind() == "d")
|
||||||
|
.and_then(|tag| tag.content())
|
||||||
|
.and_then(|value| EventId::from_hex(value).ok())
|
||||||
|
{
|
||||||
|
stale.push((gift_wrap, rumor.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
|
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (gift_wrap, rumor) in stale {
|
||||||
|
if let Err(error) = set_rumor(&client, gift_wrap, &rumor).await {
|
||||||
|
log::warn!("Failed to re-tag a cached message: {error:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut rooms = HashSet::with_capacity(grouped.len());
|
let mut rooms = HashSet::with_capacity(grouped.len());
|
||||||
|
|
||||||
for (_id, messages) in grouped.into_iter() {
|
for (_id, messages) in grouped.into_iter() {
|
||||||
|
|||||||
@@ -219,6 +219,17 @@ impl FailedMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A message that could not be decrypted, as surfaced to the UI.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TrashedMessage {
|
||||||
|
/// The undecryptable gift wrap's id, stable across renders
|
||||||
|
pub id: EventId,
|
||||||
|
/// When the gift wrap was received
|
||||||
|
pub created_at: Timestamp,
|
||||||
|
/// Why it could not be decrypted
|
||||||
|
pub reason: SharedString,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Mention {
|
pub struct Mention {
|
||||||
pub public_key: PublicKey,
|
pub public_key: PublicKey,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ static CUSTOM_EMOJI_RE: LazyLock<Regex> =
|
|||||||
|
|
||||||
mod actions;
|
mod actions;
|
||||||
mod file;
|
mod file;
|
||||||
|
pub mod trash;
|
||||||
|
|
||||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||||
cx.new(|cx| ChatPanel::new(room, window, cx))
|
cx.new(|cx| ChatPanel::new(room, window, cx))
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use chat::{ChatRegistry, TrashedMessage};
|
||||||
|
use common::TimestampExt;
|
||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, AppContext, Context, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||||
|
ParentElement, Render, Styled, Window, div, uniform_list,
|
||||||
|
};
|
||||||
|
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
||||||
|
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||||
|
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, h_flex, v_flex};
|
||||||
|
use ui::IconName;
|
||||||
|
|
||||||
|
/// Initialize the trash panel.
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) -> gpui::Entity<TrashPanel> {
|
||||||
|
cx.new(|cx| TrashPanel::new(window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The messages that could not be decrypted, shown like a chat room.
|
||||||
|
pub struct TrashPanel {
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrashPanel {
|
||||||
|
fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
Self {
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_entry(&self, message: &TrashedMessage, cx: &Context<Self>) -> AnyElement {
|
||||||
|
h_flex()
|
||||||
|
.w_full()
|
||||||
|
.gap_2()
|
||||||
|
.items_center()
|
||||||
|
.px_2()
|
||||||
|
.py_1p5()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().secondary.opacity(0.5))
|
||||||
|
.child(
|
||||||
|
Icon::new(IconName::Warning)
|
||||||
|
.small()
|
||||||
|
.text_color(cx.theme().warning),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.child(div().text_sm().child("Could not be decrypted"))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.truncate()
|
||||||
|
.child(message.reason.clone()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(message.created_at.to_ago()),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_empty(&self, cx: &Context<Self>) -> AnyElement {
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child("Trash is empty")
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl gpui_kit::component::dock::BasePanel for TrashPanel {
|
||||||
|
fn panel_name(&self) -> &'static str {
|
||||||
|
"TrashPanel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for TrashPanel {
|
||||||
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
h_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.child(
|
||||||
|
Icon::new(IconName::Warning)
|
||||||
|
.small()
|
||||||
|
.text_color(cx.theme().muted_foreground),
|
||||||
|
)
|
||||||
|
.child("Trash")
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<PanelEvent> for TrashPanel {}
|
||||||
|
|
||||||
|
impl Focusable for TrashPanel {
|
||||||
|
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for TrashPanel {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let messages = Rc::new(ChatRegistry::global(cx).read(cx).trashed(cx));
|
||||||
|
let count = messages.len();
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.p_3()
|
||||||
|
.gap_3()
|
||||||
|
.text_xs()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.w_full()
|
||||||
|
.gap_2()
|
||||||
|
.items_center()
|
||||||
|
.justify_between()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child("Messages that could not be decrypted. New encryption keys may make them readable."),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("trash-retry")
|
||||||
|
.label("Try again")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.disabled(count == 0)
|
||||||
|
.on_click(|_, _window, cx| {
|
||||||
|
ChatRegistry::global(cx).update(cx, |registry, cx| {
|
||||||
|
registry.reprocess_trash(cx);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.when(count == 0, |this| this.child(self.render_empty(cx)))
|
||||||
|
.when(count > 0, |this| {
|
||||||
|
this.child(
|
||||||
|
uniform_list(
|
||||||
|
"trash-list",
|
||||||
|
count,
|
||||||
|
cx.processor(move |this, range, _window, cx| {
|
||||||
|
this.render_entries(range, messages.as_slice(), cx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min_h_0()
|
||||||
|
.flex_1(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrashPanel {
|
||||||
|
fn render_entries(
|
||||||
|
&self,
|
||||||
|
range: std::ops::Range<usize>,
|
||||||
|
messages: &[TrashedMessage],
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Vec<AnyElement> {
|
||||||
|
messages
|
||||||
|
.get(range)
|
||||||
|
.map(|slice| {
|
||||||
|
slice
|
||||||
|
.iter()
|
||||||
|
.map(|message| {
|
||||||
|
// Keep a small gap between entries so they read as separate rows
|
||||||
|
div()
|
||||||
|
.pb_1p5()
|
||||||
|
.child(self.render_entry(message, cx))
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,14 +173,20 @@ impl Sidebar {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The chat requests awaiting a response.
|
/// The chat requests awaiting a response, followed by the trash entry when
|
||||||
|
/// it holds messages that could not be decrypted.
|
||||||
pub(super) fn requests_rows(&self, cx: &App) -> Vec<SidebarRow> {
|
pub(super) fn requests_rows(&self, cx: &App) -> Vec<SidebarRow> {
|
||||||
ChatRegistry::global(cx)
|
let chat = ChatRegistry::global(cx).read(cx);
|
||||||
.read(cx)
|
let mut rows: Vec<SidebarRow> = chat
|
||||||
.rooms(&RoomKind::Request, cx)
|
.rooms(&RoomKind::Request, cx)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|room| SidebarRow::Room { room })
|
.map(|room| SidebarRow::Room { room })
|
||||||
.collect()
|
.collect();
|
||||||
|
|
||||||
|
if chat.trashed_count(cx) > 0 {
|
||||||
|
rows.push(SidebarRow::Trash);
|
||||||
|
}
|
||||||
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The overlay with the create button and selection count.
|
/// The overlay with the create button and selection count.
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ pub(super) struct RowsSignature {
|
|||||||
/// Kinds of the rooms held by the cache; a request can turn ongoing with
|
/// Kinds of the rooms held by the cache; a request can turn ongoing with
|
||||||
/// no event or registry notify the sidebar can hear
|
/// no event or registry notify the sidebar can hear
|
||||||
room_kinds: Vec<(u64, RoomKind)>,
|
room_kinds: Vec<(u64, RoomKind)>,
|
||||||
|
/// Size of the undecryptable-messages trash, which no notify reaches
|
||||||
|
trashed: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the id and kind of every room row held by the given cache.
|
/// Read the id and kind of every room row held by the given cache.
|
||||||
@@ -81,9 +83,10 @@ impl Sidebar {
|
|||||||
("sidebar-list", None)
|
("sidebar-list", None)
|
||||||
};
|
};
|
||||||
|
|
||||||
let request_count = ChatRegistry::global(cx)
|
let request_count = {
|
||||||
.read(cx)
|
let chat = ChatRegistry::global(cx).read(cx);
|
||||||
.count(&RoomKind::Request, cx);
|
chat.count(&RoomKind::Request, cx) + chat.trashed_count(cx)
|
||||||
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -218,6 +221,7 @@ impl Sidebar {
|
|||||||
pinned_rooms: AppSettings::get_pinned_rooms(cx),
|
pinned_rooms: AppSettings::get_pinned_rooms(cx),
|
||||||
pinned_groups: AppSettings::get_pinned_groups(cx),
|
pinned_groups: AppSettings::get_pinned_groups(cx),
|
||||||
room_kinds: room_signature(&self.rows, cx),
|
room_kinds: room_signature(&self.rows, cx),
|
||||||
|
trashed: ChatRegistry::global(cx).read(cx).trashed_count(cx),
|
||||||
};
|
};
|
||||||
|
|
||||||
if self.rows_dirty || self.rows_signature != signature {
|
if self.rows_dirty || self.rows_signature != signature {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
use chat::{Room, RoomKind};
|
use chat::{ChatRegistry, Room, RoomKind};
|
||||||
use common::TimestampExt;
|
use common::TimestampExt;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -88,6 +88,8 @@ pub(super) enum SidebarRow {
|
|||||||
Message {
|
Message {
|
||||||
hit: MessageHit,
|
hit: MessageHit,
|
||||||
},
|
},
|
||||||
|
/// Entry point to the trash panel, shown under requests
|
||||||
|
Trash,
|
||||||
/// A non-interactive label, such as a search results section.
|
/// A non-interactive label, such as a search results section.
|
||||||
Heading(&'static str),
|
Heading(&'static str),
|
||||||
Empty(&'static str),
|
Empty(&'static str),
|
||||||
@@ -411,6 +413,34 @@ impl Sidebar {
|
|||||||
})
|
})
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
SidebarRow::Trash => {
|
||||||
|
let count = ChatRegistry::global(cx).read(cx).trashed_count(cx);
|
||||||
|
let dock = self.dock.clone();
|
||||||
|
|
||||||
|
Nav::new("sidebar-trash")
|
||||||
|
.label("Trash")
|
||||||
|
.text_sm()
|
||||||
|
.when(!hide_avatar, |this| {
|
||||||
|
this.prefix(Avatar::new(None).seed("trash").small())
|
||||||
|
})
|
||||||
|
.suffix(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(SharedString::from(count.to_string())),
|
||||||
|
)
|
||||||
|
.on_click(move |_event, window, cx| {
|
||||||
|
let panel = chat_ui::trash::init(window, cx);
|
||||||
|
crate::dock::add_panel_to(
|
||||||
|
&dock,
|
||||||
|
PanelHandle::new(panel),
|
||||||
|
DockPlacement::Center,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// Keep a small gap between rows so selected rows read as pills.
|
// Keep a small gap between rows so selected rows read as pills.
|
||||||
div().pb_0p5().child(element).into_any_element()
|
div().pb_0p5().child(element).into_any_element()
|
||||||
|
|||||||
@@ -966,9 +966,10 @@ clean, `cargo test --workspace` 38 passed / 0 failed. Diff: 25 files, +1072/−2
|
|||||||
(`common::stable_id` 128-bit hex, `stable_id_u64` for u64-keyed consumers); `Event` and
|
(`common::stable_id` 128-bit hex, `stable_id_u64` for u64-keyed consumers); `Event` and
|
||||||
`UnsignedEvent` `uniq_id` impls factored into one `pubkey_set_uniq_id` helper; nip29's
|
`UnsignedEvent` `uniq_id` impls factored into one `pubkey_set_uniq_id` helper; nip29's
|
||||||
`GroupKey::uniq_id` and `browse_subscription` unified (verified session-local). **Room id
|
`GroupKey::uniq_id` and `browse_subscription` unified (verified session-local). **Room id
|
||||||
values change** (new hash domain) — existing cached groupings regroup per session as rumors
|
values change** (new hash domain) — follow-up fix after first run: `Room::get_messages`
|
||||||
re-arrive; release notes should mention it. Follow-up if 128-bit ids are wanted: chat's id
|
filters cached history by the persisted `r` tag, so pre-change rows were unreachable
|
||||||
fields are u64-typed; migrating to `String` ids is mechanical.
|
(empty message lists); `query_chat_rooms` now compares each row's `r` tag against the
|
||||||
|
freshly computed id during its startup scan and re-tags stale rows, restoring history.
|
||||||
11. **nip4e lifecycle** (`nip4e/src/lib.rs`): approval has an `approving` re-entry guard (the
|
11. **nip4e lifecycle** (`nip4e/src/lib.rs`): approval has an `approving` re-entry guard (the
|
||||||
device secret can no longer be double-sent), toast lifecycle is correct on both outcomes
|
device secret can no longer be double-sent), toast lifecycle is correct on both outcomes
|
||||||
(success clears only the matching request; failure rebuilds the toast with a fresh enabled
|
(success clears only the matching request; failure rebuilds the toast with a fresh enabled
|
||||||
|
|||||||
Reference in New Issue
Block a user