Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^5.0.0",
"react-helmet-async": "^3.0.0",
"react-instantsearch-dom": "^6.40.4",
"react-intersection-observer": "^9.16.0",
"react-lazy-load-image-component": "^1.6.3",
Expand Down
26 changes: 0 additions & 26 deletions plan/achievement-sbts.md

This file was deleted.

27 changes: 0 additions & 27 deletions plan/decentralized-book-clubs.md

This file was deleted.

34 changes: 0 additions & 34 deletions plan/librarian-staking.md

This file was deleted.

27 changes: 0 additions & 27 deletions plan/proof-of-engagement.md

This file was deleted.

44 changes: 41 additions & 3 deletions src/alex_backend/alex_backend.did
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type CommentInfo = record {
created_at : nat64;
comment : text;
};
type EmbeddingEntry = record { embedding : vec float32; arweave_id : text };
type HttpRequest = record {
url : text;
method : text;
Expand All @@ -46,7 +47,10 @@ type Result_3 = variant { Ok : nat64; Err : ActivityError };
type Result_4 = variant { Ok : ReactionCounts; Err : ActivityError };
type Result_5 = variant { Ok : opt ReactionType; Err : ActivityError };
type Result_6 = variant { Ok; Err : ActivityError };
type Result_7 = variant { Ok : text; Err : text };
type Result_7 = variant { Ok : vec SimilarityResult; Err : text };
type Result_8 = variant { Ok : text; Err : text };
type SimilarityResult = record { score : float32; arweave_id : text };
type StoreResult = record { stored : nat64; errors : vec text };
type UserNFTInfo = record {
"principal" : principal;
username : text;
Expand All @@ -59,17 +63,31 @@ service : () -> {
add_comment : (text, text) -> (Result);
// Add or update a reaction to an NFT
add_reaction : (text, ReactionType) -> (Result);
// Clear all embeddings. Controller only.
clear_embeddings : () -> (nat64);
// Get all activities for a specific NFT
get_activities : (text) -> (Result_1) query;
// Get a specific activity by ID
get_activity : (nat64) -> (Result) query;
// Get all comments for a specific NFT
get_comments : (text) -> (Result_2) query;
// Get total number of indexed embeddings
get_embedding_count : () -> (nat64) query;
// Get the impression count for an article
get_impressions : (text) -> (Result_3) query;
// Get all indexed arweave IDs
get_indexed_ids : () -> (vec text) query;
// Get aggregated reaction counts for a specific NFT
get_reaction_counts : (text) -> (Result_4) query;
get_stored_nft_users : () -> (vec UserNFTInfo) query;
// Get trending content ranked by authenticated-viewer count (descending).
// Returns a list of (arweave_id, count) tuples, limited to `limit` results.
//
// Ranking uses `Some(principal)` entries only — anonymous views (`None`) are
// intentionally excluded so trending cannot be gamed by one anon user
// refreshing in a loop. `get_view_count` still reflects the full total for
// display purposes.
get_trending : (nat64) -> (vec record { text; nat64 }) query;
// Get all activities by a specific user
get_user_activities : (principal) -> (Result_1) query;
// Get the current user's reaction for a specific NFT
Expand All @@ -79,6 +97,16 @@ service : () -> {
// Get the view count for an article
get_view_count : (text) -> (Result_3) query;
http_request : (HttpRequest) -> (HttpResponse) query;
// Check if an arweave_id has been indexed
is_indexed : (text) -> (bool) query;
// Record impressions and views in a single batched call.
// Impressions: counted unconditionally — no per-caller dedup. Every entry in the
// `impressions` vec increments the counter, so repeated calls
// (refresh, multiple tabs) inflate the total. Treat the number as
// a raw signal, not a unique-user metric.
// Views: deduped per authenticated caller (same as `record_view`). Anonymous
// views are counted without dedup.
record_engagement_batch : (vec text, vec text) -> (Result_6);
// Record an impression for an article (article appeared in feed)
// Anyone can call, always increments counter
record_impression : (text) -> (Result_3);
Expand All @@ -89,10 +117,20 @@ service : () -> {
record_view : (text) -> (Result_3);
// Remove a comment (only by the comment author)
remove_comment : (nat64) -> (Result_6);
// Remove embeddings. Controller only.
remove_embeddings : (vec text) -> (nat64);
// Remove a user's reaction from an NFT
remove_reaction : (text) -> (Result_6);
start_alex_supply_timer : () -> (Result_7);
update_alex_supply : () -> (Result_7);
// Reset embeddings by reinitializing the BTreeMap. Use if data is corrupted. Controller only.
reset_embeddings : () -> (text);
// Search by a pre-computed embedding vector (for text-to-image search)
search_by_vector : (vec float32, nat32) -> (Result_7) query;
// Search for NFTs with similar images to the given arweave_id
search_similar : (text, nat32) -> (Result_7) query;
start_alex_supply_timer : () -> (Result_8);
// Store embeddings for NFT images. Controller only.
store_embeddings : (vec EmbeddingEntry) -> (StoreResult);
update_alex_supply : () -> (Result_8);
// Update a comment (only by the comment author)
update_comment : (nat64, text) -> (Result);
// Get the caller's principal
Expand Down
27 changes: 27 additions & 0 deletions src/alex_backend/src/dialectica/api/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,31 @@ pub fn get_view_count(arweave_id: String) -> ActivityResult<u64> {
None => Ok(0),
}
})
}

/// Get trending content ranked by authenticated-viewer count (descending).
/// Returns a list of (arweave_id, count) tuples, limited to `limit` results.
///
/// Ranking uses `Some(principal)` entries only — anonymous views (`None`) are
/// intentionally excluded so trending cannot be gamed by one anon user
/// refreshing in a loop. `get_view_count` still reflects the full total for
/// display purposes.
#[query]
pub fn get_trending(limit: u64) -> Vec<(String, u64)> {
let max_limit = limit.min(100) as usize;

VIEWS.with(|views| {
let views = views.borrow();
let mut entries: Vec<(String, u64)> = views
.iter()
.map(|(key, viewers)| {
let authed = viewers.0.0.iter().flatten().count() as u64;
(key.0.clone(), authed)
})
.collect();

entries.sort_by(|a, b| b.1.cmp(&a.1));
entries.truncate(max_limit);
entries
})
}
55 changes: 55 additions & 0 deletions src/alex_backend/src/dialectica/api/updates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,61 @@ pub fn update_comment(activity_id: u64, new_comment: String) -> ActivityResult<A
})
}

