Merge branch 'master' into didview-invalid-did-handling

This commit is contained in:
Jose Olarte III
2025-08-27 15:42:15 +08:00
185 changed files with 14628 additions and 2803 deletions

View File

@@ -4,7 +4,7 @@
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
<NotificationGroup group="alert">
<div
class="fixed z-[90] top-[max(1rem,env(safe-area-inset-top))] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end"
class="fixed z-[120] top-[max(1rem,env(safe-area-inset-top),var(--safe-area-inset-top,0px))] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end"
>
<Notification
v-slot="{ notifications, close }"
@@ -175,7 +175,9 @@
"-permission", "-mute", "-off"
-->
<NotificationGroup group="modal">
<div class="fixed z-[100] top-[env(safe-area-inset-top)] inset-x-0 w-full">
<div
class="fixed z-[100] top-[max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))] inset-x-0 w-full"
>
<Notification
v-slot="{ notifications, close }"
enter="transform ease-out duration-300 transition"
@@ -506,13 +508,32 @@ export default class App extends Vue {
<style>
#Content {
padding-left: max(1.5rem, env(safe-area-inset-left));
padding-right: max(1.5rem, env(safe-area-inset-right));
padding-top: max(1.5rem, env(safe-area-inset-top));
padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
padding-left: max(
1.5rem,
env(safe-area-inset-left),
var(--safe-area-inset-left, 0px)
);
padding-right: max(
1.5rem,
env(safe-area-inset-right),
var(--safe-area-inset-right, 0px)
);
padding-top: max(
1.5rem,
env(safe-area-inset-top),
var(--safe-area-inset-top, 0px)
);
padding-bottom: max(
1.5rem,
env(safe-area-inset-bottom),
var(--safe-area-inset-bottom, 0px)
);
}
#QuickNav ~ #Content {
padding-bottom: calc(env(safe-area-inset-bottom) + 6.333rem);
padding-bottom: calc(
max(env(safe-area-inset-bottom), var(--safe-area-inset-bottom, 0px)) +
6.333rem
);
}
</style>

View File

@@ -14,4 +14,12 @@
transform: translateX(100%);
background-color: #FFF !important;
}
.dialog-overlay {
@apply z-[100] fixed inset-0 bg-black/50 flex justify-center items-center p-6;
}
.dialog {
@apply bg-white p-4 rounded-lg w-full max-w-lg;
}
}

View File

