Compare commits
2
Commits
0c45bde832
...
ad1cbcf792
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad1cbcf792 | ||
|
|
492b57c60d |
@@ -49,6 +49,7 @@ import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
|
||||
import { useCallEndedToast } from '../hooks/useCallEndedToast';
|
||||
import { useCallRejoin } from '../hooks/useCallRejoin';
|
||||
import { usePttHaptics } from '../hooks/usePttHaptics';
|
||||
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
||||
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
|
||||
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
||||
@@ -747,6 +748,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
useCallJoinLeaveSounds(embed);
|
||||
useCallPolicyRevokedToast(embed, joined);
|
||||
useCallEndedToast(embed);
|
||||
usePttHaptics();
|
||||
useCallAnnouncements(embed, joined);
|
||||
useMutedTalkWarning(embed, joined);
|
||||
useCallThemeSync(embed);
|
||||
|
||||
@@ -40,6 +40,8 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../../utils/room';
|
||||
import { describeRoomVisibility } from '../../../utils/roomVisibilityLine';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { useObjectURL } from '../../../hooks/useObjectURL';
|
||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
@@ -434,6 +436,14 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
|
||||
const name = useRoomName(room);
|
||||
const topic = useRoomTopic(room);
|
||||
const joinRule = useRoomJoinRule(room);
|
||||
// [Gitea #133] One line, no controls: encryption · join rule · history.
|
||||
const historyVisibilityEvent = useStateEvent(room, StateEvent.RoomHistoryVisibility);
|
||||
const visibilityLine = describeRoomVisibility({
|
||||
encrypted: room.hasEncryptionStateEvent(),
|
||||
joinRule: joinRule?.join_rule,
|
||||
historyVisibility: historyVisibilityEvent?.getContent<{ history_visibility?: string }>()
|
||||
.history_visibility,
|
||||
});
|
||||
|
||||
const canEditAvatar = permissions.stateEvent(StateEvent.RoomAvatar, mx.getSafeUserId());
|
||||
const canEditName = permissions.stateEvent(StateEvent.RoomName, mx.getSafeUserId());
|
||||
@@ -474,6 +484,9 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
|
||||
<Text className={BreakWord} size="H5">
|
||||
{name ?? 'Unknown'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" className={BreakWord}>
|
||||
{visibilityLine}
|
||||
</Text>
|
||||
{topic && (
|
||||
<Text className={classNames(BreakWord, LineClamp3)} size="T200">
|
||||
{topic.format === 'org.matrix.custom.html' &&
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
reactionOrEditEvent,
|
||||
} from '../../utils/room';
|
||||
import { getLastEditDiff } from '../../utils/editDiff';
|
||||
import { tick } from '../../utils/haptics';
|
||||
import { GroupCandidate, GroupPlan, planMediaGroups } from '../../utils/mediaGroups';
|
||||
import { MediaGroupGrid, RegroupChip } from './message/MediaGroupGrid';
|
||||
import { Lightbox, toLightboxItems } from './MediaGallery';
|
||||
@@ -478,6 +479,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
// Read positions are computed once in Room.tsx and provided via ReadPositionsContext
|
||||
// so both RoomTimeline and ThreadTimeline consume the same value (Gitea #38).
|
||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||
const [hapticFeedback] = useSetting(settingsAtom, 'hapticFeedback');
|
||||
// [Gitea #137] Re-render after "Show separately" (the Set itself is module-level).
|
||||
const [, setSeparatedTick] = useState(0);
|
||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||
@@ -1157,13 +1159,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const rShortcode =
|
||||
shortcode ||
|
||||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
|
||||
tick('reaction', hapticFeedback);
|
||||
mx.sendEvent(
|
||||
room.roomId,
|
||||
MessageEvent.Reaction as any,
|
||||
getReactionContent(targetEventId, key, rShortcode),
|
||||
);
|
||||
},
|
||||
[mx, room],
|
||||
[mx, room, hapticFeedback],
|
||||
);
|
||||
const handleEdit = useCallback(
|
||||
(editEvtId?: string) => {
|
||||
|
||||
@@ -78,6 +78,7 @@ import { ImageViewer } from '../../../components/image-viewer';
|
||||
import * as css from './ThreadTimeline.css';
|
||||
import { inSameDay, minuteDifference } from '../../../utils/time';
|
||||
import { formatDayDivider } from '../../../utils/formatTimestamp';
|
||||
import { tick } from '../../../utils/haptics';
|
||||
import { createMentionElement, isEmptyEditor, moveCursor } from '../../../components/editor';
|
||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||
import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
|
||||
@@ -266,6 +267,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [hapticFeedback] = useSetting(settingsAtom, 'hapticFeedback');
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
@@ -573,6 +575,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
const rShortcode =
|
||||
shortcode ||
|
||||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
|
||||
tick('reaction', hapticFeedback);
|
||||
mx.sendEvent(
|
||||
room.roomId,
|
||||
// A reaction on the root is a main-timeline event, not a thread reply.
|
||||
@@ -582,7 +585,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
getReactionContent(targetEventId, key, rShortcode),
|
||||
);
|
||||
},
|
||||
[mx, room, thread, getRelationTimelineSet],
|
||||
[mx, room, thread, getRelationTimelineSet, hapticFeedback],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
|
||||
@@ -122,6 +122,7 @@ import { DenoiseTester } from './DenoiseTester';
|
||||
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||
import { isBindableCallKey } from '../../../utils/callKeybind';
|
||||
import { StorageUsage } from './StorageUsage';
|
||||
import { hapticsSupported } from '../../../utils/haptics';
|
||||
|
||||
/**
|
||||
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
|
||||
@@ -1653,6 +1654,7 @@ function Calls() {
|
||||
|
||||
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart');
|
||||
const [haptics, setHaptics] = useSetting(settingsAtom, 'hapticFeedback');
|
||||
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||
const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
|
||||
@@ -1963,6 +1965,13 @@ function Calls() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{hapticsSupported() && (
|
||||
<SettingTile
|
||||
title="Haptic Feedback"
|
||||
description="A short vibration when push-to-talk engages or releases and when you send a reaction. Off automatically when your system prefers reduced motion."
|
||||
after={<Switch variant="Primary" value={haptics} onChange={setHaptics} />}
|
||||
/>
|
||||
)}
|
||||
<SettingTile
|
||||
title="Push to Talk"
|
||||
description="Mute your microphone by default. Hold the PTT key to speak."
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { pttActiveAtom } from './useCallHotkeys';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { tick } from '../utils/haptics';
|
||||
|
||||
/**
|
||||
* [Gitea #125] One observer for every push-to-talk path (keyboard, global
|
||||
* hotkey, on-screen chip): a tick on engage and on release.
|
||||
*/
|
||||
export function usePttHaptics(): void {
|
||||
const active = useAtomValue(pttActiveAtom);
|
||||
const [enabled] = useSetting(settingsAtom, 'hapticFeedback');
|
||||
const prev = useRef(active);
|
||||
useEffect(() => {
|
||||
if (prev.current === active) return;
|
||||
prev.current = active;
|
||||
tick(active ? 'ptt-on' : 'ptt-off', enabled);
|
||||
}, [active, enabled]);
|
||||
}
|
||||
@@ -273,6 +273,8 @@ export interface Settings {
|
||||
stripImageMetadata: boolean;
|
||||
// [Gitea #118] After a crash/update/reload while in a voice room: ask, rejoin, or nothing.
|
||||
callRejoinAfterRestart: 'ask' | 'auto' | 'off';
|
||||
// [Gitea #125] Vibration ticks on PTT press/release and reactions (Android only).
|
||||
hapticFeedback: boolean;
|
||||
|
||||
// [Gitea #104] Mirror user preferences to `io.lotus.settings` account data
|
||||
// so other devices pick them up. Device-local itself (utils/settingsSync).
|
||||
@@ -397,6 +399,7 @@ const defaultSettings: Settings = {
|
||||
stripTrackingParams: true,
|
||||
stripImageMetadata: true,
|
||||
callRejoinAfterRestart: 'ask',
|
||||
hapticFeedback: true,
|
||||
|
||||
settingsSync: true,
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { hapticsSupported, tick } from './haptics';
|
||||
|
||||
const g = globalThis as { navigator?: unknown; window?: unknown };
|
||||
|
||||
test('tick is a no-op without the API, with the setting off, or under reduced motion', () => {
|
||||
g.navigator = {};
|
||||
assert.equal(hapticsSupported(), false);
|
||||
assert.equal(tick('ptt-on'), false);
|
||||
|
||||
const calls: number[] = [];
|
||||
g.navigator = { vibrate: (ms: number) => calls.push(ms) === 1 };
|
||||
g.window = { matchMedia: () => ({ matches: true }) };
|
||||
assert.equal(tick('ptt-on'), false);
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
g.window = { matchMedia: () => ({ matches: false }) };
|
||||
assert.equal(tick('ptt-on', false), false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('tick vibrates per kind when allowed', () => {
|
||||
const calls: number[] = [];
|
||||
g.navigator = { vibrate: (ms: number) => calls.push(ms) > 0 };
|
||||
g.window = { matchMedia: () => ({ matches: false }) };
|
||||
assert.equal(tick('ptt-on'), true);
|
||||
assert.equal(tick('ptt-off'), true);
|
||||
assert.equal(tick('reaction'), true);
|
||||
assert.deepEqual(calls, [10, 10, 8]);
|
||||
delete g.navigator;
|
||||
delete g.window;
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* [Gitea #125] Tiny vibration ticks for touch feedback. Android Chrome/PWA
|
||||
* only — iOS has no web vibration API — and nothing at all when the user
|
||||
* prefers reduced motion. Every call is a cheap no-op elsewhere.
|
||||
*/
|
||||
export type HapticKind = 'ptt-on' | 'ptt-off' | 'reaction';
|
||||
|
||||
const PATTERN: Record<HapticKind, number> = { 'ptt-on': 10, 'ptt-off': 10, reaction: 8 };
|
||||
|
||||
export const hapticsSupported = (): boolean =>
|
||||
typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
|
||||
|
||||
const reducedMotion = (): boolean =>
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/** Fire a tick; returns whether one was requested. */
|
||||
export function tick(kind: HapticKind, enabled = true): boolean {
|
||||
if (!enabled || !hapticsSupported() || reducedMotion()) return false;
|
||||
try {
|
||||
return navigator.vibrate(PATTERN[kind]) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { describeRoomVisibility } from './roomVisibilityLine';
|
||||
|
||||
test('describeRoomVisibility composes the three facts', () => {
|
||||
assert.equal(
|
||||
describeRoomVisibility({ encrypted: true, joinRule: 'invite', historyVisibility: 'joined' }),
|
||||
'Encrypted · Invite only · History visible to members since they joined',
|
||||
);
|
||||
assert.equal(
|
||||
describeRoomVisibility({ encrypted: false, joinRule: 'public', historyVisibility: 'shared' }),
|
||||
'Not encrypted · Anyone can join · History visible to members, including before they joined',
|
||||
);
|
||||
assert.equal(
|
||||
describeRoomVisibility({
|
||||
encrypted: false,
|
||||
joinRule: 'knock',
|
||||
historyVisibility: 'world_readable',
|
||||
}),
|
||||
'Not encrypted · Ask to join · History visible to anyone, even guests',
|
||||
);
|
||||
// Missing state: spec defaults are invite-only and "shared".
|
||||
assert.equal(
|
||||
describeRoomVisibility({ encrypted: true }),
|
||||
'Encrypted · Invite only · History visible to members, including before they joined',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { HistoryVisibility, JoinRule } from 'matrix-js-sdk';
|
||||
|
||||
/**
|
||||
* [Gitea #133] One sentence for Room Settings → General, under the name:
|
||||
* "Encrypted · Invite only · History visible to members since they joined".
|
||||
* Wording mirrors the room preview card chips and the history-visibility
|
||||
* setting so the same words mean the same thing everywhere.
|
||||
*/
|
||||
export function describeRoomVisibility(input: {
|
||||
encrypted: boolean;
|
||||
joinRule?: string;
|
||||
historyVisibility?: string;
|
||||
}): string {
|
||||
const parts: string[] = [input.encrypted ? 'Encrypted' : 'Not encrypted'];
|
||||
switch (input.joinRule) {
|
||||
case JoinRule.Public:
|
||||
parts.push('Anyone can join');
|
||||
break;
|
||||
case JoinRule.Knock:
|
||||
case 'knock_restricted':
|
||||
parts.push('Ask to join');
|
||||
break;
|
||||
case JoinRule.Restricted:
|
||||
parts.push('Restricted');
|
||||
break;
|
||||
case JoinRule.Private:
|
||||
parts.push('Private');
|
||||
break;
|
||||
case JoinRule.Invite:
|
||||
default:
|
||||
parts.push('Invite only');
|
||||
}
|
||||
switch (input.historyVisibility) {
|
||||
case HistoryVisibility.WorldReadable:
|
||||
parts.push('History visible to anyone, even guests');
|
||||
break;
|
||||
case HistoryVisibility.Invited:
|
||||
parts.push('History visible to members since they were invited');
|
||||
break;
|
||||
case HistoryVisibility.Joined:
|
||||
parts.push('History visible to members since they joined');
|
||||
break;
|
||||
case HistoryVisibility.Shared:
|
||||
default:
|
||||
// "shared" is also the spec default when the state event is absent.
|
||||
parts.push('History visible to members, including before they joined');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
Reference in New Issue
Block a user