/// Record impressions and views in a single batched call.
/// Impressions: counted unconditionally — no per-caller dedup. Every entry in the
/// `impressions` vec increments the counter, so repeated calls
/// (refresh, multiple tabs) inflate the total. Treat the number as
/// a raw signal, not a unique-user metric.
/// Views: deduped per authenticated caller (same as `record_view`). Anonymous
/// views are counted without dedup.
#[update]
pub fn record_engagement_batch(impressions: Vec<String>, views: Vec<String>) -> ActivityResult<()> {
let caller = caller();
let is_anonymous = caller == Principal::anonymous();

// Process impressions — counter-only, no dedup (see fn-level docs)
IMPRESSIONS.with(|imp_store| {
let mut imp_store = imp_store.borrow_mut();
for arweave_id in &impressions {
if arweave_id.trim().is_empty() || arweave_id.len() != 43 {
continue;
}
let current = imp_store.get(&StorableString(arweave_id.clone())).unwrap_or(0);
imp_store.insert(StorableString(arweave_id.clone()), current + 1);
}
});

// Process views — dedup per authenticated user
VIEWS.with(|view_store| {
let mut view_store = view_store.borrow_mut();
for arweave_id in &views {
if arweave_id.trim().is_empty() || arweave_id.len() != 43 {
continue;
}
let mut viewers = match view_store.get(&StorableString(arweave_id.clone())) {
Some(list) => list.0.0,
None => Vec::new(),
};

if is_anonymous {
viewers.push(None);
} else {
let already_viewed = viewers.iter().any(|v| matches!(v, Some(p) if *p == caller));
if !already_viewed {
viewers.push(Some(caller));
}
}

view_store.insert(
StorableString(arweave_id.clone()),
StorableViewersList(ViewersList(viewers)),
);
}
});

Ok(())
}

