Adds dynamic quick actions via modules

Introduces a dynamic quick action system, allowing modules to register actions that appear in a floating action button menu.

This provides a flexible way for modules to extend the application's functionality with common tasks like composing notes or initiating payments.
This commit is contained in:
padreug 2025-11-07 16:00:07 +01:00
parent b286a0315d
commit 678ccff694
4 changed files with 219 additions and 83 deletions

View file

@ -0,0 +1,95 @@
import { computed } from 'vue'
import { pluginManager } from '@/core/plugin-manager'
import type { QuickAction } from '@/core/types'
/**
* Composable for dynamic quick actions based on enabled modules
*
* Quick actions are module-provided action buttons that appear in the floating
* action button (FAB) menu. Each module can register its own quick actions
* for common tasks like composing notes, sending payments, adding expenses, etc.
*
* @example
* ```typescript
* const { quickActions, getActionsByCategory } = useQuickActions()
*
* // Get all actions
* const actions = quickActions.value
*
* // Get actions by category
* const composeActions = getActionsByCategory('compose')
* ```
*/
export function useQuickActions() {
/**
* Get all quick actions from installed modules
* Actions are sorted by order (lower = higher priority)
*/
const quickActions = computed<QuickAction[]>(() => {
const actions: QuickAction[] = []
// Iterate through installed modules
const installedModules = pluginManager.getInstalledModules()
for (const moduleName of installedModules) {
const module = pluginManager.getModule(moduleName)
if (module?.plugin.quickActions) {
actions.push(...module.plugin.quickActions)
}
}
// Sort by order (lower = higher priority), then by label
return actions.sort((a, b) => {
const orderA = a.order ?? 999
const orderB = b.order ?? 999
if (orderA !== orderB) {
return orderA - orderB
}
return a.label.localeCompare(b.label)
})
})
/**
* Get actions filtered by category
*/
const getActionsByCategory = (category: string) => {
return computed(() => {
return quickActions.value.filter(action => action.category === category)
})
}
/**
* Get a specific action by ID
*/
const getActionById = (id: string) => {
return computed(() => {
return quickActions.value.find(action => action.id === id)
})
}
/**
* Check if any actions are available
*/
const hasActions = computed(() => quickActions.value.length > 0)
/**
* Get all unique categories
*/
const categories = computed(() => {
const cats = new Set<string>()
quickActions.value.forEach(action => {
if (action.category) {
cats.add(action.category)
}
})
return Array.from(cats).sort()
})
return {
quickActions,
getActionsByCategory,
getActionById,
hasActions,
categories
}
}

View file

@ -1,37 +1,64 @@
import type { App, Component } from 'vue'
import type { RouteRecordRaw } from 'vue-router'
// Quick action interface for modular action buttons
export interface QuickAction {
/** Unique action ID */
id: string
/** Display label for the action */
label: string
/** Lucide icon name */
icon: string
/** Component to render when action is selected */
component: Component
/** Display order (lower = higher priority) */
order?: number
/** Action category (e.g., 'compose', 'wallet', 'utilities') */
category?: string
/** Whether action requires authentication */
requiresAuth?: boolean
}
// Base module plugin interface
export interface ModulePlugin {
/** Unique module name */
name: string
/** Module version */
version: string
/** Required dependencies (other module names) */
dependencies?: string[]
/** Module configuration */
config?: Record<string, any>
/** Install the module */
install(app: App, options?: any): Promise<void> | void
/** Uninstall the module (cleanup) */
uninstall?(): Promise<void> | void
/** Routes provided by this module */
routes?: RouteRecordRaw[]
/** Components provided by this module */
components?: Record<string, Component>
/** Services provided by this module */
services?: Record<string, any>
/** Composables provided by this module */
composables?: Record<string, any>
/** Quick actions provided by this module */
quickActions?: QuickAction[]
}
// Module configuration for app setup

View file