@@ -1,7 +1,7 @@
<!-- similar to UserNameDialog -->
<template>
<div v-if="visible" :class="overlayClasses">
<div :class="dialogClasses">
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 :class="titleClasses">{{ title }}</h1>
{{ message }}
Note that their name is only stored on this device.
@@ -61,20 +61,6 @@ export default class ContactNameDialog extends Vue {
title = "Contact Name";
visible = false;
/**
* CSS classes for the modal overlay backdrop
*/
get overlayClasses(): string {
return "z-index-50 fixed top-0 left-0 right-0 bottom-0 bg-black/50 flex justify-center items-center p-6";
}
/**
* CSS classes for the modal dialog container
*/
get dialogClasses(): string {
return "bg-white p-4 rounded-lg w-full max-w-[500px]";
}
/**
* CSS classes for the dialog title
*/

View File

@@ -22,7 +22,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- "Unnamed" entity -->
<SpecialEntityCard
entity-type="unnamed"
label="Unnamed"
:label="unnamedEntityName"
icon="circle-question"
:entity-data="unnamedEntityData"
:notify="notify"
@@ -83,6 +83,7 @@ import ShowAllCard from "./ShowAllCard.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
/**
* EntityGrid - Unified grid layout for displaying people or projects
@@ -159,6 +160,10 @@ export default class EntityGrid extends Vue {
@Prop({ default: "other party" })
conflictContext!: string;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/**
* Function to determine which entities to display (allows parent control)
*
@@ -245,7 +250,9 @@ export default class EntityGrid extends Vue {
* Whether to show the "Show All" navigation
*/
get shouldShowAll(): boolean {
return this.entities.length > 0 && this.showAllRoute !== "";
return (
!this.hideShowAll && this.entities.length > 0 && this.showAllRoute !== ""
);
}
/**
@@ -271,10 +278,17 @@ export default class EntityGrid extends Vue {
get unnamedEntityData(): { did: string; name: string } {
return {
did: "",
name: "Unnamed",
name: UNNAMED_ENTITY_NAME,
};
}
/**
* Get the unnamed entity name constant
*/
get unnamedEntityName(): string {
return UNNAMED_ENTITY_NAME;
}
/**
* Check if a person DID is conflicted
*/
@@ -304,16 +318,13 @@ export default class EntityGrid extends Vue {
/**
* Handle special entity selection from SpecialEntityCard
* Treat "You" and "Unnamed" as person entities
*/
handleEntitySelected(event: {
type: string;
entityType: string;
data: { did?: string; name: string };
}): void {
handleEntitySelected(event: { data: { did?: string; name: string } }): void {
// Convert special entities to person entities since they represent people
this.emitEntitySelected({
type: "special",
entityType: event.entityType,
data: event.data,
type: "person",
data: event.data as Contact,
});
}
@@ -321,13 +332,11 @@ export default class EntityGrid extends Vue {
@Emit("entity-selected")
emitEntitySelected(data: {
type: "person" | "project" | "special";
entityType?: string;
data: Contact | PlanData | { did?: string; name: string };
type: "person" | "project";
data: Contact | PlanData;
}): {
type: "person" | "project" | "special";
entityType?: string;
data: Contact | PlanData | { did?: string; name: string };
type: "person" | "project";
data: Contact | PlanData;
} {
return data;
}

View File

@@ -27,6 +27,7 @@ Matthew Raymer */
:show-all-query-params="showAllQueryParams"
:notify="notify"
:conflict-context="conflictContext"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected"
/>
@@ -55,9 +56,8 @@ interface EntityData {
* Entity selection event data structure
*/
interface EntitySelectionEvent {
type: "person" | "project" | "special";
entityType?: string;
data: Contact | PlanData | EntityData;
type: "person" | "project";
data: Contact | PlanData;
}
/**
@@ -154,6 +154,10 @@ export default class EntitySelectionStep extends Vue {
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/**
* CSS classes for the cancel button
*/

View File

@@ -42,8 +42,8 @@ computed CSS properties * * @author Matthew Raymer */
<p class="text-xs text-slate-500 leading-1 -mb-1 uppercase">
{{ label }}
</p>
<h3 class="font-semibold truncate">
{{ entity?.name || "Unnamed" }}
<h3 :class="nameClasses">
{{ displayName }}
</h3>
</div>
@@ -62,6 +62,7 @@ import { Component, Prop, Vue } from "vue-facing-decorator";
import EntityIcon from "./EntityIcon.vue";
import ProjectIcon from "./ProjectIcon.vue";
import { Contact } from "../db/tables/contacts";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
/**
* Entity interface for both person and project entities
@@ -138,6 +139,38 @@ export default class EntitySummaryButton extends Vue {
return this.editable ? "text-blue-500" : "text-slate-400";
}
/**
* Computed CSS classes for the entity name
*/
get nameClasses(): string {
const baseClasses = "font-semibold truncate";
// Add italic styling for special "Unnamed" or entities without set names
if (!this.entity?.name || this.entity?.did === "") {
return `${baseClasses} italic text-slate-500`;
}
return baseClasses;
}
/**
* Computed display name for the entity
*/
get displayName(): string {
// If the entity has a set name, use that name
if (this.entity?.name) {
return this.entity.name;
}
// If the entity is the special "Unnamed", use "Unnamed"
if (this.entity?.did === "") {
return UNNAMED_ENTITY_NAME;
}
// If the entity does not have a set name, but is not the special "Unnamed", use their DID
return this.entity?.did;
}
/**
* Handle click event - only call function prop if editable
* Allows parent to control edit behavior and validation

View File

@@ -212,30 +212,7 @@ export default class FeedFilters extends Vue {
</script>
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
#dialogFeedFilters.dialog-overlay {
z-index: 100;
overflow: scroll;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -29,6 +29,7 @@
:unit-code="unitCode"
:offer-id="offerId"
:notify="$notify"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected"
@cancel="cancel"
/>
@@ -87,6 +88,7 @@ import {
NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER,
NOTIFY_GIFTED_DETAILS_RECORDING_GIVE,
} from "@/constants/notifications";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
@Component({
components: {
@@ -114,6 +116,7 @@ export default class GiftedDialog extends Vue {
@Prop() fromProjectId = "";
@Prop() toProjectId = "";
@Prop() isFromProjectView = false;
@Prop() hideShowAll = false;
@Prop({ default: "person" }) giverEntityType = "person" as
| "person"
| "project";
@@ -224,15 +227,6 @@ export default class GiftedDialog extends Vue {
this.allMyDids = await retrieveAccountDids();
if (this.giver && !this.giver.name) {
this.giver.name = didInfo(
this.giver.did,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
if (
this.giverEntityType === "project" ||
this.recipientEntityType === "project"
@@ -455,14 +449,14 @@ export default class GiftedDialog extends Vue {
if (contact) {
this.giver = {
did: contact.did,
name: contact.name || contact.did,
name: contact.name,
};
} else {
// Only set to "Unnamed" if no giver is currently set
if (!this.giver || !this.giver.did) {
this.giver = {
did: "",
name: "Unnamed",
name: UNNAMED_ENTITY_NAME,
};
}
}
@@ -517,14 +511,14 @@ export default class GiftedDialog extends Vue {
if (contact) {
this.receiver = {
did: contact.did,
name: contact.name || contact.did,
name: contact.name,
};
} else {
// Only set to "Unnamed" if no receiver is currently set
if (!this.receiver || !this.receiver.did) {
this.receiver = {
did: "",
name: "Unnamed",
name: UNNAMED_ENTITY_NAME,
};
}
}
@@ -566,20 +560,21 @@ export default class GiftedDialog extends Vue {
/**
* Handle entity selection from EntitySelectionStep
* @param entity - The selected entity (person, project, or special) with stepType
* @param entity - The selected entity (person or project) with stepType
*/
handleEntitySelected(entity: {
type: "person" | "project" | "special";
entityType?: string;
data: Contact | PlanData | { did?: string; name: string };
type: "person" | "project";
data: Contact | PlanData;
stepType: string;
}) {
if (entity.type === "person") {
const contact = entity.data as Contact;
// Apply DID-based logic for person entities
const processedContact = this.processPersonEntity(contact);
if (entity.stepType === "giver") {
this.selectGiver(contact);
this.selectGiver(processedContact);
} else {
this.selectRecipient(contact);
this.selectRecipient(processedContact);
}
} else if (entity.type === "project") {
const project = entity.data as PlanData;
@@ -588,33 +583,22 @@ export default class GiftedDialog extends Vue {
} else {
this.selectRecipientProject(project);
}
} else if (entity.type === "special") {
// Handle special entities like "You" and "Unnamed"
if (entity.entityType === "you") {
// "You" entity selected
const youEntity = {
did: this.activeDid,
name: "You",
};
if (entity.stepType === "giver") {
this.giver = youEntity;
} else {
this.receiver = youEntity;
}
this.firstStep = false;
} else if (entity.entityType === "unnamed") {
// "Unnamed" entity selected
const unnamedEntity = {
did: "",
name: "Unnamed",
};
if (entity.stepType === "giver") {
this.giver = unnamedEntity;
} else {
this.receiver = unnamedEntity;
}
this.firstStep = false;
}
}
}
/**
* Processes person entities using DID-based logic for "You" and "Unnamed"
*/
private processPersonEntity(contact: Contact): Contact {
if (contact.did === this.activeDid) {
// If DID matches active DID, create "You" entity
return { ...contact, name: "You" };
} else if (!contact.did || contact.did === "") {
// If DID is empty/null, create "Unnamed" entity
return { ...contact, name: UNNAMED_ENTITY_NAME };
} else {
// Return the contact as-is
return contact;
}
}
@@ -665,27 +649,3 @@ export default class GiftedDialog extends Vue {
}
}
</script>
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -291,27 +291,3 @@ export default class GivenPrompts extends Vue {
}
}
</script>
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -1,9 +1,6 @@
<template>
<div
v-if="isOpen"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
<div v-if="isOpen" class="dialog-overlay">
<div class="dialog">
<!-- Header -->
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold capitalize">{{ roleName }} Details</h2>

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="visible" class="dialog-overlay z-[60]">
<div v-if="visible" class="dialog-overlay">
<div class="dialog relative">
<div class="text-lg text-center font-bold relative">
<h1 id="ViewHeading" class="text-center font-bold">
@@ -931,32 +931,6 @@ export default class ImageMethodDialog extends Vue {
</script>
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 700px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Add styles for diagnostic panel */
.diagnostic-panel {
font-family: monospace;

View File

@@ -93,27 +93,3 @@ export default class InviteDialog extends Vue {
}
}
</script>
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -81,7 +81,7 @@
<div class="flex items-center justify-between">
<div class="flex items-center">
<h3 class="text-lg font-medium">
{{ member.name || "Unnamed Member" }}
{{ member.name || unnamedMember }}
</h3>
<div
v-if="!getContactFor(member.did) && member.did !== activeDid"
@@ -177,6 +177,7 @@ import {
NOTIFY_ADD_CONTACT_FIRST,
NOTIFY_CONTINUE_WITHOUT_ADDING,
} from "@/constants/notifications";
import { SOMEONE_UNNAMED } from "@/constants/entities";
interface Member {
admitted: boolean;
@@ -220,6 +221,13 @@ export default class MembersList extends Vue {
apiServer = "";
contacts: Array<Contact> = [];
/**
* Get the unnamed member constant
*/
get unnamedMember(): string {
return SOMEONE_UNNAMED;
}
async created() {
this.notify = createNotifyHelpers(this.$notify);

View File

@@ -312,28 +312,3 @@ export default class OfferDialog extends Vue {
}
}
</script>
<style scoped>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 50;
}
.dialog {
background: white;
padding: 1.5rem;
border-radius: 0.5rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
</style>

View File

@@ -307,27 +307,3 @@ export default class OnboardingDialog extends Vue {
}
}
</script>
<style>
.dialog-overlay {
z-index: 40;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -25,7 +25,7 @@ conflict detection. * * @author Matthew Raymer */
</div>
<h3 :class="nameClasses">
{{ person.name || person.did || "Unnamed" }}
{{ displayName }}
</h3>
</li>
</template>
@@ -98,9 +98,27 @@ export default class PersonCard extends Vue {
return `${baseClasses} text-slate-400`;
}
// Add italic styling for entities without set names
if (!this.person.name) {
return `${baseClasses} italic text-slate-500`;
}
return baseClasses;
}
/**
* Computed display name for the person
*/
get displayName(): string {
// If the entity has a set name, use that name
if (this.person.name) {
return this.person.name;
}
// If the entity does not have a set name
return this.person.did;
}
/**
* Handle card click - emit if selectable and not conflicted, show warning if conflicted
*/
@@ -114,7 +132,7 @@ export default class PersonCard extends Vue {
group: "alert",
type: "warning",
title: "Cannot Select",
text: `You cannot select "${this.person.name || this.person.did || "Unnamed"}" because they are already selected as the ${this.conflictContext}.`,
text: `You cannot select "${this.displayName}" because they are already selected as the ${this.conflictContext}.`,
},
3000,
);

View File

@@ -10,7 +10,7 @@ Comprehensive error handling * * @author Matthew Raymer * @version 1.0.0 * @file
PhotoDialog.vue */
<template>
<div v-if="visible" class="dialog-overlay z-[60]">
<div v-if="visible" class="dialog-overlay">
<div class="dialog relative">
<div class="text-lg text-center font-light relative z-50">
<div id="ViewHeading" :class="headingClasses">
@@ -628,34 +628,6 @@ export default class PhotoDialog extends Vue {
</script>
<style>
/* Dialog overlay styling */
.dialog-overlay {
z-index: 60;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
/* Dialog container styling */
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 700px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Camera preview styling */
.camera-preview {
flex: 1;

View File

@@ -15,7 +15,7 @@ issuer information. * * @author Matthew Raymer */
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ project.name || "Unnamed Project" }}
{{ project.name || unnamedProject }}
</h3>
<div class="text-xs text-slate-500 truncate">
@@ -31,6 +31,7 @@ import ProjectIcon from "./ProjectIcon.vue";
import { PlanData } from "../interfaces/records";
import { Contact } from "../db/tables/contacts";
import { didInfo } from "../libs/endorserServer";
import { UNNAMED_PROJECT } from "@/constants/entities";
/**
* ProjectCard - Displays a project entity with selection capability
@@ -63,6 +64,13 @@ export default class ProjectCard extends Vue {
@Prop({ required: true })
allContacts!: Contact[];
/**
* Get the unnamed project constant
*/
get unnamedProject(): string {
return UNNAMED_PROJECT;
}
/**
* Computed display name for the project issuer
*/

View File

@@ -115,6 +115,7 @@ import { urlBase64ToUint8Array } from "../libs/crypto/vc/util";
import * as libsUtil from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
// Example interface for error
interface ErrorResponse {
@@ -602,7 +603,7 @@ export default class PushNotificationPermission extends Vue {
* Returns the default message for direct push
*/
get notificationMessagePlaceholder(): string {
return "Click to share some gratitude with the world -- even if they're unnamed.";
return `Click to share some gratitude with the world -- even if they're ${UNNAMED_ENTITY_NAME.toLowerCase()}.`;
}
/**

View File

@@ -2,7 +2,7 @@
<!-- QUICK NAV -->
<nav
id="QuickNav"
class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50 pb-[env(safe-area-inset-bottom)]"
class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50 pb-[max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px))]"
>
<ul class="flex text-2xl px-6 py-2 gap-1 max-w-3xl mx-auto">
<!-- Home Feed -->

View File

@@ -1,33 +1,154 @@
/** * @file RegistrationNotice.vue * @description Reusable component for
displaying user registration status and related actions. * Shows registration
notice when user is not registered, with options to show identifier info * or
access advanced options. * * @author Jose Olarte III * @version 1.0.0 * @created
2025-08-21T17:25:28-08:00 */
<template>
<div
v-if="!isRegistered && show"
id="noticeBeforeAnnounce"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mt-4"
role="alert"
aria-live="polite"
id="noticeSomeoneMustRegisterYou"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 my-4"
>
<p class="mb-4">
Before you can publicly announce a new project or time commitment, a
friend needs to register you.
</p>
<button
class="inline-block text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="shareInfo"
>
Share Your Info
</button>
<p class="mb-4">{{ message }}</p>
<div class="grid grid-cols-1 gap-2 sm:flex sm:justify-center">
<button
class="inline-block text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="showNameThenIdDialog"
>
Show them {{ passkeysEnabled ? "default" : "your" }} identifier info
</button>
<button
class="inline-block text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="openAdvancedOptions"
>
See advanced options
</button>
</div>
</div>
<UserNameDialog ref="userNameDialog" />
<ChoiceButtonDialog ref="choiceButtonDialog" />
</template>
<script lang="ts">
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
import { Component, Vue, Prop } from "vue-facing-decorator";
import { Router } from "vue-router";
import { Capacitor } from "@capacitor/core";
import UserNameDialog from "./UserNameDialog.vue";
import ChoiceButtonDialog from "./ChoiceButtonDialog.vue";
@Component({ name: "RegistrationNotice" })
/**
* RegistrationNotice Component
*
* Displays registration status notice and provides actions for unregistered users.
* Handles all registration-related flows internally without requiring parent component intervention.
*
* Template Usage:
* ```vue
* <RegistrationNotice
* v-if="!isUserRegistered"
* :passkeys-enabled="PASSKEYS_ENABLED"
* :given-name="givenName"
* message="Custom registration message here"
* />
* ```
*
* Component Dependencies:
* - UserNameDialog: Dialog for entering user name
* - ChoiceButtonDialog: Dialog for sharing method selection
*/
@Component({
name: "RegistrationNotice",
components: {
UserNameDialog,
ChoiceButtonDialog,
},
})
export default class RegistrationNotice extends Vue {
@Prop({ required: true }) isRegistered!: boolean;
@Prop({ required: true }) show!: boolean;
$router!: Router;
@Emit("share-info")
shareInfo() {}
/**
* Whether passkeys are enabled in the application
*/
@Prop({ required: true })
passkeysEnabled!: boolean;
/**
* User's given name for dialog pre-population
*/
@Prop({ required: true })
givenName!: string;
/**
* Custom message to display in the registration notice
* Defaults to "To share, someone must register you."
*/
@Prop({ default: "To share, someone must register you." })
message!: string;
/**
* Shows name input dialog if needed
* Handles the full flow internally without requiring parent component intervention
*/
showNameThenIdDialog() {
this.openUserNameDialog(() => {
this.promptForShareMethod();
});
}
/**
* Opens advanced options page
* Navigates directly to the start page
*/
openAdvancedOptions() {
this.$router.push({ name: "start" });
}
/**
* Shows dialog for sharing method selection
* Provides options for different sharing scenarios
*/
promptForShareMethod() {
(this.$refs.choiceButtonDialog as ChoiceButtonDialog).open({
title: "How can you share your info?",
text: "",
option1Text: "We are nearby with cameras",
option2Text: "Someone created a meeting room",
option3Text: "We will share some other way",
onOption1: () => {
this.handleQRCodeClick();
},
onOption2: () => {
this.$router.push({ name: "onboard-meeting-list" });
},
onOption3: () => {
this.$router.push({ name: "share-my-contact-info" });
},
});
}
/**
* Handles QR code sharing based on platform
* Navigates to appropriate QR code page
*/
private handleQRCodeClick() {
if (Capacitor.isNativePlatform()) {
this.$router.push({ name: "contact-qr-scan-full" });
} else {
this.$router.push({ name: "contact-qr" });
}
}
/**
* Opens the user name dialog if needed
*
* @param callback Function to call after name is entered
*/
openUserNameDialog(callback: () => void) {
if (!this.givenName) {
(this.$refs.userNameDialog as UserNameDialog).open(callback);
} else {
callback();
}
}
}
</script>

View File

@@ -124,8 +124,6 @@ export default class SpecialEntityCard extends Vue {
handleClick(): void {
if (this.selectable && !this.conflicted) {
this.emitEntitySelected({
type: "special",
entityType: this.entityType,
data: this.entityData,
});
} else if (this.conflicted && this.notify) {
@@ -145,13 +143,7 @@ export default class SpecialEntityCard extends Vue {
// Emit methods using @Emit decorator
@Emit("entity-selected")
emitEntitySelected(data: {
type: string;
entityType: string;
data: { did?: string; name: string };
}): {
type: string;
entityType: string;
emitEntitySelected(data: { data: { did?: string; name: string } }): {
data: { did?: string; name: string };
} {
return data;

View File

@@ -1,5 +1,7 @@
<template>
<div class="absolute right-5 top-[max(0.75rem,env(safe-area-inset-top))]">
<div
class="absolute right-5 top-[max(0.75rem,env(safe-area-inset-top),var(--safe-area-inset-top,0px))]"
>
<span class="align-center text-red-500 mr-2">{{ message }}</span>
<span class="ml-2">
<router-link

View File

@@ -134,27 +134,3 @@ export default class UserNameDialog extends Vue {
}
}
</script>
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

14
src/constants/entities.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* Constants for entity-related strings, particularly for unnamed/unknown person entities
*/
// Core unnamed entity names
export const UNNAMED_ENTITY_NAME = "Unnamed";
// Descriptive phrases for unnamed entities
export const SOMEONE_UNNAMED = "Someone Unnamed";
export const THAT_UNNAMED_PERSON = "That unnamed person";
export const UNNAMED_PERSON = "unnamed person";
// Project-related unnamed entities
export const UNNAMED_PROJECT = "Unnamed Project";

View File

@@ -1,4 +1,5 @@
import axios from "axios";
import { THAT_UNNAMED_PERSON } from "./entities";
// Notification message constants for user-facing notifications
// Add new notification messages here as needed
@@ -873,7 +874,7 @@ export const NOTIFY_CONTACT_LINK_COPIED = {
// Template for registration success message
// Used in: ContactsView.vue (register method - registration success with contact name)
export const getRegisterPersonSuccessMessage = (name?: string): string =>
`${name || "That unnamed person"} ${NOTIFY_REGISTER_PERSON_SUCCESS.message}`;
`${name || THAT_UNNAMED_PERSON} ${NOTIFY_REGISTER_PERSON_SUCCESS.message}`;
// Template for visibility success message
// Used in: ContactsView.vue (setVisibility method - visibility success with contact name)
@@ -1378,7 +1379,7 @@ export function createQRContactAddedMessage(hasVisibility: boolean): string {
export function createQRRegistrationSuccessMessage(
contactName: string,
): string {
return `${contactName || "That unnamed person"}${NOTIFY_QR_REGISTRATION_SUCCESS.message}`;
return `${contactName || THAT_UNNAMED_PERSON}${NOTIFY_QR_REGISTRATION_SUCCESS.message}`;
}
// ContactQRScanShowView.vue timeout constants

View File

@@ -60,6 +60,7 @@ import { PlanSummaryRecord } from "../interfaces/records";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { APP_SERVER } from "@/constants/app";
import { SOMEONE_UNNAMED } from "@/constants/entities";
/**
* Standard context for schema.org data
@@ -309,7 +310,7 @@ export function didInfoForContact(
showDidForVisible: boolean = false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): { known: boolean; displayName: string; profileImageUrl?: string } {
if (!did) return { displayName: "Someone Unnamed/Unknown", known: false };
if (!did) return { displayName: SOMEONE_UNNAMED, known: false };
if (did === activeDid) {
return { displayName: "You", known: true };
} else if (contact) {

View File

@@ -33,6 +33,7 @@ import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { IIdentifier } from "@veramo/core";
import { DEFAULT_ROOT_DERIVATION_PATH } from "./crypto";
import { UNNAMED_PERSON } from "@/constants/entities";
// Consolidate this with src/utils/PlatformServiceMixin.mapQueryResultToValues
function mapQueryResultToValues(
@@ -192,7 +193,7 @@ export const nameForContact = (
): string => {
return (
(contact?.name as string) ||
(capitalize ? "This" : "this") + " unnamed user"
(capitalize ? "This" : "this") + " " + UNNAMED_PERSON
);
};
@@ -657,7 +658,7 @@ export async function saveNewIdentity(
await platformService.updateDefaultSettings({ activeDid: identity.did });
await platformService.insertDidSpecificSettings(identity.did);
await platformService.insertNewDidIntoSettings(identity.did);
} catch (error) {
logger.error("Failed to update default settings:", error);
throw new Error(
@@ -954,7 +955,7 @@ export async function importFromMnemonic(
try {
// First, ensure the DID-specific settings record exists
await platformService.insertDidSpecificSettings(newId.did);
await platformService.insertNewDidIntoSettings(newId.did);
// Then update with Test User #0 specific settings
await platformService.updateDidSpecificSettings(newId.did, {

View File

@@ -35,6 +35,7 @@ import { handleApiError } from "./services/api";
import { AxiosError } from "axios";
import { DeepLinkHandler } from "./services/deepLinks";
import { logger, safeStringify } from "./utils/logger";
import "./utils/safeAreaInset";
logger.log("[Capacitor] 🚀 Starting initialization");
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);

View File

@@ -0,0 +1,185 @@
import { Capacitor } from "@capacitor/core";
import { Clipboard } from "@capacitor/clipboard";
import { useClipboard } from "@vueuse/core";
import { logger } from "@/utils/logger";
/**
* Platform-agnostic clipboard service that handles both web and native platforms
* Provides reliable clipboard functionality across all platforms including iOS
*/
export class ClipboardService {
private static instance: ClipboardService | null = null;
/**
* Get singleton instance of ClipboardService
*/
public static getInstance(): ClipboardService {
if (!ClipboardService.instance) {
ClipboardService.instance = new ClipboardService();
}
return ClipboardService.instance;
}
/**
* Copy text to clipboard with platform-specific handling
*
* @param text - The text to copy to clipboard
* @returns Promise that resolves when copy is complete
* @throws Error if copy operation fails
*/
public async copyToClipboard(text: string): Promise<void> {
const platform = Capacitor.getPlatform();
const isNative = Capacitor.isNativePlatform();
logger.debug("[ClipboardService] Copying to clipboard:", {
text: text.substring(0, 50) + (text.length > 50 ? "..." : ""),
platform,
isNative,
timestamp: new Date().toISOString(),
});
try {
if (isNative && (platform === "ios" || platform === "android")) {
// Use native Capacitor clipboard for mobile platforms
await this.copyNative(text);
} else {
// Use web clipboard API for web/desktop platforms
await this.copyWeb(text);
}
logger.debug("[ClipboardService] Copy successful", {
platform,
timestamp: new Date().toISOString(),
});
} catch (error) {
logger.error("[ClipboardService] Copy failed:", {
error: error instanceof Error ? error.message : String(error),
platform,
timestamp: new Date().toISOString(),
});
throw error;
}
}
/**
* Copy text using native Capacitor clipboard API
*
* @param text - The text to copy
* @returns Promise that resolves when copy is complete
*/
private async copyNative(text: string): Promise<void> {
try {
await Clipboard.write({
string: text,
});
} catch (error) {
logger.error("[ClipboardService] Native copy failed:", {
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString(),
});
throw new Error(
`Native clipboard copy failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Copy text using web clipboard API with fallback
*
* @param text - The text to copy
* @returns Promise that resolves when copy is complete
*/
private async copyWeb(text: string): Promise<void> {
try {
// Try VueUse clipboard first (handles some edge cases)
const { copy } = useClipboard();
await copy(text);
} catch (error) {
logger.warn(
"[ClipboardService] VueUse clipboard failed, trying native API:",
{
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString(),
},
);
// Fallback to native navigator.clipboard
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
throw new Error("Clipboard API not supported in this browser");
}
}
}
/**
* Read text from clipboard (platform-specific)
*
* @returns Promise that resolves to the clipboard text
* @throws Error if read operation fails
*/
public async readFromClipboard(): Promise<string> {
const platform = Capacitor.getPlatform();
const isNative = Capacitor.isNativePlatform();
try {
if (isNative && (platform === "ios" || platform === "android")) {
// Use native Capacitor clipboard for mobile platforms
const result = await Clipboard.read();
return result.value || "";
} else {
// Use web clipboard API for web/desktop platforms
if (navigator.clipboard && navigator.clipboard.readText) {
return await navigator.clipboard.readText();
} else {
throw new Error("Clipboard read API not supported in this browser");
}
}
} catch (error) {
logger.error("[ClipboardService] Read from clipboard failed:", {
error: error instanceof Error ? error.message : String(error),
platform,
timestamp: new Date().toISOString(),
});
throw error;
}
}
/**
* Check if clipboard is supported on current platform
*
* @returns boolean indicating if clipboard is supported
*/
public isSupported(): boolean {
const platform = Capacitor.getPlatform();
const isNative = Capacitor.isNativePlatform();
if (isNative && (platform === "ios" || platform === "android")) {
return true; // Capacitor clipboard should work on native platforms
}
// Check web clipboard support
return !!(navigator.clipboard && navigator.clipboard.writeText);
}
}
/**
* Convenience function to copy text to clipboard
* Uses the singleton ClipboardService instance
*
* @param text - The text to copy to clipboard
* @returns Promise that resolves when copy is complete
*/
export async function copyToClipboard(text: string): Promise<void> {
return ClipboardService.getInstance().copyToClipboard(text);
}
/**
* Convenience function to read text from clipboard
* Uses the singleton ClipboardService instance
*
* @returns Promise that resolves to the clipboard text
*/
export async function readFromClipboard(): Promise<string> {
return ClipboardService.getInstance().readFromClipboard();
}

View File

@@ -175,11 +175,11 @@ export interface PlatformService {
updateDefaultSettings(settings: Record<string, unknown>): Promise<void>;
/**
* Inserts DID-specific settings into the database.
* Inserts a new DID into the settings table.
* @param did - The DID to associate with the settings
* @returns Promise that resolves when the insertion is complete
*/
insertDidSpecificSettings(did: string): Promise<void>;
insertNewDidIntoSettings(did: string): Promise<void>;
/**
* Updates DID-specific settings in the database.

View File

@@ -74,13 +74,13 @@ export class DeepLinkHandler {
* @throws {DeepLinkError} If validation fails or route is invalid
*/
async handleDeepLink(url: string): Promise<void> {
logger.info(`[DeepLink] 🚀 Starting deeplink processing for URL: ${url}`);
logger.debug(`[DeepLink] 🚀 Starting deeplink processing for URL: ${url}`);
try {
logger.info(`[DeepLink] 📍 Parsing URL: ${url}`);
logger.debug(`[DeepLink] 📍 Parsing URL: ${url}`);
const { path, params, query } = this.parseDeepLink(url);
logger.info(`[DeepLink] ✅ URL parsed successfully:`, {
logger.debug(`[DeepLink] ✅ URL parsed successfully:`, {
path,
params: Object.keys(params),
query: Object.keys(query),
@@ -93,10 +93,10 @@ export class DeepLinkHandler {
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
);
logger.info(`[DeepLink] 🧹 Parameters sanitized:`, sanitizedParams);
logger.debug(`[DeepLink] 🧹 Parameters sanitized:`, sanitizedParams);
await this.validateAndRoute(path, sanitizedParams, query);
logger.info(`[DeepLink] 🎯 Deeplink processing completed successfully`);
logger.debug(`[DeepLink] 🎯 Deeplink processing completed successfully`);
} catch (error) {
logger.error(`[DeepLink] ❌ Deeplink processing failed:`, {
url,
@@ -159,7 +159,7 @@ export class DeepLinkHandler {
logger.debug(`[DeepLink] 🔗 Query parameters extracted:`, query);
}
logger.info(`[DeepLink] ✅ Parse completed:`, {
logger.debug(`[DeepLink] ✅ Parse completed:`, {
routePath,
pathParams: pathParams.length,
queryParams: Object.keys(query).length,
@@ -186,7 +186,7 @@ export class DeepLinkHandler {
params: Record<string, string>,
query: Record<string, string>,
): Promise<void> {
logger.info(
logger.debug(
`[DeepLink] 🎯 Starting validation and routing for path: ${path}`,
);
@@ -197,11 +197,11 @@ export class DeepLinkHandler {
logger.debug(`[DeepLink] 🔍 Validating route path: ${path}`);
// Validate route exists
const validRoute = routeSchema.parse(path) as DeepLinkRoute;
logger.info(`[DeepLink] ✅ Route validation passed: ${validRoute}`);
logger.debug(`[DeepLink] ✅ Route validation passed: ${validRoute}`);
// Get route configuration
const routeConfig = ROUTE_MAP[validRoute];
logger.info(`[DeepLink] 📋 Route config retrieved:`, routeConfig);
logger.debug(`[DeepLink] 📋 Route config retrieved:`, routeConfig);
if (!routeConfig) {
logger.error(`[DeepLink] ❌ No route config found for: ${validRoute}`);
@@ -209,7 +209,7 @@ export class DeepLinkHandler {
}
routeName = routeConfig.name;
logger.info(`[DeepLink] 🎯 Route name resolved: ${routeName}`);
logger.debug(`[DeepLink] 🎯 Route name resolved: ${routeName}`);
} catch (error) {
logger.error(`[DeepLink] ❌ Route validation failed:`, {
path,
@@ -228,14 +228,14 @@ export class DeepLinkHandler {
},
});
logger.info(
logger.debug(
`[DeepLink] 🔄 Redirected to error page for invalid route: ${path}`,
);
return;
}
// Continue with parameter validation
logger.info(
logger.debug(
`[DeepLink] 🔍 Starting parameter validation for route: ${routeName}`,
);
@@ -258,7 +258,7 @@ export class DeepLinkHandler {
if (pathSchema) {
logger.debug(`[DeepLink] 🔍 Validating path parameters:`, params);
validatedPathParams = await pathSchema.parseAsync(params);
logger.info(
logger.debug(
`[DeepLink] ✅ Path parameters validated:`,
validatedPathParams,
);
@@ -270,7 +270,7 @@ export class DeepLinkHandler {
if (querySchema) {
logger.debug(`[DeepLink] 🔍 Validating query parameters:`, query);
validatedQueryParams = await querySchema.parseAsync(query);
logger.info(
logger.debug(
`[DeepLink] ✅ Query parameters validated:`,
validatedQueryParams,
);
@@ -299,7 +299,7 @@ export class DeepLinkHandler {
},
});
logger.info(
logger.debug(
`[DeepLink] 🔄 Redirected to error page for invalid parameters`,
);
return;
@@ -307,7 +307,7 @@ export class DeepLinkHandler {
// Attempt navigation
try {
logger.info(`[DeepLink] 🚀 Attempting navigation:`, {
logger.debug(`[DeepLink] 🚀 Attempting navigation:`, {
routeName,
pathParams: validatedPathParams,
queryParams: validatedQueryParams,
@@ -319,7 +319,7 @@ export class DeepLinkHandler {
query: validatedQueryParams,
});
logger.info(`[DeepLink] ✅ Navigation successful to: ${routeName}`);
logger.debug(`[DeepLink] ✅ Navigation successful to: ${routeName}`);
} catch (error) {
logger.error(`[DeepLink] ❌ Navigation failed:`, {
routeName,
@@ -342,7 +342,7 @@ export class DeepLinkHandler {
},
});
logger.info(
logger.debug(
`[DeepLink] 🔄 Redirected to error page for navigation failure`,
);
}

View File

@@ -1319,7 +1319,7 @@ export class CapacitorPlatformService implements PlatformService {
await this.dbExec(sql, params);
}
async insertDidSpecificSettings(did: string): Promise<void> {
async insertNewDidIntoSettings(did: string): Promise<void> {
await this.dbExec("INSERT INTO settings (accountDid) VALUES (?)", [did]);
}

View File

@@ -681,7 +681,7 @@ export class WebPlatformService implements PlatformService {
await this.dbExec(sql, params);
}
async insertDidSpecificSettings(did: string): Promise<void> {
async insertNewDidIntoSettings(did: string): Promise<void> {
await this.dbExec("INSERT INTO settings (accountDid) VALUES (?)", [did]);
}

View File

@@ -50,6 +50,10 @@ export async function testServerRegisterUser() {
"@/db/databaseUtil"
);
const settings = await retrieveSettingsForActiveAccount();
const currentDid = settings?.activeDid;
if (!currentDid) {
throw new Error("No active DID found");
}
// Make a claim
const vcClaim = {
@@ -57,7 +61,7 @@ export async function testServerRegisterUser() {
"@type": "RegisterAction",
agent: { identifier: identity0.did },
object: SERVICE_ID,
participant: { identifier: settings.activeDid },
participant: { identifier: currentDid },
};
// Make a payload for the claim
@@ -94,5 +98,12 @@ export async function testServerRegisterUser() {
const resp = await axios.post(url, payload, { headers });
logger.log("User registration result:", resp);
const platformService = await PlatformServiceFactory.getInstance();
await platformService.updateDefaultSettings({ activeDid: currentDid });
await platformService.updateDidSpecificSettings(currentDid!, {
isRegistered: true,
});
return resp;
}

226
src/utils/safeAreaInset.js Normal file
View File

@@ -0,0 +1,226 @@
/**
* Safe Area Inset Injection for Android WebView
*
* This script injects safe area inset values into CSS environment variables
* when running in Android WebView, since Android doesn't natively support
* CSS env(safe-area-inset-*) variables like iOS does.
*/
// Check if we're running in Android WebView with Capacitor
const isAndroidWebView = () => {
// Check if we're on iOS - if so, skip this script entirely
const isIOS =
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
if (isIOS) {
return false;
}
// Check if we're on Android
const isAndroid = /Android/.test(navigator.userAgent);
// Check if we have Capacitor (required for Android WebView)
const hasCapacitor = window.Capacitor !== undefined;
// Only run on Android with Capacitor
return isAndroid && hasCapacitor;
};
// Wait for Capacitor to be available
const waitForCapacitor = () => {
return new Promise((resolve) => {
if (window.Capacitor) {
resolve(window.Capacitor);
return;
}
// Wait for Capacitor to be available
const checkCapacitor = () => {
if (window.Capacitor) {
resolve(window.Capacitor);
} else {
setTimeout(checkCapacitor, 100);
}
};
checkCapacitor();
});
};
// Inject safe area inset values into CSS custom properties
const injectSafeAreaInsets = async () => {
try {
// Wait for Capacitor to be available
const Capacitor = await waitForCapacitor();
// Try to get safe area insets using StatusBar plugin (which is already available)
let top = 0,
bottom = 0,
left = 0,
right = 0;
try {
// Use StatusBar plugin to get status bar height
if (Capacitor.Plugins.StatusBar) {
const statusBarInfo = await Capacitor.Plugins.StatusBar.getInfo();
// Status bar height is typically the top safe area inset
top = statusBarInfo.overlays ? 0 : statusBarInfo.height || 0;
}
} catch (error) {
// Status bar info not available, will use fallback
}
// Detect navigation bar and gesture bar heights
const detectNavigationBar = () => {
const screenHeight = window.screen.height;
const screenWidth = window.screen.width;
const windowHeight = window.innerHeight;
const devicePixelRatio = window.devicePixelRatio || 1;
// Calculate navigation bar height
let navBarHeight = 0;
// Method 1: Direct comparison (most reliable)
if (windowHeight < screenHeight) {
navBarHeight = screenHeight - windowHeight;
}
// Method 2: Check for gesture navigation indicators
if (navBarHeight === 0) {
// Look for common gesture navigation patterns
const isTallDevice = screenHeight > 2000;
const isModernDevice = screenHeight > 1800;
const hasHighDensity = devicePixelRatio >= 2.5;
if (isTallDevice && hasHighDensity) {
// Modern gesture-based device
navBarHeight = 12; // Typical gesture bar height
} else if (isModernDevice) {
// Modern device with traditional navigation
navBarHeight = 48; // Traditional navigation bar height
}
}
// Method 3: Check visual viewport (more accurate for WebView)
if (navBarHeight === 0) {
if (window.visualViewport) {
const visualHeight = window.visualViewport.height;
if (visualHeight < windowHeight) {
navBarHeight = windowHeight - visualHeight;
}
}
}
// Method 4: Device-specific estimation based on screen dimensions
if (navBarHeight === 0) {
// Common Android navigation bar heights in pixels
const commonNavBarHeights = {
"1080x2400": 48, // Common 1080p devices
"1440x3200": 64, // QHD devices
"720x1600": 32, // HD devices
};
const resolution = `${screenWidth}x${screenHeight}`;
const estimatedHeight = commonNavBarHeights[resolution];
if (estimatedHeight) {
navBarHeight = estimatedHeight;
} else {
// Fallback: estimate based on screen height
navBarHeight = screenHeight > 2000 ? 48 : 32;
}
}
return navBarHeight;
};
// Get navigation bar height
bottom = detectNavigationBar();
// If we still don't have a top value, estimate it
if (top === 0) {
const screenHeight = window.screen.height;
// Common status bar heights: 24dp (48px) for most devices, 32dp (64px) for some
top = screenHeight > 1920 ? 64 : 48;
}
// Left/right safe areas are rare on Android
left = 0;
right = 0;
// Create CSS custom properties
const style = document.createElement("style");
style.textContent = `
:root {
--safe-area-inset-top: ${top}px;
--safe-area-inset-bottom: ${bottom}px;
--safe-area-inset-left: ${left}px;
--safe-area-inset-right: ${right}px;
}
`;
// Inject the style into the document head
document.head.appendChild(style);
// Also set CSS environment variables if supported
if (CSS.supports("env(safe-area-inset-top)")) {
document.documentElement.style.setProperty(
"--env-safe-area-inset-top",
`${top}px`,
);
document.documentElement.style.setProperty(
"--env-safe-area-inset-bottom",
`${bottom}px`,
);
document.documentElement.style.setProperty(
"--env-safe-area-inset-left",
`${left}px`,
);
document.documentElement.style.setProperty(
"--env-safe-area-inset-right",
`${right}px`,
);
}
} catch (error) {
// Error injecting safe area insets, will use fallback values
}
};
// Initialize when DOM is ready
const initializeSafeArea = () => {
// Check if we should run this script at all
if (!isAndroidWebView()) {
return;
}
// Add a small delay to ensure WebView is fully initialized
setTimeout(() => {
injectSafeAreaInsets();
}, 100);
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeSafeArea);
} else {
initializeSafeArea();
}
// Re-inject on orientation change (only on Android)
window.addEventListener("orientationchange", () => {
if (isAndroidWebView()) {
setTimeout(() => injectSafeAreaInsets(), 100);
}
});
// Re-inject on resize (only on Android)
window.addEventListener("resize", () => {
if (isAndroidWebView()) {
setTimeout(() => injectSafeAreaInsets(), 100);
}
});
// Export for use in other modules
export { injectSafeAreaInsets, isAndroidWebView };

View File

@@ -55,9 +55,11 @@
<!-- Registration notice -->
<RegistrationNotice
:is-registered="isRegistered"
:show="showRegistrationNotice"
@share-info="onShareInfo"
v-if="!isRegistered"
:passkeys-enabled="PASSKEYS_ENABLED"
:given-name="givenName"
message="Before you can publicly announce a new project or time commitment,
a friend needs to register you."
/>
<!-- Notifications -->
@@ -781,6 +783,7 @@ import {
DEFAULT_PUSH_SERVER,
IMAGE_TYPE_PROFILE,
NotificationIface,
PASSKEYS_ENABLED,
} from "../constants/app";
import { Contact } from "../db/tables/contacts";
import {
@@ -851,6 +854,7 @@ export default class AccountViewView extends Vue {
readonly DEFAULT_PUSH_SERVER: string = DEFAULT_PUSH_SERVER;
readonly DEFAULT_IMAGE_API_SERVER: string = DEFAULT_IMAGE_API_SERVER;
readonly DEFAULT_PARTNER_API_SERVER: string = DEFAULT_PARTNER_API_SERVER;
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
// Identity and settings properties
activeDid: string = "";
@@ -1789,20 +1793,6 @@ export default class AccountViewView extends Vue {
this.doCopyTwoSecRedo(did, () => (this.showDidCopy = !this.showDidCopy));
}
get showRegistrationNotice(): boolean {
// Show the notice if not registered and any other conditions you want
return !this.isRegistered;
}
onShareInfo() {
// Navigate to QR code sharing page - mobile uses full scan, web uses basic
if (Capacitor.isNativePlatform()) {
this.$router.push({ name: "contact-qr-scan-full" });
} else {
this.$router.push({ name: "contact-qr" });
}
}
onRecheckLimits() {
this.checkLimits();
}

View File

@@ -17,20 +17,40 @@
<!-- Results List -->
<ul class="border-t border-slate-300">
<li class="border-b border-slate-300 py-3">
<!-- "You" entity -->
<li v-if="shouldShowYouEntity" class="border-b border-slate-300 py-3">
<h2 class="text-base flex gap-4 items-center">
<span class="grow flex gap-2 items-center font-medium">
<font-awesome
icon="circle-question"
class="text-slate-400 text-4xl"
/>
<span class="italic text-slate-400">(Unnamed/Unknown)</span>
<font-awesome icon="hand" class="text-blue-500 text-4xl shrink-0" />
<span class="text-ellipsis overflow-hidden text-blue-500">You</span>
</span>
<span class="text-right">
<button
type="button"
class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
@click="openDialog('Unnamed')"
@click="openDialog({ did: activeDid, name: 'You' })"
>
<font-awesome icon="gift" class="fa-fw"></font-awesome>
</button>
</span>
</h2>
</li>
<li class="border-b border-slate-300 py-3">
<h2 class="text-base flex gap-4 items-center">
<span class="grow flex gap-2 items-center font-medium">
<font-awesome
icon="circle-question"
class="text-slate-400 text-4xl shrink-0"
/>
<span class="text-ellipsis overflow-hidden italic text-slate-500">{{
unnamedEntityName
}}</span>
</span>
<span class="text-right">
<button
type="button"
class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
@click="openDialog({ did: '', name: unnamedEntityName })"
>
<font-awesome icon="gift" class="fa-fw"></font-awesome>
</button>
@@ -43,14 +63,22 @@
class="border-b border-slate-300 py-3"
>
<h2 class="text-base flex gap-4 items-center">
<span class="grow flex gap-2 items-center font-medium">
<span
class="grow flex gap-2 items-center font-medium overflow-hidden"
>
<EntityIcon
:contact="contact"
:icon-size="34"
class="inline-block align-middle border border-slate-300 rounded-full overflow-hidden"
class="inline-block align-middle border border-slate-300 rounded-full overflow-hidden shrink-0"
/>
<span v-if="contact.name">{{ contact.name }}</span>
<span v-else class="italic text-slate-400">(No name)</span>
<span v-if="contact.name" class="text-ellipsis overflow-hidden">{{
contact.name
}}</span>
<span
v-else
class="text-ellipsis overflow-hidden italic text-slate-500"
>{{ contact.did }}</span
>
</span>
<span class="text-right">
<button
@@ -72,6 +100,7 @@
:from-project-id="fromProjectId"
:to-project-id="toProjectId"
:is-from-project-view="isFromProjectView"
:hide-show-all="true"
/>
</section>
</template>
@@ -89,6 +118,7 @@ import { GiverReceiverInputInfo } from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
@Component({
components: { GiftedDialog, QuickNav, EntityIcon },
mixins: [PlatformServiceMixin],
@@ -188,147 +218,151 @@ export default class ContactGiftingView extends Vue {
}
}
openDialog(contact?: GiverReceiverInputInfo | "Unnamed") {
if (contact === "Unnamed") {
// Special case: Handle "Unnamed" contacts for both givers and recipients
let recipient: GiverReceiverInputInfo;
let giver: GiverReceiverInputInfo | undefined;
openDialog(contact?: GiverReceiverInputInfo) {
// Determine the selected entity based on contact type
const selectedEntity = this.createEntityFromContact(contact);
if (this.stepType === "giver") {
// We're selecting a giver, so preserve the existing recipient from context
if (this.recipientEntityType === "project") {
recipient = {
did: this.recipientProjectHandleId,
name: this.recipientProjectName,
image: this.recipientProjectImage,
handleId: this.recipientProjectHandleId,
};
} else {
// Preserve the existing recipient from context
if (this.recipientDid === this.activeDid) {
// Recipient was "You"
recipient = { did: this.activeDid, name: "You" };
} else if (this.recipientDid) {
// Recipient was a regular contact
recipient = {
did: this.recipientDid,
name: this.recipientProjectName || "Someone",
};
} else {
// Fallback to "You" if no recipient was previously selected
recipient = { did: this.activeDid, name: "You" };
}
}
giver = undefined; // Will be set to "Unnamed" in GiftedDialog
} else {
// We're selecting a recipient, so recipient is "Unnamed" and giver is preserved from context
recipient = { did: "", name: "Unnamed" };
// Create giver and recipient based on step type and selected entity
const { giver, recipient } = this.createGiverAndRecipient(selectedEntity);
// Preserve the existing giver from the context
if (this.giverEntityType === "project") {
giver = {
did: this.giverProjectHandleId,
name: this.giverProjectName,
image: this.giverProjectImage,
handleId: this.giverProjectHandleId,
};
} else if (this.giverDid) {
giver = {
did: this.giverDid,
name: this.giverProjectName || "Someone",
};
} else {
giver = { did: this.activeDid, name: "You" };
}
}
// Open the dialog
(this.$refs.giftedDialog as GiftedDialog).open(
giver,
recipient,
this.offerId,
this.prompt,
this.description,
this.amountInput,
this.unitCode,
);
(this.$refs.giftedDialog as GiftedDialog).open(
giver,
recipient,
this.offerId,
this.prompt,
this.description,
this.amountInput,
this.unitCode,
);
// Move to Step 2 - entities are already set by the open() call
(this.$refs.giftedDialog as GiftedDialog).moveToStep2();
}
// Move to Step 2 - entities are already set by the open() call
(this.$refs.giftedDialog as GiftedDialog).moveToStep2();
/**
* Creates an entity object from the contact parameter
* Uses DID-based logic to determine "You" and "Unnamed" entities
*/
private createEntityFromContact(
contact?: GiverReceiverInputInfo,
): GiverReceiverInputInfo | undefined {
if (!contact) {
return undefined;
}
// Handle GiverReceiverInputInfo object
if (contact.did === this.activeDid) {
// If DID matches active DID, create "You" entity
return { did: this.activeDid, name: "You" };
} else if (!contact.did || contact.did === "") {
// If DID is empty/null, create "Unnamed" entity
return { did: "", name: UNNAMED_ENTITY_NAME };
} else {
// Regular case: contact is a GiverReceiverInputInfo
let giver: GiverReceiverInputInfo;
let recipient: GiverReceiverInputInfo;
// Create a copy of the contact to avoid modifying the original
return { ...contact };
}
}
if (this.stepType === "giver") {
// We're selecting a giver, so the contact becomes the giver
giver = contact as GiverReceiverInputInfo; // Safe because we know contact is not "Unnamed" or undefined
/**
* Creates giver and recipient objects based on step type and selected entity
*/
private createGiverAndRecipient(selectedEntity?: GiverReceiverInputInfo): {
giver: GiverReceiverInputInfo | undefined;
recipient: GiverReceiverInputInfo;
} {
if (this.stepType === "giver") {
// We're selecting a giver, so the selected entity becomes the giver
const giver = selectedEntity;
const recipient = this.createRecipientFromContext();
return { giver, recipient };
} else {
// We're selecting a recipient, so the selected entity becomes the recipient
const recipient = selectedEntity || {
did: "",
name: UNNAMED_ENTITY_NAME,
};
const giver = this.createGiverFromContext();
return { giver, recipient };
}
}
// Preserve the existing recipient from the context
if (this.recipientEntityType === "project") {
recipient = {
did: this.recipientProjectHandleId,
name: this.recipientProjectName,
image: this.recipientProjectImage,
handleId: this.recipientProjectHandleId,
};
} else {
// Check if the preserved recipient was "You" or a regular contact
if (this.recipientDid === this.activeDid) {
// Recipient was "You"
recipient = { did: this.activeDid, name: "You" };
} else if (this.recipientDid) {
// Recipient was a regular contact
recipient = {
did: this.recipientDid,
name: this.recipientProjectName || "Someone",
};
} else {
// Fallback to "Unnamed"
recipient = { did: "", name: "Unnamed" };
}
}
/**
* Creates recipient object from context (preserves existing recipient)
*/
private createRecipientFromContext(): GiverReceiverInputInfo {
if (this.recipientEntityType === "project") {
return {
name: this.recipientProjectName,
image: this.recipientProjectImage,
handleId: this.recipientProjectHandleId,
};
} else {
if (this.recipientDid === this.activeDid) {
return { did: this.activeDid, name: "You" };
} else if (this.recipientDid) {
return {
did: this.recipientDid,
name: this.recipientProjectName,
};
} else {
// We're selecting a recipient, so the contact becomes the recipient
recipient = contact as GiverReceiverInputInfo; // Safe because we know contact is not "Unnamed" or undefined
// Preserve the existing giver from the context
if (this.giverEntityType === "project") {
giver = {
did: this.giverProjectHandleId,
name: this.giverProjectName,
image: this.giverProjectImage,
handleId: this.giverProjectHandleId,
};
} else {
// Check if the preserved giver was "You" or a regular contact
if (this.giverDid === this.activeDid) {
// Giver was "You"
giver = { did: this.activeDid, name: "You" };
} else if (this.giverDid) {
// Giver was a regular contact
giver = {
did: this.giverDid,
name: this.giverProjectName || "Someone",
};
} else {
// Fallback to "Unnamed"
giver = { did: "", name: "Unnamed" };
}
}
return { did: "", name: UNNAMED_ENTITY_NAME };
}
}
}
(this.$refs.giftedDialog as GiftedDialog).open(
giver,
recipient,
this.offerId,
this.prompt,
this.description,
this.amountInput,
this.unitCode,
);
/**
* Creates giver object from context (preserves existing giver)
*/
private createGiverFromContext(): GiverReceiverInputInfo {
if (this.giverEntityType === "project") {
return {
name: this.giverProjectName,
image: this.giverProjectImage,
handleId: this.giverProjectHandleId,
};
} else {
if (this.giverDid === this.activeDid) {
return { did: this.activeDid, name: "You" };
} else if (this.giverDid) {
return {
did: this.giverDid,
name: this.giverProjectName,
};
} else {
return { did: "", name: UNNAMED_ENTITY_NAME };
}
}
}
// Move to Step 2 - entities are already set by the open() call
(this.$refs.giftedDialog as GiftedDialog).moveToStep2();
/**
* Get the unnamed entity name constant
*/
get unnamedEntityName(): string {
return UNNAMED_ENTITY_NAME;
}
get shouldShowYouEntity(): boolean {
if (this.stepType === "giver") {
// When selecting a giver, show "You" if the current recipient is not "You"
// This prevents selecting yourself as both giver and recipient
if (this.recipientEntityType === "project") {
// If recipient is a project, we can select "You" as giver
return true;
} else {
// If recipient is a person, check if it's not "You"
return this.recipientDid !== this.activeDid;
}
} else {
// When selecting a recipient, show "You" if the current giver is not "You"
// This prevents selecting yourself as both giver and recipient
if (this.giverEntityType === "project") {
// If giver is a project, we can select "You" as recipient
return true;
} else {
// If giver is a person, check if it's not "You"
return this.giverDid !== this.activeDid;
}
}
}
}

View File

@@ -220,21 +220,21 @@ export default class ContactQRScanFull extends Vue {
* Computed property for QR code container CSS classes
*/
get qrContainerClasses(): string {
return "block w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto mt-4";
return "block w-full max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto mt-4";
}
/**
* Computed property for camera frame CSS classes
*/
get cameraFrameClasses(): string {
return "relative w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto border border-dashed border-white mt-8 aspect-square";
return "relative w-full max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto border border-dashed border-white mt-8 aspect-square";
}
/**
* Computed property for main content container CSS classes
*/
get mainContentClasses(): string {
return "p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto";
return "p-6 bg-white w-full max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto";
}
/**

View File

@@ -140,7 +140,7 @@ import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import { QrcodeStream } from "vue-qrcode-reader";
import QuickNav from "../components/QuickNav.vue";
@@ -183,8 +183,6 @@ import {
NOTIFY_QR_PROCESSING_ERROR,
createQRContactAddedMessage,
createQRRegistrationSuccessMessage,
QR_TIMEOUT_SHORT,
QR_TIMEOUT_MEDIUM,
QR_TIMEOUT_STANDARD,
QR_TIMEOUT_LONG,
} from "@/constants/notifications";
@@ -259,11 +257,11 @@ export default class ContactQRScanShow extends Vue {
}
get qrCodeContainerClasses(): string {
return "block w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto my-4";
return "block w-[90vw] max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto my-4";
}
get scannerContainerClasses(): string {
return "relative aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto";
return "relative aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto";
}
get statusMessageClasses(): string {
@@ -544,11 +542,7 @@ export default class ContactQRScanShow extends Vue {
did: contact.did,
name: contact.name,
});
this.notify.toast(
"Submitted",
NOTIFY_QR_REGISTRATION_SUBMITTED.message,
QR_TIMEOUT_SHORT,
);
this.notify.toast("Submitted", NOTIFY_QR_REGISTRATION_SUBMITTED.message);
try {
const regResult = await register(
@@ -624,18 +618,15 @@ export default class ContactQRScanShow extends Vue {
);
// Copy the URL to clipboard
useClipboard()
.copy(jwtUrl)
.then(() => {
this.notify.toast(
"Copied",
NOTIFY_QR_URL_COPIED.message,
QR_TIMEOUT_MEDIUM,
);
});
const { copyToClipboard } = await import("../services/ClipboardService");
await copyToClipboard(jwtUrl);
this.notify.toast(
NOTIFY_QR_URL_COPIED.title,
NOTIFY_QR_URL_COPIED.message,
);
} catch (error) {
logger.error("Failed to generate contact URL:", error);
this.notify.error("Failed to generate contact URL. Please try again.");
this.$logAndConsole(`Error copying URL to clipboard: ${error}`, true);
this.notify.error("Failed to copy URL to clipboard.");
}
}
@@ -643,13 +634,16 @@ export default class ContactQRScanShow extends Vue {
this.notify.info(NOTIFY_QR_CODE_HELP.message, QR_TIMEOUT_LONG);
}
onCopyDidToClipboard() {
async onCopyDidToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
useClipboard()
.copy(this.activeDid)
.then(() => {
this.notify.info(NOTIFY_QR_DID_COPIED.message, QR_TIMEOUT_LONG);
});
try {
const { copyToClipboard } = await import("../services/ClipboardService");
await copyToClipboard(this.activeDid);
this.notify.info(NOTIFY_QR_DID_COPIED.message, QR_TIMEOUT_LONG);
} catch (error) {
this.$logAndConsole(`Error copying DID to clipboard: ${error}`, true);
this.notify.error("Failed to copy DID to clipboard.");
}
}
openUserNameDialog() {
@@ -745,7 +739,6 @@ export default class ContactQRScanShow extends Vue {
) {
setTimeout(() => {
this.notify.confirm(
"Register",
"Do you want to register them?",
{
onCancel: async (stopAsking?: boolean) => {

View File

@@ -130,10 +130,9 @@ import { JWTPayload } from "did-jwt";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
// Capacitor import removed - using PlatformService instead
import QuickNav from "../components/QuickNav.vue";
import { copyToClipboard } from "../services/ClipboardService";
import EntityIcon from "../components/EntityIcon.vue";
import GiftedDialog from "../components/GiftedDialog.vue";
import OfferDialog from "../components/OfferDialog.vue";
@@ -1192,12 +1191,14 @@ export default class ContactsView extends Vue {
});
// Use production URL for sharing to avoid localhost issues in development
const contactsJwtUrl = `${APP_SERVER}/deep-link/contact-import/${contactsJwt}`;
useClipboard()
.copy(contactsJwtUrl)
.then(() => {
// Use notification helper
this.notify.copied(NOTIFY_CONTACT_LINK_COPIED.message);
});
try {
await copyToClipboard(contactsJwtUrl);
// Use notification helper
this.notify.copied(NOTIFY_CONTACT_LINK_COPIED.message);
} catch (error) {
this.$logAndConsole(`Error copying to clipboard: ${error}`, true);
this.notify.error("Failed to copy to clipboard. Please try again.");
}
}
private showCopySelectionsInfo() {

View File

@@ -292,6 +292,7 @@ import {
NOTIFY_NO_IDENTITY_ERROR,
NOTIFY_CONTACT_INVALID_DID,
} from "@/constants/notifications";
import { THAT_UNNAMED_PERSON } from "@/constants/entities";
/**
* DIDView Component
@@ -560,7 +561,7 @@ export default class DIDView extends Vue {
contact.registered = true;
await this.$updateContact(contact.did, { registered: true });
const name = contact.name || "That unnamed person";
const name = contact.name || THAT_UNNAMED_PERSON;
this.notify.success(
`${name} ${NOTIFY_REGISTRATION_SUCCESS.message}`,
TIMEOUTS.LONG,
@@ -840,26 +841,3 @@ export default class DIDView extends Vue {
}
}
</script>
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="deep-link-error">
<div class="safe-area-spacer"></div>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<h1>Invalid Deep Link</h1>
<div class="error-details">
<div class="error-message">
@@ -39,7 +39,7 @@
</li>
</ul>
</div>
</div>
</section>
</template>
<script setup lang="ts">
@@ -114,18 +114,6 @@ onMounted(() => {
</script>
<style scoped>
.deep-link-error {
padding-top: 60px;
padding-left: 20px;
padding-right: 20px;
max-width: 600px;
margin: 0 auto;
}
.safe-area-spacer {
height: env(safe-area-inset-top);
}
h1 {
color: #ff4444;
margin-bottom: 24px;

View File

@@ -1,95 +1,87 @@
<template>
<!-- CONTENT -->
<section id="Content" class="relative w-[100vw] h-[100vh]">
<div
class="p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
>
<div class="mb-4">
<h1 class="text-xl text-center font-semibold relative mb-4">
Redirecting to Time Safari
</h1>
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<div class="mb-4">
<h1 class="text-2xl text-center font-semibold relative px-7">
Redirecting to Time Safari
</h1>
<div v-if="destinationUrl" class="space-y-4">
<!-- Platform-specific messaging -->
<div class="text-center text-gray-600 mb-4">
<p v-if="isMobile">
{{
isIOS
? "Opening Time Safari app on your iPhone..."
: "Opening Time Safari app on your Android device..."
}}
</p>
<p v-else>Opening Time Safari app...</p>
<p class="text-sm mt-2">
<span v-if="isMobile"
>If the app doesn't open automatically, use one of these
options:</span
>
<span v-else>Choose how you'd like to open this link:</span>
</p>
</div>
<!-- Deep Link Button -->
<div class="text-center">
<a
:href="deepLinkUrl || '#'"
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
@click="handleDeepLinkClick"
<div v-if="destinationUrl" class="space-y-4">
<!-- Platform-specific messaging -->
<div class="text-center text-gray-600 mb-4">
<p v-if="isMobile">
{{
isIOS
? "Opening Time Safari app on your iPhone..."
: "Opening Time Safari app on your Android device..."
}}
</p>
<p v-else>Opening Time Safari app...</p>
<p class="text-sm mt-2">
<span v-if="isMobile"
>If the app doesn't open automatically, use one of these
options:</span
>
<span v-if="isMobile">Open in Time Safari App</span>
<span v-else>Try Opening in Time Safari App</span>
</a>
</div>
<span v-else>Choose how you'd like to open this link:</span>
</p>
</div>
<!-- Web Fallback Link -->
<div class="text-center">
<a
:href="webUrl || '#'"
target="_blank"
class="inline-block bg-gray-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-gray-700 transition-colors"
@click="handleWebFallbackClick"
>
<span v-if="isMobile">Open in Web Browser Instead</span>
<span v-else>Open in Web Browser</span>
</a>
</div>
<!-- Manual Instructions -->
<div class="text-center text-sm text-gray-500 mt-4">
<p v-if="isMobile">
Or manually open:
<code class="bg-gray-100 px-2 py-1 rounded">{{
deepLinkUrl
}}</code>
</p>
<p v-else>
If you have the Time Safari app installed, you can also copy this
link:
<code class="bg-gray-100 px-2 py-1 rounded">{{
deepLinkUrl
}}</code>
</p>
</div>
<!-- Platform info for debugging -->
<div
v-if="isDevelopment"
class="text-center text-xs text-gray-400 mt-4"
<!-- Deep Link Button -->
<div class="text-center">
<a
:href="deepLinkUrl || '#'"
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
@click="handleDeepLinkClick"
>
<p>
Platform: {{ isMobile ? (isIOS ? "iOS" : "Android") : "Desktop" }}
</p>
<p>User Agent: {{ userAgent.substring(0, 50) }}...</p>
</div>
<span v-if="isMobile">Open in Time Safari App</span>
<span v-else>Try Opening in Time Safari App</span>
</a>
</div>
<div v-else-if="pageError" class="text-center text-red-500 mb-4">
{{ pageError }}
<!-- Web Fallback Link -->
<div class="text-center">
<a
:href="webUrl || '#'"
target="_blank"
class="inline-block bg-gray-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-gray-700 transition-colors"
@click="handleWebFallbackClick"
>
<span v-if="isMobile">Open in Web Browser Instead</span>
<span v-else>Open in Web Browser</span>
</a>
</div>
<div v-else class="text-center text-gray-600">
<p>Processing redirect...</p>
<!-- Manual Instructions -->
<div class="text-center text-sm text-gray-500 mt-4">
<p v-if="isMobile">
Or manually open:
<code class="bg-gray-100 px-2 py-1 rounded">{{ deepLinkUrl }}</code>
</p>
<p v-else>
If you have the Time Safari app installed, you can also copy this
link:
<code class="bg-gray-100 px-2 py-1 rounded">{{ deepLinkUrl }}</code>
</p>
</div>
<!-- Platform info for debugging -->
<div
v-if="isDevelopment"
class="text-center text-xs text-gray-400 mt-4"
>
<p>
Platform: {{ isMobile ? (isIOS ? "iOS" : "Android") : "Desktop" }}
</p>
<p>User Agent: {{ userAgent.substring(0, 50) }}...</p>
</div>
</div>
<div v-else-if="pageError" class="text-center text-red-500 mb-4">
{{ pageError }}
</div>
<div v-else class="text-center text-gray-600">
<p>Processing redirect...</p>
</div>
</div>
</section>

View File

@@ -233,7 +233,7 @@
<div class="grow">
<h2 class="text-base font-semibold">
{{ project.name || "Unnamed Project" }}
{{ project.name || unnamedProject }}
</h2>
<div class="text-sm">
<font-awesome
@@ -340,6 +340,7 @@ import {
NOTIFY_DISCOVER_LOCAL_SEARCH_ERROR,
NOTIFY_DISCOVER_MAP_SEARCH_ERROR,
} from "@/constants/notifications";
import { UNNAMED_PROJECT } from "@/constants/entities";
interface Tile {
indexLat: number;
indexLon: number;
@@ -370,6 +371,13 @@ export default class DiscoverView extends Vue {
notify!: ReturnType<typeof createNotifyHelpers>;
/**
* Get the unnamed project constant
*/
get unnamedProject(): string {
return UNNAMED_PROJECT;
}
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];

View File

@@ -18,6 +18,7 @@
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Help
<span class="text-xs text-gray-500">{{ package.version }}</span>
</h1>
</div>
@@ -199,7 +200,7 @@
</p>
<p>
Then you can record your appreciation for... whatever: select any contact on the home page
(or "Unnamed") and send it. The main goal is to record what people
(or "{{ unnamedEntityName }}") and send it. The main goal is to record what people
have given you, to grow giving economies. You can also record your own
ideas for projects. Each claim is recorded on a
custom ledger.
@@ -599,6 +600,7 @@ import QuickNav from "../components/QuickNav.vue";
import { APP_SERVER } from "../constants/app";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { QRNavigationService } from "@/services/QRNavigationService";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
/**
* HelpView.vue - Comprehensive Help System Component
@@ -646,6 +648,13 @@ export default class HelpView extends Vue {
APP_SERVER = APP_SERVER;
// Capacitor reference removed - using QRNavigationService instead
/**
* Get the unnamed entity name constant
*/
get unnamedEntityName(): string {
return UNNAMED_ENTITY_NAME;
}
// Ideally, we put no functionality in here, especially in the setup,
// because we never want this page to have a chance of throwing an error.

View File

@@ -86,33 +86,14 @@ Raymer * @version 1.0.0 */
Identity creation is now handled by router navigation guard.
-->
<div class="mb-4">
<div
<RegistrationNotice
v-if="!isUserRegistered"
id="noticeSomeoneMustRegisterYou"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
To share, someone must register you.
<div class="block text-center">
<button
class="text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
@click="showNameThenIdDialog()"
>
Show them {{ PASSKEYS_ENABLED ? "default" : "your" }} identifier
info
</button>
</div>
<UserNameDialog ref="userNameDialog" />
<div class="flex justify-end w-full">
<router-link
:to="{ name: 'start' }"
class="block text-right text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
See advanced options
</router-link>
</div>
</div>
:passkeys-enabled="PASSKEYS_ENABLED"
:given-name="givenName"
message="To share, someone must register you."
/>
<div v-else id="sectionRecordSomethingGiven">
<div v-if="isUserRegistered" id="sectionRecordSomethingGiven">
<!-- Record Quick-Action -->
<div class="mb-6">
<div class="flex gap-2 items-center mb-2">
@@ -252,8 +233,6 @@ Raymer * @version 1.0.0 */
</div>
</section>
<ChoiceButtonDialog ref="choiceButtonDialog" />
<ImageViewer v-model:is-open="isImageViewerOpen" :image-url="selectedImage" />
</template>
@@ -261,7 +240,6 @@ Raymer * @version 1.0.0 */
import { UAParser } from "ua-parser-js";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { Capacitor } from "@capacitor/core";
//import App from "../App.vue";
import EntityIcon from "../components/EntityIcon.vue";
@@ -272,10 +250,9 @@ import InfiniteScroll from "../components/InfiniteScroll.vue";
import OnboardingDialog from "../components/OnboardingDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
import ImageViewer from "../components/ImageViewer.vue";
import ActivityListItem from "../components/ActivityListItem.vue";
import RegistrationNotice from "../components/RegistrationNotice.vue";
import {
AppString,
NotificationIface,
@@ -305,6 +282,7 @@ import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_CONTACT_LOADING_ISSUE } from "@/constants/notifications";
import * as Package from "../../package.json";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
// consolidate this with GiveActionClaim in src/interfaces/claims.ts
interface Claim {
@@ -383,12 +361,11 @@ interface FeedError {
GiftedPrompts,
InfiniteScroll,
OnboardingDialog,
ChoiceButtonDialog,
QuickNav,
TopMessage,
UserNameDialog,
ImageViewer,
ActivityListItem,
RegistrationNotice,
},
mixins: [PlatformServiceMixin],
})
@@ -1570,30 +1547,41 @@ export default class HomeView extends Vue {
* @param giver Optional contact info for giver
* @param description Optional gift description
*/
openDialog(giver?: GiverReceiverInputInfo | "Unnamed", prompt?: string) {
if (giver === "Unnamed") {
// Special case: Pass undefined to trigger Step 1, but with "Unnamed" pre-selected
(this.$refs.giftedDialog as GiftedDialog).open(
undefined,
{
did: this.activeDid,
name: "You",
} as GiverReceiverInputInfo,
undefined,
prompt,
);
// Immediately select "Unnamed" and move to Step 2
(this.$refs.giftedDialog as GiftedDialog).selectGiver();
openDialog(giver?: GiverReceiverInputInfo, prompt?: string) {
// Determine the giver entity based on DID logic
const giverEntity = this.createGiverEntity(giver);
(this.$refs.giftedDialog as GiftedDialog).open(
giverEntity,
{
did: this.activeDid,
name: "You", // In HomeView, we always use "You" as the giver
} as GiverReceiverInputInfo,
undefined,
prompt,
);
}
/**
* Creates giver entity using DID-based logic
*/
private createGiverEntity(
giver?: GiverReceiverInputInfo,
): GiverReceiverInputInfo | undefined {
if (!giver) {
return undefined;
}
// Handle GiverReceiverInputInfo object
if (giver.did === this.activeDid) {
// If DID matches active DID, create "You" entity
return { did: this.activeDid, name: "You" };
} else if (!giver.did || giver.did === "") {
// If DID is empty/null, create "Unnamed" entity
return { did: "", name: UNNAMED_ENTITY_NAME };
} else {
(this.$refs.giftedDialog as GiftedDialog).open(
giver,
{
did: this.activeDid,
name: "You",
} as GiverReceiverInputInfo,
undefined,
prompt,
);
// Return the giver as-is
return giver;
}
}
@@ -1644,67 +1632,6 @@ export default class HomeView extends Vue {
return known ? "text-slate-500" : "text-slate-100";
}
/**
* Shows name input dialog if needed
*
* @public
* @callGraph
* Called by: Template
* Calls:
* - UserNameDialog.open()
* - promptForShareMethod()
*
* @chain
* Template -> showNameThenIdDialog() -> promptForShareMethod()
*
* @requires
* - this.$refs.userNameDialog
* - this.givenName
*/
showNameThenIdDialog() {
if (!this.givenName) {
(this.$refs.userNameDialog as UserNameDialog).open(() => {
this.promptForShareMethod();
});
} else {
this.promptForShareMethod();
}
}
/**
* Shows dialog for sharing method selection
*
* @internal
* @callGraph
* Called by: showNameThenIdDialog()
* Calls: ChoiceButtonDialog.open()
*
* @chain
* Template -> showNameThenIdDialog() -> promptForShareMethod()
*
* @requires
* - this.$refs.choiceButtonDialog
* - this.$router
*/
promptForShareMethod() {
(this.$refs.choiceButtonDialog as ChoiceButtonDialog).open({
title: "How can you share your info?",
text: "",
option1Text: "We are in a meeting together",
option2Text: "We are nearby with cameras",
option3Text: "We will share some other way",
onOption1: () => {
this.$router.push({ name: "onboard-meeting-list" });
},
onOption2: () => {
this.handleQRCodeClick();
},
onOption3: () => {
this.$router.push({ name: "share-my-contact-info" });
},
});
}
/**
* Opens image viewer dialog
*
@@ -1725,10 +1652,7 @@ export default class HomeView extends Vue {
}
}
openPersonDialog(
giver?: GiverReceiverInputInfo | "Unnamed",
prompt?: string,
) {
openPersonDialog(giver?: GiverReceiverInputInfo, prompt?: string) {
this.showProjectsDialog = false;
this.openDialog(giver, prompt);
}

View File

@@ -183,7 +183,7 @@
class="text-blue-500"
@click="onClickLoadProject(plan.handleId)"
>
{{ plan.name || "Unnamed Project" }}
{{ plan.name || unnamedProject }}
</button>
</div>
<div v-if="fulfillersToHitLimit" class="text-center">
@@ -207,7 +207,7 @@
class="text-blue-500"
@click="onClickLoadProject(fulfilledByThis.handleId)"
>
{{ fulfilledByThis.name || "Unnamed Project" }}
{{ fulfilledByThis.name || unnamedProject }}
</button>
</div>
</div>
@@ -611,6 +611,7 @@ import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_CONFIRM_CLAIM } from "@/constants/notifications";
import { APP_SERVER } from "@/constants/app";
import { UNNAMED_PROJECT } from "@/constants/entities";
/**
* Project View Component
* @author Matthew Raymer
@@ -664,6 +665,13 @@ export default class ProjectViewView extends Vue {
/** Notification helpers instance */
notify!: ReturnType<typeof createNotifyHelpers>;
/**
* Get the unnamed project constant
*/
get unnamedProject(): string {
return UNNAMED_PROJECT;
}
// Account and Settings State
/** Currently active DID */
activeDid = "";

View File

@@ -216,15 +216,12 @@
<font-awesome icon="plus" :class="plusIconClasses" />
button. You'll never know until you try.
</div>
<div v-else>
<button
:class="onboardingButtonClasses"
@click="showNameThenIdDialog()"
>
Get someone to onboard you.
</button>
<UserNameDialog ref="userNameDialog" />
</div>
<RegistrationNotice
v-else
:passkeys-enabled="PASSKEYS_ENABLED"
:given-name="givenName"
message="To announce a project, get someone to onboard you."
/>
</div>
<ul id="listProjects" class="border-t border-slate-300">
<li
@@ -247,7 +244,7 @@
<div class="grow overflow-hidden">
<h2 class="text-base font-semibold">
{{ project.name || "Unnamed Project" }}
{{ project.name || unnamedProject }}
</h2>
<div class="text-sm truncate">
{{ project.description }}
@@ -266,14 +263,14 @@ import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
// Capacitor import removed - using QRNavigationService instead
import { NotificationIface } from "../constants/app";
import { NotificationIface, PASSKEYS_ENABLED } from "../constants/app";
import EntityIcon from "../components/EntityIcon.vue";
import InfiniteScroll from "../components/InfiniteScroll.vue";
import QuickNav from "../components/QuickNav.vue";
import OnboardingDialog from "../components/OnboardingDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import RegistrationNotice from "../components/RegistrationNotice.vue";
import { Contact } from "../db/tables/contacts";
import { didInfo, getHeaders, getPlanFromCache } from "../libs/endorserServer";
import { OfferSummaryRecord, PlanData } from "../interfaces/records";
@@ -281,15 +278,15 @@ import { OnboardPage, iconForUnitCode } from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { QRNavigationService } from "@/services/QRNavigationService";
import {
NOTIFY_NO_ACCOUNT_ERROR,
NOTIFY_PROJECT_LOAD_ERROR,
NOTIFY_PROJECT_INIT_ERROR,
NOTIFY_OFFERS_LOAD_ERROR,
NOTIFY_OFFERS_FETCH_ERROR,
NOTIFY_CAMERA_SHARE_METHOD,
} from "@/constants/notifications";
import { UNNAMED_PROJECT } from "@/constants/entities";
/**
* Projects View Component
@@ -318,7 +315,7 @@ import {
OnboardingDialog,
ProjectIcon,
TopMessage,
UserNameDialog,
RegistrationNotice,
},
mixins: [PlatformServiceMixin],
})
@@ -328,6 +325,13 @@ export default class ProjectsView extends Vue {
notify!: ReturnType<typeof createNotifyHelpers>;
/**
* Get the unnamed project constant
*/
get unnamedProject(): string {
return UNNAMED_PROJECT;
}
// User account state
activeDid = "";
allContacts: Array<Contact> = [];
@@ -336,6 +340,7 @@ export default class ProjectsView extends Vue {
givenName = "";
isLoading = false;
isRegistered = false;
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
// Data collections
offers: OfferSummaryRecord[] = [];
@@ -624,39 +629,6 @@ export default class ProjectsView extends Vue {
* Ensures user has provided their name before proceeding with contact sharing.
* Uses UserNameDialog component if name is not set.
*/
showNameThenIdDialog() {
if (!this.givenName) {
(this.$refs.userNameDialog as UserNameDialog).open(() => {
this.promptForShareMethod();
});
} else {
this.promptForShareMethod();
}
}
/**
* Prompts user to choose contact sharing method
*
* Presents modal dialog asking if users are nearby with cameras.
* Routes to appropriate sharing method based on user's choice:
* - QR code sharing for nearby users with cameras
* - Alternative sharing methods for remote users
*/
promptForShareMethod() {
this.$notify(
{
group: "modal",
type: "confirm",
title: NOTIFY_CAMERA_SHARE_METHOD.title,
text: NOTIFY_CAMERA_SHARE_METHOD.text,
onYes: () => this.handleQRCodeClick(),
onNo: () => this.$router.push({ name: "share-my-contact-info" }),
yesText: NOTIFY_CAMERA_SHARE_METHOD.yesText,
noText: NOTIFY_CAMERA_SHARE_METHOD.noText,
},
TIMEOUTS.MODAL,
);
}
/**
* Computed properties for template logic streamlining
@@ -722,14 +694,6 @@ export default class ProjectsView extends Vue {
return "bg-green-600 text-white px-1.5 py-1 rounded-full";
}
/**
* CSS class names for onboarding button
* @returns String with CSS classes for the onboarding button
*/
get onboardingButtonClasses() {
return "text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md";
}
/**
* CSS class names for project tab styling
* @returns Object with CSS classes based on current tab selection
@@ -754,20 +718,6 @@ export default class ProjectsView extends Vue {
* Utility methods
*/
/**
* Handles QR code sharing functionality with platform detection
*
* Routes to appropriate QR code interface based on current platform:
* - Full QR scanner for native mobile platforms
* - Web-based QR interface for browser environments
*/
private handleQRCodeClick() {
const qrNavigationService = QRNavigationService.getInstance();
const route = qrNavigationService.getQRScannerRoute();
this.$router.push(route);
}
/**
* Legacy method compatibility
* @deprecated Use computedOfferTabClassNames for backward compatibility

View File

@@ -239,7 +239,8 @@ export default class QuickActionBvcEndView extends Vue {
}
const eventStartDateObj = currentOrPreviousSat
.set({ weekday: 6 })
.set({ hour: 9 })
.set({ hour: 8 })
.set({ minute: 30 }) // to catch if people put their claims 30 minutes early
.startOf("hour");
// Hack, but full ISO pushes the length to 340 which crashes verifyJWT!

View File

@@ -144,8 +144,8 @@ export default class ShareMyContactInfoView extends Vue {
* Copy the contact message to clipboard
*/
private async copyToClipboard(message: string): Promise<void> {
const { useClipboard } = await import("@vueuse/core");
await useClipboard().copy(message);
const { copyToClipboard } = await import("../services/ClipboardService");
await copyToClipboard(message);
}
/**