/// Record an impression for an article (article appeared in feed)
/// Anyone can call, always increments counter
#[update]
Expand Down
7 changes: 2 additions & 5 deletions src/alex_backend/src/dialectica/store.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
use candid::{CandidType, Decode, Encode, Principal};
use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory};
use ic_stable_structures::memory_manager::{MemoryId, VirtualMemory};
use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap, Storable};
use std::borrow::Cow;
use std::cell::RefCell;
use serde::{Serialize, Deserialize};

use super::models::activity::Activity;
use crate::MEMORY_MANAGER;

type Memory = VirtualMemory<DefaultMemoryImpl>;

Expand Down Expand Up @@ -144,10 +145,6 @@ impl Storable for StorableUserReactionKey {
}

thread_local! {
static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> = RefCell::new(
MemoryManager::init(DefaultMemoryImpl::default())
);

// Main activities storage: activity_id -> Activity
pub static ACTIVITIES: RefCell<StableBTreeMap<u64, StorableActivity, Memory>> = RefCell::new(
StableBTreeMap::init(
Expand Down
9 changes: 8 additions & 1 deletion src/alex_backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,20 @@ pub use nft_users::{UserNFTInfo, get_stored_nft_users};
// Dialectica module - social features (reactions, comments, views, impressions)
pub mod dialectica;

// Similarity module - NFT image similarity search using CLIP embeddings
pub mod similarity;

// Re-export dialectica types for candid export
pub use dialectica::{
Activity, ActivityType, ActivityError, ActivityResult,
ReactionType, ReactionCounts, CommentInfo,
AddCommentRequest, AddReactionRequest, ActivityResponse, UpdateCommentRequest,
};

pub use similarity::{
SimilarityResult, EmbeddingEntry, StoreResult,
};

pub const ICRC7_CANISTER_ID: &str = "53ewn-qqaaa-aaaap-qkmqq-cai";
pub const ICRC7_SCION_CANISTER_ID: &str = "uxyan-oyaaa-aaaap-qhezq-cai";
pub const USER_CANISTER_ID: &str = "yo4hu-nqaaa-aaaap-qkmoq-cai";
Expand All @@ -27,7 +34,7 @@ pub const ALEX_TOKEN_CANISTER_ID: &str = "ysy5f-2qaaa-aaaap-qkmmq-cai";
type Memory = ic_stable_structures::memory_manager::VirtualMemory<DefaultMemoryImpl>;

thread_local! {
static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> = RefCell::new(
pub static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> = RefCell::new(
MemoryManager::init(DefaultMemoryImpl::default())
);

Expand Down
11 changes: 5 additions & 6 deletions src/alex_backend/src/nft_users.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
use candid::{CandidType, Deserialize, Principal};
use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory};
use ic_stable_structures::memory_manager::{MemoryId, VirtualMemory};
use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap, Storable};
use ic_stable_structures::storable::Bound;
use std::cell::RefCell;
use ic_cdk::api::call::CallResult;

use crate::MEMORY_MANAGER;

const MAX_VALUE_SIZE: u32 = 256;

thread_local! {
static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> = RefCell::new(
MemoryManager::init(DefaultMemoryImpl::default())
);

static NFT_USERS: RefCell<StableBTreeMap<Principal, UserNFTInfo, VirtualMemory<DefaultMemoryImpl>>> =
RefCell::new(StableBTreeMap::init(
MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0))),
Expand Down Expand Up @@ -134,4 +132,5 @@ pub fn get_stored_nft_users() -> Vec<UserNFTInfo> {
.map(|(_, value)| value.clone())
.collect()
})
}
}

Loading
Loading