mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf5d75d5e9 | ||
|
|
53520a4236 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -79,7 +79,8 @@ function makeStyles(c: ThemePalette) {
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: c.card,
|
||||
borderRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderBottomRightRadius: radius.lg,
|
||||
marginBottom: spacing.sm,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
|
||||
@@ -8,8 +8,9 @@ import type { ContactCard, Email, CalendarEvent } from '../../api/types';
|
||||
import type { RootStackParamList } from '../../navigation/types';
|
||||
import { getEmails, queryEmailsByFilter } from '../../api/email';
|
||||
import { useCalendarStore } from '../../stores/calendar-store';
|
||||
import { getEventColor } from '../../lib/calendar-utils';
|
||||
import SenderAvatar from '../SenderAvatar';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { radius, spacing, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
const EMAIL_LIMIT = 5;
|
||||
@@ -87,6 +88,7 @@ export function ContactActivity({ contact }: Props) {
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const calendarEvents = useCalendarStore((s) => s.events);
|
||||
const calendars = useCalendarStore((s) => s.calendars);
|
||||
|
||||
const addresses = React.useMemo(() => getContactEmails(contact), [contact]);
|
||||
const addressKey = addresses.join(',');
|
||||
@@ -145,74 +147,85 @@ export function ContactActivity({ contact }: Props) {
|
||||
<View style={styles.wrap}>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MailIcon size={14} color={c.primary} />
|
||||
<Text style={styles.sectionTitle}>Recent emails</Text>
|
||||
</View>
|
||||
{emailsLoading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
<View style={styles.sectionIcon}>
|
||||
<MailIcon size={16} color={c.textMuted} />
|
||||
</View>
|
||||
) : emailsError ? (
|
||||
<Text style={styles.emptyText}>Couldn't load emails.</Text>
|
||||
) : !emails || emails.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No emails with this contact.</Text>
|
||||
) : (
|
||||
emails.map((email) => {
|
||||
const sender = email.from?.[0];
|
||||
const senderName = sender?.name || sender?.email || 'Unknown';
|
||||
return (
|
||||
<Pressable
|
||||
key={email.id}
|
||||
onPress={() =>
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject ?? undefined,
|
||||
})
|
||||
}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<SenderAvatar name={senderName} email={sender?.email} size={32} />
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowTop}>
|
||||
<Text style={styles.rowSender} numberOfLines={1}>{senderName}</Text>
|
||||
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt)}</Text>
|
||||
<Text style={styles.sectionLabel}>Recent emails</Text>
|
||||
</View>
|
||||
<View style={styles.sectionBody}>
|
||||
{emailsLoading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
</View>
|
||||
) : emailsError ? (
|
||||
<Text style={styles.emptyText}>Couldn't load emails.</Text>
|
||||
) : !emails || emails.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No emails with this contact.</Text>
|
||||
) : (
|
||||
emails.map((email) => {
|
||||
const sender = email.from?.[0];
|
||||
const senderName = sender?.name || sender?.email || 'Unknown';
|
||||
return (
|
||||
<Pressable
|
||||
key={email.id}
|
||||
onPress={() =>
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject ?? undefined,
|
||||
})
|
||||
}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<SenderAvatar name={senderName} email={sender?.email} size={32} />
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowTop}>
|
||||
<Text style={styles.rowSender} numberOfLines={1}>{senderName}</Text>
|
||||
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt)}</Text>
|
||||
</View>
|
||||
<Text style={styles.rowSubject} numberOfLines={1}>
|
||||
{email.subject || '(no subject)'}
|
||||
</Text>
|
||||
{email.preview ? (
|
||||
<Text style={styles.rowPreview} numberOfLines={1}>{email.preview}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.rowSubject} numberOfLines={1}>
|
||||
{email.subject || '(no subject)'}
|
||||
</Text>
|
||||
{email.preview ? (
|
||||
<Text style={styles.rowPreview} numberOfLines={1}>{email.preview}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<CalendarDays size={14} color={c.primary} />
|
||||
<Text style={styles.sectionTitle}>Upcoming events</Text>
|
||||
<View style={styles.sectionIcon}>
|
||||
<CalendarDays size={16} color={c.textMuted} />
|
||||
</View>
|
||||
<Text style={styles.sectionLabel}>Upcoming events</Text>
|
||||
</View>
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No upcoming events.</Text>
|
||||
) : (
|
||||
upcomingEvents.map((event) => (
|
||||
<View key={event.id} style={styles.eventRow}>
|
||||
<Text style={styles.eventTime}>{formatEventTime(event)}</Text>
|
||||
<View style={styles.eventInfo}>
|
||||
<Text style={styles.eventTitle} numberOfLines={1}>
|
||||
{event.title || '(no title)'}
|
||||
</Text>
|
||||
<Text style={styles.eventDate}>
|
||||
{formatRelativeDate(event.utcStart || event.start)}
|
||||
</Text>
|
||||
<View style={styles.sectionBody}>
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No upcoming events.</Text>
|
||||
) : (
|
||||
upcomingEvents.map((event) => (
|
||||
<View key={event.id} style={styles.eventRow}>
|
||||
<View
|
||||
style={[styles.eventDot, { backgroundColor: getEventColor(event, calendars) }]}
|
||||
/>
|
||||
<Text style={styles.eventTime}>{formatEventTime(event)}</Text>
|
||||
<View style={styles.eventInfo}>
|
||||
<Text style={styles.eventTitle} numberOfLines={1}>
|
||||
{event.title || '(no title)'}
|
||||
</Text>
|
||||
<Text style={styles.eventDate}>
|
||||
{formatRelativeDate(event.utcStart || event.start)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -220,18 +233,18 @@ export function ContactActivity({ contact }: Props) {
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
wrap: { gap: spacing.md, paddingHorizontal: spacing.lg, marginBottom: spacing.lg },
|
||||
section: {
|
||||
backgroundColor: c.card,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: c.primary,
|
||||
wrap: { gap: spacing.lg },
|
||||
section: { gap: spacing.sm },
|
||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
sectionIcon: { width: 16 },
|
||||
sectionLabel: {
|
||||
...typography.bodyMedium,
|
||||
color: c.textSecondary,
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 11,
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
|
||||
sectionTitle: { ...typography.bodySemibold, color: c.text },
|
||||
sectionBody: { paddingLeft: spacing.lg + spacing.xs, gap: spacing.xs },
|
||||
loading: { paddingVertical: spacing.sm, alignItems: 'flex-start' },
|
||||
emptyText: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
@@ -251,10 +264,16 @@ function makeStyles(c: ThemePalette) {
|
||||
rowPreview: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
eventRow: { flexDirection: 'row', gap: spacing.sm, alignItems: 'baseline', paddingVertical: 4 },
|
||||
eventDot: {
|
||||
width: componentSizes.eventDot,
|
||||
height: componentSizes.eventDot,
|
||||
borderRadius: componentSizes.eventDot / 2,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
eventTime: {
|
||||
...typography.caption,
|
||||
color: c.textMuted,
|
||||
width: 64,
|
||||
width: 52,
|
||||
},
|
||||
eventInfo: { flex: 1, minWidth: 0 },
|
||||
eventTitle: { ...typography.body, color: c.text },
|
||||
|
||||
@@ -23,7 +23,12 @@ export default function TagAssignSheet({
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const keywords = useContactsStore(selectKeywordsUsed);
|
||||
// Subscribe to the stable `contacts` array and derive the keyword list in a
|
||||
// memo. Passing `selectKeywordsUsed` straight to the store would return a
|
||||
// fresh array on every call, which trips useSyncExternalStore's snapshot
|
||||
// check and spins into an infinite render loop.
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const keywords = React.useMemo(() => selectKeywordsUsed(contacts), [contacts]);
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
|
||||
|
||||
@@ -22,7 +22,14 @@ export function ContactsSettings() {
|
||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const books = useContactsStore(selectAddressBooksWithCount);
|
||||
const addressBooks = useContactsStore((s) => s.addressBooks);
|
||||
// Derive in a memo from stable store fields. Subscribing with
|
||||
// `selectAddressBooksWithCount` directly returns a new array each call and
|
||||
// sends useSyncExternalStore into an infinite render loop.
|
||||
const books = React.useMemo(
|
||||
() => selectAddressBooksWithCount(addressBooks, contacts),
|
||||
[addressBooks, contacts],
|
||||
);
|
||||
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
|
||||
const fetchContacts = useContactsStore((s) => s.fetchContacts);
|
||||
const createAddressBook = useContactsStore((s) => s.createAddressBook);
|
||||
|
||||
@@ -376,9 +376,9 @@ export function selectGroups(state: ContactsState): ContactCard[] {
|
||||
return state.contacts.filter(isGroup);
|
||||
}
|
||||
|
||||
export function selectKeywordsUsed(state: ContactsState): Array<{ keyword: string; count: number }> {
|
||||
export function selectKeywordsUsed(contacts: ContactCard[]): Array<{ keyword: string; count: number }> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const contact of state.contacts) {
|
||||
for (const contact of contacts) {
|
||||
for (const kw of getContactKeywords(contact)) {
|
||||
counts.set(kw, (counts.get(kw) || 0) + 1);
|
||||
}
|
||||
@@ -388,10 +388,13 @@ export function selectKeywordsUsed(state: ContactsState): Array<{ keyword: strin
|
||||
.sort((a, b) => a.keyword.localeCompare(b.keyword));
|
||||
}
|
||||
|
||||
export function selectAddressBooksWithCount(state: ContactsState): Array<AddressBook & { count: number }> {
|
||||
return state.addressBooks.map((book) => ({
|
||||
export function selectAddressBooksWithCount(
|
||||
addressBooks: AddressBook[],
|
||||
contacts: ContactCard[],
|
||||
): Array<AddressBook & { count: number }> {
|
||||
return addressBooks.map((book) => ({
|
||||
...book,
|
||||
count: state.contacts.filter((c) => !isGroup(c) && c.addressBookIds?.[book.id]).length,
|
||||
count: contacts.filter((c) => !isGroup(c) && c.addressBookIds?.[book.id]).length,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user