@ -1,7 +1,10 @@
import type { App } from 'vue'
import { markRaw } from 'vue'
import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container'
import NostrFeed from './components/NostrFeed.vue'
import NoteComposer from './components/NoteComposer.vue'
import RideshareComposer from './components/RideshareComposer.vue'
import { useFeed } from './composables/useFeed'
import { FeedService } from './services/FeedService'
import { ProfileService } from './services/ProfileService'
@ -17,6 +20,28 @@ export const nostrFeedModule: ModulePlugin = {
version: '1.0.0',
dependencies: ['base'],
// Register quick actions for the FAB menu
quickActions: [
{
id: 'note',
label: 'Note',
icon: 'MessageSquare',
component: markRaw(NoteComposer),
category: 'compose',
order: 1,
requiresAuth: true
},
{
id: 'rideshare',
label: 'Rideshare',
icon: 'Car',
component: markRaw(RideshareComposer),
category: 'compose',
order: 2,
requiresAuth: true
}
],
async install(app: App) {
console.log('nostr-feed module: Starting installation...')

View file

@ -36,24 +36,20 @@
<!-- Main Feed Area - Takes remaining height with scrolling -->
<div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<!-- Collapsible Composer -->
<div v-if="showComposer || replyTo" class="border-b bg-background sticky top-0 z-10">
<!-- Quick Action Component Area -->
<div v-if="activeAction || replyTo" class="border-b bg-background sticky top-0 z-10">
<div class="max-h-[70vh] overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<div class="px-4 py-3 sm:px-6">
<!-- Regular Note Composer -->
<NoteComposer
v-if="composerType === 'note' || replyTo"
<!-- Dynamic Quick Action Component -->
<component
:is="activeAction?.component"
v-if="activeAction"
:reply-to="replyTo"
@note-published="onNotePublished"
@note-published="onActionComplete"
@rideshare-published="onActionComplete"
@action-complete="onActionComplete"
@clear-reply="onClearReply"
@close="onCloseComposer"
/>
<!-- Rideshare Composer -->
<RideshareComposer
v-else-if="composerType === 'rideshare'"
@rideshare-published="onRidesharePublished"
@close="onCloseComposer"
@close="closeQuickAction"
/>
</div>
</div>
@ -72,39 +68,32 @@
</div>
</div>
<!-- Floating Action Buttons for Compose -->
<div v-if="!showComposer && !replyTo" class="fixed bottom-6 right-6 z-50">
<!-- Main compose button -->
<!-- Floating Quick Action Button -->
<div v-if="!activeAction && !replyTo && quickActions.length > 0" class="fixed bottom-6 right-6 z-50">
<div class="flex flex-col items-end gap-3">
<!-- Secondary buttons (when expanded) -->
<div v-if="showComposerOptions" class="flex flex-col gap-2">
<!-- Quick Action Buttons (when expanded) -->
<div v-if="showQuickActions" class="flex flex-col gap-2">
<Button
@click="openComposer('note')"
v-for="action in quickActions"
:key="action.id"
@click="openQuickAction(action)"
size="lg"
class="h-12 px-4 rounded-full shadow-lg hover:shadow-xl transition-all gap-2 bg-card border-2 border-border hover:bg-accent text-card-foreground"
>
<MessageSquare class="h-4 w-4" />
<span class="text-sm font-medium">Note</span>
</Button>
<Button
@click="openComposer('rideshare')"
size="lg"
class="h-12 px-4 rounded-full shadow-lg hover:shadow-xl transition-all gap-2 bg-card border-2 border-border hover:bg-accent text-card-foreground"
>
<Car class="h-4 w-4" />
<span class="text-sm font-medium">Rideshare</span>
<component :is="getIconComponent(action.icon)" class="h-4 w-4" />
<span class="text-sm font-medium">{{ action.label }}</span>
</Button>
</div>
<!-- Main FAB -->
<Button
@click="toggleComposerOptions"
@click="toggleQuickActions"
size="lg"
class="h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-all bg-primary hover:bg-primary/90 border-2 border-primary-foreground/20 flex items-center justify-center p-0"
>
<Plus
class="h-6 w-6 stroke-[2.5] transition-transform duration-200"
:class="{ 'rotate-45': showComposerOptions }"
:class="{ 'rotate-45': showQuickActions }"
/>
</Button>
</div>
@ -136,32 +125,34 @@
import { ref, computed, watch } from 'vue'
import { Button } from '@/components/ui/button'
import { Filter, Plus, MessageSquare, Car } from 'lucide-vue-next'
import { Filter, Plus } from 'lucide-vue-next'
import * as LucideIcons from 'lucide-vue-next'
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
import FeedFilters from '@/modules/nostr-feed/components/FeedFilters.vue'
import NoteComposer from '@/modules/nostr-feed/components/NoteComposer.vue'
import RideshareComposer from '@/modules/nostr-feed/components/RideshareComposer.vue'
import NostrFeed from '@/modules/nostr-feed/components/NostrFeed.vue'
import { FILTER_PRESETS } from '@/modules/nostr-feed/config/content-filters'
import { useQuickActions } from '@/composables/useQuickActions'
import appConfig from '@/app.config'
import type { ContentFilter } from '@/modules/nostr-feed/services/FeedService'
import type { ReplyToNote } from '@/modules/nostr-feed/components/NoteComposer.vue'
import type { QuickAction } from '@/core/types'
// Get quick actions from modules
const { quickActions } = useQuickActions()
// Get admin pubkeys from app config
const adminPubkeys = appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
// UI state
const showFilters = ref(false)
const showComposer = ref(false)
const showComposerOptions = ref(false)
const composerType = ref<'note' | 'rideshare'>('note')
const showQuickActions = ref(false)
const activeAction = ref<QuickAction | null>(null)
// Feed configuration
const selectedFilters = ref<ContentFilter[]>(FILTER_PRESETS.all)
const feedKey = ref(0) // Force feed component to re-render when filters change
// Note composer state
const replyTo = ref<ReplyToNote | undefined>()
// Reply state (for note composer compatibility)
const replyTo = ref<any | undefined>()
// Quick filter presets for mobile bottom bar
const quickFilterPresets = {
@ -221,48 +212,46 @@ const setQuickFilter = (presetKey: string) => {
}
}
const onNotePublished = (noteId: string) => {
console.log('Note published:', noteId)
// Refresh the feed to show the new note
feedKey.value++
// Clear reply state and hide composer
// Quick action methods
const toggleQuickActions = () => {
showQuickActions.value = !showQuickActions.value
}
const openQuickAction = (action: QuickAction) => {
activeAction.value = action
showQuickActions.value = false
}
const closeQuickAction = () => {
activeAction.value = null
replyTo.value = undefined
}
// Event handlers for quick action components
const onActionComplete = (eventData?: any) => {
console.log('Quick action completed:', activeAction.value?.id, eventData)
// Refresh the feed to show new content
feedKey.value++
// Close the action
activeAction.value = null
replyTo.value = undefined
showComposer.value = false
}
const onClearReply = () => {
replyTo.value = undefined
showComposer.value = false
}
const onReplyToNote = (note: ReplyToNote) => {
const onReplyToNote = (note: any) => {
replyTo.value = note
showComposer.value = true
// Find and open the note composer action
const noteAction = quickActions.value.find(a => a.id === 'note')
if (noteAction) {
activeAction.value = noteAction
}
}
const onCloseComposer = () => {
showComposer.value = false
showComposerOptions.value = false
replyTo.value = undefined
}
// New composer methods
const toggleComposerOptions = () => {
showComposerOptions.value = !showComposerOptions.value
}
const openComposer = (type: 'note' | 'rideshare') => {
composerType.value = type
showComposer.value = true
showComposerOptions.value = false
}
const onRidesharePublished = (noteId: string) => {
console.log('Rideshare post published:', noteId)
// Refresh the feed to show the new rideshare post
feedKey.value++
// Hide composer
showComposer.value = false
showComposerOptions.value = false
// Helper to get Lucide icon component
const getIconComponent = (iconName: string) => {
return (LucideIcons as any)[iconName] || Plus
}
</script>