Enhance content management and user interaction features

- Introduced a new content source toggle in the profile and app header to switch between IndeeHub and TopDoc films.
- Updated the content fetching logic to dynamically load content based on the selected source.
- Enhanced the seeding process to include a combined catalog of IndeeHub and TopDoc films, ensuring diverse content availability.
- Improved user interaction by preventing duplicate reactions and ensuring a smoother voting experience across comments and content.
- Added support for Amber login (NIP-55) for Android users, integrating it into the existing authentication flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dorian
2026-02-12 14:24:52 +00:00
parent ab0560de00
commit 35bc78b890
38 changed files with 1107 additions and 185 deletions

View File

@@ -1,6 +1,6 @@
import { ref, computed, onUnmounted } from 'vue'
import { Accounts } from 'applesauce-accounts'
import { accountManager } from '../lib/accounts'
import { accountManager, AmberClipboardSigner, AmberClipboardAccount } from '../lib/accounts'
import { TEST_PERSONAS, TASTEMAKER_PERSONAS } from '../data/testPersonas'
import type { Subscription } from 'rxjs'
@@ -101,6 +101,35 @@ export function useAccounts() {
}
}
/**
* Check if Amber signer is supported on this platform (Android + clipboard)
*/
const isAmberSupported = computed(() => !!AmberClipboardSigner.SUPPORTED)
/**
* Login with Amber (NIP-55 Android Signer).
* Uses the AmberClipboardSigner to request the pubkey via Android intents.
* The signer is retained for future event signing (comments, reactions, etc.)
*/
async function loginWithAmber() {
isLoggingIn.value = true
loginError.value = null
try {
const signer = new AmberClipboardSigner()
const pubkey = await signer.getPublicKey()
const account = new AmberClipboardAccount(pubkey, signer)
accountManager.addAccount(account)
accountManager.setActive(account)
return pubkey
} catch (err: any) {
loginError.value = err.message || 'Amber login failed'
console.error('Amber login error:', err)
throw err
} finally {
isLoggingIn.value = false
}
}
/**
* Logout current account
*/
@@ -142,8 +171,12 @@ export function useAccounts() {
loginWithExtension,
loginWithPersona,
loginWithPrivateKey,
loginWithAmber,
logout,
// Platform checks
isAmberSupported,
// Personas for dev UI
testPersonas,
tastemakerPersonas,

View File

@@ -320,6 +320,7 @@ export function useNostr(contentId?: string) {
/**
* Post a reaction (+/-) on the movie/content itself.
* Uses kind 17 with #i and #k tags.
* One vote per user per content — rejects duplicates.
*/
async function postReaction(positive: boolean, id: string = contentId!) {
if (!id) throw new Error('Content ID required')
@@ -327,6 +328,14 @@ export function useNostr(contentId?: string) {
const account = accountManager.active
if (!account) throw new Error('Not logged in')
// Prevent duplicate votes (check store, not just UI state)
const existingVote = reactions.value.find(
(r) => r.pubkey === account.pubkey,
)
if (existingVote) {
throw new Error('You have already voted on this content')
}
const externalId = getExternalContentId(id)
try {
@@ -353,11 +362,18 @@ export function useNostr(contentId?: string) {
/**
* React to a comment event (+/-).
* Uses factory.reaction(event, emoji) which creates kind 7 events.
* One reaction per user per comment — rejects duplicates.
*/
async function reactToComment(event: NostrEvent, positive: boolean) {
const account = accountManager.active
if (!account) throw new Error('Not logged in')
// Prevent duplicate comment reactions
const existing = commentReactions.value.get(event.id) || []
if (existing.some((r) => r.pubkey === account.pubkey)) {
throw new Error('You have already reacted to this comment')
}
const currentRelays = relays.value ?? APP_RELAYS
try {
@@ -375,21 +391,39 @@ export function useNostr(contentId?: string) {
// --- Computed State ---
/**
* Movie-level reaction counts.
* Deduplicated reactions: keep only the latest reaction per pubkey.
* Prevents inflated counts when a user has published multiple events.
*/
const uniqueReactions = computed(() => {
const byPubkey = new Map<string, NostrEvent>()
for (const r of reactions.value) {
const existing = byPubkey.get(r.pubkey)
if (!existing || r.created_at > existing.created_at) {
byPubkey.set(r.pubkey, r)
}
}
return [...byPubkey.values()]
})
/**
* Movie-level reaction counts (deduplicated per user).
*/
const reactionCounts = computed(() => {
const positive = reactions.value.filter((r) => r.content === '+').length
const negative = reactions.value.filter((r) => r.content === '-').length
const positive = uniqueReactions.value.filter((r) => r.content === '+').length
const negative = uniqueReactions.value.filter((r) => r.content === '-').length
return { positive, negative, total: positive - negative }
})
/**
* Current user's movie-level reaction, read from relay data.
* Uses the latest event if multiple exist.
*/
const userContentReaction = computed((): string | null => {
const account = accountManager.active
if (!account) return null
const userReaction = reactions.value.find((r) => r.pubkey === account.pubkey)
const userReaction = uniqueReactions.value.find(
(r) => r.pubkey === account.pubkey,
)
return userReaction?.content || null
})
@@ -399,12 +433,27 @@ export function useNostr(contentId?: string) {
const hasVotedOnContent = computed(() => userContentReaction.value !== null)
/**
* Get reaction counts for a specific comment event.
* Deduplicate comment reactions: one per pubkey, keep latest.
*/
function getUniqueCommentReactions(eventId: string): NostrEvent[] {
const events = commentReactions.value.get(eventId) || []
const byPubkey = new Map<string, NostrEvent>()
for (const r of events) {
const existing = byPubkey.get(r.pubkey)
if (!existing || r.created_at > existing.created_at) {
byPubkey.set(r.pubkey, r)
}
}
return [...byPubkey.values()]
}
/**
* Get reaction counts for a specific comment event (deduplicated per user).
*/
function getCommentReactionCounts(eventId: string) {
const events = commentReactions.value.get(eventId) || []
const positive = events.filter((r) => r.content === '+').length
const negative = events.filter((r) => r.content === '-').length
const unique = getUniqueCommentReactions(eventId)
const positive = unique.filter((r) => r.content === '+').length
const negative = unique.filter((r) => r.content === '-').length
return { positive, negative }
}
@@ -414,8 +463,8 @@ export function useNostr(contentId?: string) {
function getUserCommentReaction(eventId: string): string | null {
const account = accountManager.active
if (!account) return null
const events = commentReactions.value.get(eventId) || []
const userReaction = events.find((r) => r.pubkey === account.pubkey)
const unique = getUniqueCommentReactions(eventId)
const userReaction = unique.find((r) => r.pubkey === account.pubkey)
return userReaction?.content || null
}